Compare commits

...

18 Commits

Author SHA1 Message Date
Michilis
5bc8c31a7f Add .history folder to .gitignore to prevent committing .env settings 2025-12-25 18:25:40 +00:00
Michilis
d1ce0f0bef Disable keyboard buttons completely in group chats
- Added getReplyMarkupForChat utility to explicitly remove keyboards in groups
- Updated all group chat messages to remove keyboards
- Prevents keyboards from being re-enabled when users press buttons
- Ensures keyboards are disabled for all users in group chats
2025-12-25 18:03:16 +00:00
Michilis
ff9c1f1dcf Remove keyboard buttons for users in groups when pressed 2025-12-20 02:38:56 +00:00
Michilis
83298dc4ca Update API client and notification scheduler services 2025-12-20 02:26:40 +00:00
Michilis
1dce27ea42 Filter out expired invoices from My Tickets view 2025-12-19 21:14:15 +00:00
Michilis
3bc067f691 feat: display truncated lightning address for winners
- Add truncateLightningAddress utility (shows first 2 chars + ******)
- Backend: Include winner_address in past-wins API response
- Frontend: Display truncated address in past winners list
- Telegram: Add truncated address to draw announcements for transparency

Example: username@blink.sv -> us******@blink.sv
2025-12-12 16:20:18 +00:00
Michilis
00f09236a3 feat(telegram): improve group handling and default display name to @username
- /lottosettings now opens settings in private DM instead of group
- Bot only reacts to / commands in groups (keyboard buttons ignored)
- Reply keyboard buttons removed from group messages
- Default display name now uses @username instead of 'Anon'
- Users can still manually update display name in settings
- Updated all display name usages to use centralized getDisplayName()
2025-12-12 15:28:05 +00:00
Michilis
959268e7c1 Improve startFirst error message for group users 2025-12-10 18:54:46 +00:00
Michilis
fb378d56c5 Fix draw notification spam and winning ticket number
- Group participants by telegramId to send only ONE message per user
- First pass finds the winning ticket info before sending any messages
- Loser messages now show the actual winning ticket number instead of #0000
- Unique participant count used for group announcements
2025-12-09 01:15:40 +00:00
Michilis
fcd180b7a4 Add 'Upcoming Jackpot' button to Telegram bot menu
Shows current prize pool, ticket price, draw time, and time remaining
2025-12-09 01:04:27 +00:00
Michilis
404fdf2610 Maintenance mode activates after current draw completes
- When admin enables maintenance, it's set to 'pending' state
- Maintenance activates automatically after the current draw completes
- Admin can use immediate=true to force immediate activation
- Frontend shows 'Maintenance Scheduled' banner when pending
- Telegram bot warns users but still allows purchases when pending
- Both mode and pending status tracked in system_settings table
2025-12-09 00:46:55 +00:00
Michilis
0eb8a6c580 Fix: Fetch fresh pot amount when sending draw reminders
Reminders now fetch current cycle data instead of using cached data
from when the reminder was scheduled, ensuring accurate pot amounts.
2025-12-09 00:20:36 +00:00
Michilis
d1ede9ee8d Fix: Add database migration for new_jackpot_delay_minutes column
Existing databases were missing the new column, causing SQLITE_ERROR
when updating group settings. Added migration logic to add missing columns.
2025-12-08 23:57:08 +00:00
Michilis
86e2e0a321 Fix reminder scheduling and add group features
- Fix reminder duplicate bug: use slot-only keys to prevent multiple reminders when settings change
- Add New Jackpot announcement delay setting for groups (default 5 min)
- Cancel unpaid purchases after draw completes (prevents payments for past rounds)
- Add BotFather commands template file
- Update README documentation
2025-12-08 23:49:54 +00:00
Michilis
13fd2b8989 Add SQLite database for Telegram bot user/group settings
- Replace Redis/in-memory storage with SQLite for persistence
- Add database.ts service with tables for users, groups, purchases, participants
- Update state.ts and groupState.ts to use SQLite backend
- Fix buyer_name to use display name instead of Telegram ID
- Remove legacy reminder array handlers (now using 3-slot system)
- Add better-sqlite3 dependency, remove ioredis
- Update env.example with BOT_DATABASE_PATH option
- Add data/ directory to .gitignore for database files
2025-12-08 22:33:40 +00:00
Michilis
dd6b26c524 feat: Mobile optimization and UI improvements
- Add mobile hamburger menu in TopBar with slide-in panel
- Optimize all components for mobile (responsive fonts, spacing, touch targets)
- Add proper viewport meta tags and safe area padding
- Fix /jackpot/next API to return active cycles regardless of scheduled time
- Remove BTC display from jackpot pot (show sats only)
- Add setup/ folder to .gitignore
- Improve mobile UX: 16px inputs (no iOS zoom), 44px touch targets
- Add active states for touch feedback on buttons
2025-12-08 15:51:13 +00:00
Michilis
2fea2dc836 feat: Add configurable draw cycles, improve UX, fix builds
Backend:
- Add configurable draw cycle settings (minutes/hourly/daily/weekly/custom)
- Add CYCLE_TYPE, CYCLE_INTERVAL_*, CYCLE_DAILY_TIME, CYCLE_WEEKLY_* env vars
- Add SALES_CLOSE_BEFORE_DRAW_MINUTES and CYCLES_TO_GENERATE_AHEAD
- Fix SQLite parameter issue in scheduler
- Fix TypeScript error in webhooks controller

Frontend:
- Add 'Save This Link' section with copy button on ticket status page
- Improve draw animation to show immediately when draw starts
- Show 'Waiting for next round...' instead of 'Drawing Now!' after draw
- Hide Buy Tickets button when waiting for next round
- Skip draw animation if no tickets were sold
- Keep winner screen open longer (15s) for next cycle to load
- Auto-refresh to next lottery cycle after draw

Telegram Bot:
- Various improvements and fixes
2025-11-28 03:44:10 +00:00
Michilis
918d3bc31e feat: Add configurable draw cycles, improve UX
Backend:
- Add configurable draw cycle settings (minutes/hourly/daily/weekly/custom)
- Add CYCLE_TYPE, CYCLE_INTERVAL_*, CYCLE_DAILY_TIME, CYCLE_WEEKLY_* env vars
- Add SALES_CLOSE_BEFORE_DRAW_MINUTES and CYCLES_TO_GENERATE_AHEAD
- Fix SQLite parameter issue in scheduler

Frontend:
- Add 'Save This Link' section with copy button on ticket status page
- Improve draw animation to show immediately when draw starts
- Show 'Waiting for next round...' instead of 'Drawing Now!' after draw
- Hide Buy Tickets button when waiting for next round
- Skip draw animation if no tickets were sold
- Keep winner screen open longer (15s) for next cycle to load
- Auto-refresh to next lottery cycle after draw

Telegram Bot:
- Various improvements and fixes
2025-11-28 03:24:17 +00:00
55 changed files with 5589 additions and 1007 deletions

3
.gitignore vendored
View File

@@ -31,6 +31,7 @@ App_info/
# IDE and editor files
.idea/
.vscode/
.history/
*.swp
*.swo
*~
@@ -88,3 +89,5 @@ ehthumbs.db
*.key
secrets/
# Setup/deployment configs (contains server-specific settings)
setup/

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.
@@ -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
- 📱 **Mobile First**: Beautiful responsive design optimized for all devices
- 🏆 **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
- 🎰 **Random Tickets**: Ticket numbers are randomly generated, not sequential
- 📊 **Swagger Docs**: Full API documentation at `/api-docs`
- 🤖 **Telegram Bot**: Full-featured bot for buying tickets and receiving notifications
## Architecture
The system consists of three main components:
The system consists of four main components:
1. **Backend API** (Node.js + TypeScript + Express)
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
- RESTful API with comprehensive endpoints
- LNbits integration for Lightning payments
- Automated scheduler for draws and cycle generation
- Configurable cycle types (minutes, hours, days, weekly, cron)
- JWT authentication for Nostr users
- Admin API for manual operations
- Payment monitoring with polling fallback
@@ -36,13 +39,23 @@ The system consists of three main components:
### Frontend
- Server-side rendered Next.js application
- Redux state management
- Real-time countdown timers
- Invoice QR code display with payment animations
- Automatic status polling
- Nostr NIP-07 authentication
- Draw animation with winner reveal
- 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
@@ -58,7 +71,8 @@ cd LightningLotto
```bash
cp back_end/env.example back_end/.env
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:
@@ -113,6 +127,22 @@ npm run build
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
### Required Environment Variables
@@ -138,6 +168,15 @@ ADMIN_API_KEY=your-admin-api-key
DEFAULT_TICKET_PRICE_SATS=1000
DEFAULT_HOUSE_FEE_PERCENT=5
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)
@@ -146,7 +185,16 @@ PAYOUT_MAX_ATTEMPTS_BEFORE_REDRAW=2
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
@@ -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).
## 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
7 main tables:
### Backend Database (7 main tables)
- `lotteries` - Lottery configuration
- `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)
- `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
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
4. **Ticket Issuance**: Random ticket numbers assigned in database transaction
5. **Draw Execution**: At scheduled time, winner selected using CSPRNG
6. **Payout**: Winner's Lightning Address paid automatically
7. **Retry/Redraw**: Failed payouts retried; new winner drawn after max attempts
6. **Notifications**: Telegram bot sends draw results to participants and groups
7. **Payout**: Winner's Lightning Address paid automatically
8. **Retry/Redraw**: Failed payouts retried; new winner drawn after max attempts
## Security Features
@@ -219,6 +305,7 @@ Full API documentation available at `/api-docs` (Swagger UI).
- `crypto.randomBytes()` for winner selection
- `crypto.randomInt()` for ticket number generation
- No floating-point math (BIGINT for all sats)
- Lightning Address LNURL verification
## Frontend Pages
@@ -233,21 +320,18 @@ Full API documentation available at `/api-docs` (Swagger UI).
| `/dashboard/tickets` | User's ticket history |
| `/dashboard/wins` | User's win history |
## Testing
## Production Deployment
### Backend Tests
```bash
cd back_end
npm test
```
### Systemd Services & Nginx
### Frontend Tests
```bash
cd front_end
npm test
```
The `setup/` folder contains production-ready configuration files:
## 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
@@ -298,11 +382,38 @@ LightningLotto/
│ ├── components/ # React components
│ ├── config/ # Frontend config
│ ├── constants/ # Text strings
── lib/ # API client and utilities
│ └── store/ # Redux state
── lib/ # API client and utilities
├── 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
```
## Testing
### Backend Tests
```bash
cd back_end
npm test
```
### Frontend Tests
```bash
cd front_end
npm test
```
## License
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
- Uses [Nostr](https://nostr.com/) for decentralized authentication
- Telegram bot integration for mobile-first experience
- Inspired by the Bitcoin Lightning Network community
---

View File

@@ -73,6 +73,46 @@ DEFAULT_HOUSE_FEE_PERCENT=5
# Maximum Lightning payout attempts before drawing a new winner
PAYOUT_MAX_ATTEMPTS=2
# ======================
# Draw Cycle Configuration
# ======================
# Cycle type: "minutes" | "hourly" | "daily" | "weekly" | "custom"
CYCLE_TYPE=hourly
# ---- For MINUTES cycle ----
# Draw every X minutes (e.g., 30 = every 30 minutes)
CYCLE_INTERVAL_MINUTES=60
# ---- For HOURLY cycle ----
# Draw every X hours (e.g., 1 = every hour, 4 = every 4 hours)
CYCLE_INTERVAL_HOURS=1
# ---- For DAILY cycle ----
# Draw time in 24-hour format (HH:MM in UTC)
# Example: 18:00 = 6 PM UTC
CYCLE_DAILY_TIME=18:00
# ---- For WEEKLY cycle ----
# Day of week: 0=Sunday, 1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday
CYCLE_WEEKLY_DAY=6
# Draw time in 24-hour format (HH:MM in UTC)
CYCLE_WEEKLY_TIME=20:00
# ---- For CUSTOM cycle (advanced) ----
# Cron expression for custom schedules
# Format: "minute hour day-of-month month day-of-week"
# Example: "0 */4 * * *" = every 4 hours
# Example: "0 20 * * 6" = every Saturday at 8 PM
# Example: "30 12,18 * * *" = 12:30 PM and 6:30 PM daily
CYCLE_CRON_EXPRESSION=0 * * * *
# ---- Sales Window ----
# How many minutes before the draw to stop accepting tickets
SALES_CLOSE_BEFORE_DRAW_MINUTES=5
# How many cycles to pre-generate in advance
CYCLES_TO_GENERATE_AHEAD=5
# ======================
# Notes
# ======================

View File

@@ -42,6 +42,20 @@ const allowedCorsOrigins = (() => {
return Array.from(new Set([...frontendOrigins, appBaseUrl]));
})();
type CycleType = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'custom';
interface CycleConfig {
type: CycleType;
intervalMinutes: number;
intervalHours: number;
dailyTime: string;
weeklyDay: number;
weeklyTime: string;
cronExpression: string;
salesCloseBeforeDrawMinutes: number;
cyclesToGenerateAhead: number;
}
interface Config {
app: {
port: number;
@@ -75,6 +89,7 @@ interface Config {
defaultHouseFeePercent: number;
maxTicketsPerPurchase: number;
};
cycle: CycleConfig;
admin: {
apiKey: string;
};
@@ -119,6 +134,17 @@ const config: Config = {
defaultHouseFeePercent: parseInt(process.env.DEFAULT_HOUSE_FEE_PERCENT || '5', 10),
maxTicketsPerPurchase: 100,
},
cycle: {
type: (process.env.CYCLE_TYPE || 'hourly') as CycleType,
intervalMinutes: parseInt(process.env.CYCLE_INTERVAL_MINUTES || '60', 10),
intervalHours: parseInt(process.env.CYCLE_INTERVAL_HOURS || '1', 10),
dailyTime: process.env.CYCLE_DAILY_TIME || '18:00',
weeklyDay: parseInt(process.env.CYCLE_WEEKLY_DAY || '6', 10),
weeklyTime: process.env.CYCLE_WEEKLY_TIME || '20:00',
cronExpression: process.env.CYCLE_CRON_EXPRESSION || '0 * * * *',
salesCloseBeforeDrawMinutes: parseInt(process.env.SALES_CLOSE_BEFORE_DRAW_MINUTES || '5', 10),
cyclesToGenerateAhead: parseInt(process.env.CYCLES_TO_GENERATE_AHEAD || '5', 10),
},
admin: {
apiKey: process.env.ADMIN_API_KEY || '',
},
@@ -149,6 +175,51 @@ function validateConfig(): void {
console.error('❌ DATABASE_TYPE must be either "postgres" or "sqlite"');
process.exit(1);
}
// Validate cycle type
const validCycleTypes: CycleType[] = ['minutes', 'hourly', 'daily', 'weekly', 'custom'];
if (!validCycleTypes.includes(config.cycle.type)) {
console.error(`❌ CYCLE_TYPE must be one of: ${validCycleTypes.join(', ')}`);
process.exit(1);
}
// Validate weekly day (0-6)
if (config.cycle.weeklyDay < 0 || config.cycle.weeklyDay > 6) {
console.error('❌ CYCLE_WEEKLY_DAY must be between 0 (Sunday) and 6 (Saturday)');
process.exit(1);
}
// Validate time formats (HH:MM)
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (!timeRegex.test(config.cycle.dailyTime)) {
console.error('❌ CYCLE_DAILY_TIME must be in HH:MM format (24-hour)');
process.exit(1);
}
if (!timeRegex.test(config.cycle.weeklyTime)) {
console.error('❌ CYCLE_WEEKLY_TIME must be in HH:MM format (24-hour)');
process.exit(1);
}
// Log cycle configuration
console.log(`✓ Cycle configuration: ${config.cycle.type}`);
switch (config.cycle.type) {
case 'minutes':
console.log(` → Draw every ${config.cycle.intervalMinutes} minutes`);
break;
case 'hourly':
console.log(` → Draw every ${config.cycle.intervalHours} hour(s)`);
break;
case 'daily':
console.log(` → Draw daily at ${config.cycle.dailyTime} UTC`);
break;
case 'weekly':
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
console.log(` → Draw every ${days[config.cycle.weeklyDay]} at ${config.cycle.weeklyTime} UTC`);
break;
case 'custom':
console.log(` → Cron: ${config.cycle.cronExpression}`);
break;
}
}
if (config.app.nodeEnv !== 'test') {

View File

@@ -189,3 +189,122 @@ export async function retryPayout(req: Request, res: Response) {
}
}
/**
* GET /admin/maintenance
* Get current maintenance mode status
*/
export async function getMaintenanceStatus(req: Request, res: Response) {
try {
const result = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_mode'`
);
const pendingResult = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_pending'`
);
const messageResult = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_message'`
);
const enabled = result.rows[0]?.value === 'true';
const pending = pendingResult.rows[0]?.value === 'true';
const message = messageResult.rows[0]?.value || 'System is under maintenance. Please try again later.';
return res.json({
version: '1.0',
data: {
maintenance_mode: enabled,
maintenance_pending: pending,
message: message,
},
});
} catch (error: any) {
console.error('Get maintenance status error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to get maintenance status',
});
}
}
/**
* POST /admin/maintenance
* Enable or disable maintenance mode
* When enabling, maintenance is set to "pending" and activates after current draw completes
*/
export async function setMaintenanceMode(req: Request, res: Response) {
try {
const { enabled, message, immediate } = req.body;
if (typeof enabled !== 'boolean') {
return res.status(400).json({
version: '1.0',
error: 'INVALID_INPUT',
message: 'enabled must be a boolean',
});
}
if (enabled) {
if (immediate === true) {
// Immediate activation (admin override)
await db.query(
`INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_mode', 'true', datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = datetime('now')`
);
await db.query(
`INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_pending', 'false', datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = 'false', updated_at = datetime('now')`
);
console.log('Maintenance mode ENABLED IMMEDIATELY');
} else {
// Set pending - will activate after current draw completes
await db.query(
`INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_pending', 'true', datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = datetime('now')`
);
console.log('Maintenance mode PENDING (will activate after current draw)');
}
} else {
// Disable both active and pending maintenance
await db.query(
`INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_mode', 'false', datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = 'false', updated_at = datetime('now')`
);
await db.query(
`INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_pending', 'false', datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = 'false', updated_at = datetime('now')`
);
console.log('Maintenance mode DISABLED');
}
// Update message if provided
if (message && typeof message === 'string') {
await db.query(
`INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_message', $1, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = $1, updated_at = datetime('now')`,
[message]
);
}
// Get current state
const modeResult = await db.query(`SELECT value FROM system_settings WHERE key = 'maintenance_mode'`);
const pendingResult = await db.query(`SELECT value FROM system_settings WHERE key = 'maintenance_pending'`);
return res.json({
version: '1.0',
data: {
maintenance_mode: modeResult.rows[0]?.value === 'true',
maintenance_pending: pendingResult.rows[0]?.value === 'true',
message: message || 'System is under maintenance. Please try again later.',
},
});
} catch (error: any) {
console.error('Set maintenance mode error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to set maintenance mode',
});
}
}

View File

@@ -8,6 +8,68 @@ import config from '../config';
import { JackpotCycle, TicketPurchase, Ticket, Payout } from '../types';
import { AuthRequest } from '../middleware/auth';
/**
* GET /status/maintenance
* Public endpoint to check maintenance mode
*/
export async function getPublicMaintenanceStatus(req: Request, res: Response) {
try {
const result = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_mode'`
);
const pendingResult = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_pending'`
);
const messageResult = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_message'`
);
const enabled = result.rows[0]?.value === 'true';
const pending = pendingResult.rows[0]?.value === 'true';
const message = messageResult.rows[0]?.value || 'System is under maintenance. Please try again later.';
return res.json({
version: '1.0',
data: {
maintenance_mode: enabled,
maintenance_pending: pending,
message: enabled ? message : (pending ? 'Maintenance will begin after the current draw completes.' : null),
},
});
} catch (error: any) {
// If table doesn't exist yet, return not in maintenance
return res.json({
version: '1.0',
data: {
maintenance_mode: false,
maintenance_pending: false,
message: null,
},
});
}
}
/**
* Helper to check if system is in maintenance mode
*/
async function isMaintenanceMode(): Promise<{ enabled: boolean; message: string }> {
try {
const result = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_mode'`
);
const messageResult = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_message'`
);
return {
enabled: result.rows[0]?.value === 'true',
message: messageResult.rows[0]?.value || 'System is under maintenance. Please try again later.',
};
} catch {
return { enabled: false, message: '' };
}
}
const toIsoString = (value: any): string => {
if (!value) {
return new Date().toISOString();
@@ -65,16 +127,28 @@ export async function getNextJackpot(req: Request, res: Response) {
const lottery = lotteryResult.rows[0];
// Get next cycle
const cycleResult = await db.query<JackpotCycle>(
// Get next cycle - first try to find one that hasn't drawn yet
let cycleResult = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles
WHERE lottery_id = $1
AND status IN ('scheduled', 'sales_open')
AND status IN ('scheduled', 'sales_open', 'drawing')
ORDER BY scheduled_at ASC
LIMIT 1`,
[lottery.id]
);
// If no active cycles, get the next upcoming one
if (cycleResult.rows.length === 0) {
cycleResult = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles
WHERE lottery_id = $1
AND status = 'scheduled'
AND scheduled_at > NOW()
ORDER BY scheduled_at ASC
LIMIT 1`,
[lottery.id]
);
}
if (cycleResult.rows.length === 0) {
return res.status(503).json({
@@ -128,6 +202,16 @@ export async function getNextJackpot(req: Request, res: Response) {
*/
export async function buyTickets(req: AuthRequest, res: Response) {
try {
// Check maintenance mode first
const maintenance = await isMaintenanceMode();
if (maintenance.enabled) {
return res.status(503).json({
version: '1.0',
error: 'MAINTENANCE_MODE',
message: maintenance.message,
});
}
const { tickets, lightning_address, nostr_pubkey, name, buyer_name } = req.body;
const userId = req.user?.id || null;
const authNostrPubkey = req.user?.nostr_pubkey || null;
@@ -419,6 +503,23 @@ interface PastWinRow {
pot_after_fee_sats: number | null;
buyer_name: string | null;
serial_number: number | null;
winning_lightning_address: string | null;
}
/**
* Truncate lightning address for privacy
* "username@blink.sv" -> "us******@blink.sv"
*/
function truncateLightningAddress(address: string | null): string | null {
if (!address || !address.includes('@')) return address;
const [username, domain] = address.split('@');
// Show first 2 chars of username, then asterisks
const visibleChars = Math.min(2, username.length);
const truncatedUsername = username.substring(0, visibleChars) + '******';
return `${truncatedUsername}@${domain}`;
}
/**
@@ -439,6 +540,7 @@ export async function getPastWins(req: Request, res: Response) {
jc.scheduled_at,
jc.pot_total_sats,
jc.pot_after_fee_sats,
jc.winning_lightning_address,
tp.buyer_name,
t.serial_number
FROM jackpot_cycles jc
@@ -460,6 +562,7 @@ export async function getPastWins(req: Request, res: Response) {
? parseInt(row.pot_after_fee_sats.toString())
: null,
winner_name: row.buyer_name || 'Anon',
winner_address: truncateLightningAddress(row.winning_lightning_address),
winning_ticket_serial: row.serial_number
? parseInt(row.serial_number.toString())
: null,

View File

@@ -18,12 +18,12 @@ export async function handleLNbitsPayment(req: Request, res: Response) {
const webhookSecretQuery = (() => {
const value = req.query?.secret;
if (Array.isArray(value)) {
return value[0];
return typeof value[0] === 'string' ? value[0] : undefined;
}
return value as string | undefined;
return typeof value === 'string' ? value : undefined;
})();
const providedSecret = webhookSecretHeader || webhookSecretQuery || '';
const providedSecret: string = webhookSecretHeader || webhookSecretQuery || '';
if (!lnbitsService.verifyWebhook(providedSecret)) {
console.error('Webhook verification failed');

View File

@@ -158,6 +158,17 @@ class DatabaseWrapper {
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Initialize default settings
INSERT OR IGNORE INTO system_settings (key, value) VALUES ('maintenance_mode', 'false');
INSERT OR IGNORE INTO system_settings (key, value) VALUES ('maintenance_pending', 'false');
INSERT OR IGNORE INTO system_settings (key, value) VALUES ('maintenance_message', 'System is under maintenance. Please try again later.');
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_cycles_status_time ON jackpot_cycles(status, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_paymenthash ON ticket_purchases(lnbits_payment_hash);

View File

@@ -3,7 +3,9 @@ import {
listCycles,
runDrawManually,
retryPayout,
listPayouts
listPayouts,
getMaintenanceStatus,
setMaintenanceMode
} from '../controllers/admin';
import { verifyAdmin } from '../middleware/auth';
@@ -126,5 +128,78 @@ router.get('/payouts', listPayouts);
*/
router.post('/payouts/:id/retry', retryPayout);
/**
* @swagger
* /admin/maintenance:
* get:
* summary: Get maintenance mode status
* tags: [Admin]
* security:
* - adminKey: []
* responses:
* 200:
* description: Maintenance status
* content:
* application/json:
* schema:
* type: object
* properties:
* maintenance_mode:
* type: boolean
* message:
* type: string
* 403:
* description: Invalid admin key
*/
router.get('/maintenance', getMaintenanceStatus);
/**
* @swagger
* /admin/maintenance:
* post:
* summary: Enable or disable maintenance mode
* description: When enabling, maintenance is set to "pending" and activates after current draw completes. Use immediate=true to activate immediately.
* tags: [Admin]
* security:
* - adminKey: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - enabled
* properties:
* enabled:
* type: boolean
* description: Whether to enable maintenance mode
* message:
* type: string
* description: Custom maintenance message
* immediate:
* type: boolean
* description: If true, activate immediately instead of waiting for draw to complete
* responses:
* 200:
* description: Maintenance mode updated
* content:
* application/json:
* schema:
* type: object
* properties:
* maintenance_mode:
* type: boolean
* maintenance_pending:
* type: boolean
* message:
* type: string
* 400:
* description: Invalid input
* 403:
* description: Invalid admin key
*/
router.post('/maintenance', setMaintenanceMode);
export default router;

View File

@@ -3,7 +3,8 @@ import {
getNextJackpot,
buyTickets,
getTicketStatus,
getPastWins
getPastWins,
getPublicMaintenanceStatus
} from '../controllers/public';
import { buyRateLimiter, ticketStatusRateLimiter } from '../middleware/rateLimit';
import { optionalAuth } from '../middleware/auth';
@@ -187,5 +188,32 @@ router.get('/jackpot/past-wins', getPastWins);
*/
router.get('/tickets/:id', ticketStatusRateLimiter, getTicketStatus);
/**
* @swagger
* /status/maintenance:
* get:
* summary: Check if system is in maintenance mode
* tags: [Public]
* responses:
* 200:
* description: Maintenance status
* content:
* application/json:
* schema:
* type: object
* properties:
* version:
* type: string
* data:
* type: object
* properties:
* maintenance_mode:
* type: boolean
* message:
* type: string
* nullable: true
*/
router.get('/status/maintenance', getPublicMaintenanceStatus);
export default router;

View File

@@ -4,9 +4,120 @@ import { executeDraw } from '../services/draw';
import { autoRetryFailedPayouts } from '../services/payout';
import config from '../config';
import { JackpotCycle } from '../types';
import { randomUUID } from 'crypto';
/**
* Generate future cycles for all cycle types
* Calculate the next scheduled draw time based on cycle configuration
*/
function calculateNextDrawTime(fromDate: Date): Date {
const cycleConfig = config.cycle;
let next = new Date(fromDate);
switch (cycleConfig.type) {
case 'minutes':
// Add interval minutes
next = new Date(next.getTime() + cycleConfig.intervalMinutes * 60 * 1000);
break;
case 'hourly':
// Add interval hours
next = new Date(next.getTime() + cycleConfig.intervalHours * 60 * 60 * 1000);
// Round to the start of the hour
next.setMinutes(0, 0, 0);
break;
case 'daily':
// Move to next day at specified time
const [dailyHour, dailyMinute] = cycleConfig.dailyTime.split(':').map(Number);
next.setUTCDate(next.getUTCDate() + 1);
next.setUTCHours(dailyHour, dailyMinute, 0, 0);
break;
case 'weekly':
// Find next occurrence of the specified day
const [weeklyHour, weeklyMinute] = cycleConfig.weeklyTime.split(':').map(Number);
const targetDay = cycleConfig.weeklyDay;
const currentDay = next.getUTCDay();
// Calculate days until target day
let daysUntilTarget = targetDay - currentDay;
if (daysUntilTarget <= 0) {
daysUntilTarget += 7;
}
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
next.setUTCHours(weeklyHour, weeklyMinute, 0, 0);
break;
case 'custom':
// Parse cron and calculate next occurrence
next = calculateNextCronTime(cycleConfig.cronExpression, next);
break;
}
return next;
}
/**
* Calculate next occurrence from a cron expression
*/
function calculateNextCronTime(cronExpr: string, fromDate: Date): Date {
// Simple cron parser for common patterns
// Format: "minute hour day-of-month month day-of-week"
const parts = cronExpr.trim().split(/\s+/);
if (parts.length !== 5) {
console.error('Invalid cron expression:', cronExpr);
// Fallback to 1 hour from now
return new Date(fromDate.getTime() + 60 * 60 * 1000);
}
const [minutePart, hourPart] = parts;
let next = new Date(fromDate);
// Handle common patterns
if (hourPart.startsWith('*/')) {
// Every X hours
const interval = parseInt(hourPart.slice(2), 10);
next = new Date(next.getTime() + interval * 60 * 60 * 1000);
next.setMinutes(parseInt(minutePart, 10) || 0, 0, 0);
} else if (minutePart.startsWith('*/')) {
// Every X minutes
const interval = parseInt(minutePart.slice(2), 10);
next = new Date(next.getTime() + interval * 60 * 1000);
} else {
// Specific time - move to next occurrence
const targetHour = hourPart === '*' ? next.getUTCHours() : parseInt(hourPart, 10);
const targetMinute = minutePart === '*' ? 0 : parseInt(minutePart, 10);
next.setUTCHours(targetHour, targetMinute, 0, 0);
if (next <= fromDate) {
next.setUTCDate(next.getUTCDate() + 1);
}
}
return next;
}
/**
* Get the cycle type name for database storage based on config
*/
function getCycleTypeName(): 'hourly' | 'daily' | 'weekly' | 'monthly' {
switch (config.cycle.type) {
case 'minutes':
case 'hourly':
case 'custom':
return 'hourly';
case 'daily':
return 'daily';
case 'weekly':
return 'weekly';
default:
return 'hourly';
}
}
/**
* Generate future cycles based on configuration
*/
async function generateFutureCycles(): Promise<void> {
try {
@@ -23,36 +134,34 @@ async function generateFutureCycles(): Promise<void> {
}
const lottery = lotteryResult.rows[0];
const cycleTypes: Array<'hourly' | 'daily' | 'weekly' | 'monthly'> = ['hourly', 'daily'];
const cycleType = getCycleTypeName();
const cyclesToGenerate = config.cycle.cyclesToGenerateAhead;
const salesCloseMinutes = config.cycle.salesCloseBeforeDrawMinutes;
for (const cycleType of cycleTypes) {
await generateCyclesForType(lottery.id, cycleType);
// Get existing future cycles count
const existingResult = await db.query<{ count: string }>(
`SELECT COUNT(*) as count FROM jackpot_cycles
WHERE lottery_id = $1
AND status IN ('scheduled', 'sales_open')
AND scheduled_at > NOW()`,
[lottery.id]
);
const existingCount = parseInt(existingResult.rows[0]?.count || '0', 10);
const cyclesToCreate = Math.max(0, cyclesToGenerate - existingCount);
if (cyclesToCreate === 0) {
console.log(`Already have ${existingCount} future cycles, no generation needed`);
return;
}
} catch (error) {
console.error('Cycle generation error:', error);
}
}
/**
* Generate cycles for a specific type
*/
async function generateCyclesForType(
lotteryId: string,
cycleType: 'hourly' | 'daily' | 'weekly' | 'monthly'
): Promise<void> {
try {
// Determine horizon (how far in the future to generate)
const horizonHours = cycleType === 'hourly' ? 48 : 168; // 48h for hourly, 1 week for daily
const horizonDate = new Date(Date.now() + horizonHours * 60 * 60 * 1000);
// Get latest cycle for this type
// Get the latest cycle to determine next sequence number and time
const latestResult = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles
WHERE lottery_id = $1 AND cycle_type = $2
ORDER BY sequence_number DESC
WHERE lottery_id = $1
ORDER BY scheduled_at DESC
LIMIT 1`,
[lotteryId, cycleType]
[lottery.id]
);
let lastScheduledAt: Date;
@@ -68,45 +177,17 @@ async function generateCyclesForType(
sequenceNumber = latest.sequence_number;
}
// Generate cycles until horizon
while (lastScheduledAt < horizonDate) {
// Generate new cycles
for (let i = 0; i < cyclesToCreate; i++) {
sequenceNumber++;
// Calculate next scheduled time
let nextScheduledAt: Date;
const nextScheduledAt = calculateNextDrawTime(lastScheduledAt);
switch (cycleType) {
case 'hourly':
nextScheduledAt = new Date(lastScheduledAt.getTime() + 60 * 60 * 1000);
break;
case 'daily':
nextScheduledAt = new Date(lastScheduledAt.getTime() + 24 * 60 * 60 * 1000);
nextScheduledAt.setHours(20, 0, 0, 0); // 8 PM UTC
break;
case 'weekly':
nextScheduledAt = new Date(lastScheduledAt.getTime() + 7 * 24 * 60 * 60 * 1000);
break;
case 'monthly':
nextScheduledAt = new Date(lastScheduledAt);
nextScheduledAt.setMonth(nextScheduledAt.getMonth() + 1);
break;
}
// Sales open immediately, close at draw time
// Sales open now, close X minutes before draw
const salesOpenAt = new Date();
const salesCloseAt = nextScheduledAt;
const salesCloseAt = new Date(nextScheduledAt.getTime() - salesCloseMinutes * 60 * 1000);
// Check if cycle already exists
const existingResult = await db.query(
`SELECT id FROM jackpot_cycles
WHERE lottery_id = $1 AND cycle_type = $2 AND sequence_number = $3`,
[lotteryId, cycleType, sequenceNumber]
);
if (existingResult.rows.length === 0) {
// Create new cycle with explicit UUID generation
const crypto = require('crypto');
const cycleId = crypto.randomUUID();
const cycleId = randomUUID();
await db.query(
`INSERT INTO jackpot_cycles (
@@ -115,7 +196,7 @@ async function generateCyclesForType(
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
cycleId,
lotteryId,
lottery.id,
cycleType,
sequenceNumber,
nextScheduledAt.toISOString(),
@@ -125,14 +206,15 @@ async function generateCyclesForType(
]
);
console.log(`Created ${cycleType} cycle #${sequenceNumber} (${cycleId}) for ${nextScheduledAt.toISOString()}`);
}
console.log(`Created cycle #${sequenceNumber} (${cycleId}) for ${nextScheduledAt.toISOString()}`);
lastScheduledAt = nextScheduledAt;
}
console.log(`Generated ${cyclesToCreate} new cycles`);
} catch (error) {
console.error(`Error generating ${cycleType} cycles:`, error);
console.error('Cycle generation error:', error);
}
}
@@ -160,6 +242,10 @@ async function checkAndExecuteDraws(): Promise<void> {
for (const cycle of result.rows) {
console.log(`Executing draw for cycle ${cycle.id} (${cycle.cycle_type})`);
await executeDraw(cycle.id);
// Cancel unpaid invoices for this cycle after draw
await cancelUnpaidPurchases(cycle.id);
// Check if maintenance was pending and activate it
await activatePendingMaintenance();
}
} catch (error) {
@@ -167,23 +253,104 @@ 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);
}
}
/**
* Check if maintenance is pending and activate it after draw completion
*/
async function activatePendingMaintenance(): Promise<void> {
try {
const pendingResult = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_pending'`
);
if (pendingResult.rows[0]?.value === 'true') {
// Activate maintenance mode
await db.query(
`INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_mode', 'true', datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = datetime('now')`
);
// Clear pending flag
await db.query(
`INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_pending', 'false', datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = 'false', updated_at = datetime('now')`
);
console.log('🔧 MAINTENANCE MODE ACTIVATED (pending maintenance enabled after draw)');
}
} catch (error) {
console.error('Error activating pending maintenance:', error);
}
}
/**
* Update cycles to 'sales_open' status when appropriate
*/
async function updateCycleStatuses(): Promise<void> {
try {
const now = new Date().toISOString();
// Update scheduled cycles to sales_open when sales period starts
await db.query(
`UPDATE jackpot_cycles
SET status = 'sales_open', updated_at = NOW()
WHERE status = 'scheduled'
AND sales_open_at <= $1
AND scheduled_at > $2`,
[now, now]
);
} catch (error) {
console.error('Cycle status update error:', error);
}
}
/**
* Start all schedulers
*/
export function startSchedulers(): void {
console.log('Starting schedulers...');
// Log cycle configuration
console.log(`Cycle type: ${config.cycle.type}`);
// Cycle generator - every 5 minutes (or configured interval)
const cycleGenInterval = Math.max(config.scheduler.cycleGeneratorIntervalSeconds, 60);
cron.schedule(`*/${Math.floor(cycleGenInterval / 60)} * * * *`, generateFutureCycles);
const cycleGenMinutes = Math.max(1, Math.floor(cycleGenInterval / 60));
cron.schedule(`*/${cycleGenMinutes} * * * *`, generateFutureCycles);
console.log(`✓ Cycle generator scheduled (every ${cycleGenInterval}s)`);
console.log(`✓ Cycle generator scheduled (every ${cycleGenMinutes} minute(s))`);
// Draw executor - every minute (or configured interval)
const drawInterval = Math.max(config.scheduler.drawIntervalSeconds, 30);
cron.schedule(`*/${Math.floor(drawInterval / 60)} * * * *`, checkAndExecuteDraws);
const drawMinutes = Math.max(1, Math.floor(drawInterval / 60));
cron.schedule(`*/${drawMinutes} * * * *`, async () => {
await updateCycleStatuses();
await checkAndExecuteDraws();
});
console.log(`✓ Draw executor scheduled (every ${drawInterval}s)`);
console.log(`✓ Draw executor scheduled (every ${drawMinutes} minute(s))`);
// Payout retry - every 10 minutes
cron.schedule('*/10 * * * *', autoRetryFailedPayouts);
@@ -191,9 +358,9 @@ export function startSchedulers(): void {
console.log(`✓ Payout retry scheduled (every 10 minutes)`);
// Run immediately on startup
setTimeout(() => {
generateFutureCycles();
checkAndExecuteDraws();
setTimeout(async () => {
await generateFutureCycles();
await updateCycleStatuses();
await checkAndExecuteDraws();
}, 5000);
}

View File

@@ -17,6 +17,8 @@ export default function BuyPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [jackpot, setJackpot] = useState<any>(null);
const [maintenanceMode, setMaintenanceMode] = useState(false);
const [maintenanceMessage, setMaintenanceMessage] = useState<string | null>(null);
// Form state
const [lightningAddress, setLightningAddress] = useState('');
@@ -125,6 +127,11 @@ export default function BuyPage() {
const loadJackpot = async () => {
try {
// Check maintenance status first
const maintenanceStatus = await api.getMaintenanceStatus();
setMaintenanceMode(maintenanceStatus.maintenance_mode);
setMaintenanceMessage(maintenanceStatus.message);
const response = await api.getNextJackpot();
if (response.data) {
setJackpot(response.data);
@@ -221,16 +228,40 @@ export default function BuyPage() {
const ticketPriceSats = jackpot.lottery.ticket_price_sats;
const totalCost = ticketPriceSats * tickets;
// Show maintenance message if in maintenance mode
if (maintenanceMode) {
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white">
<div className="max-w-2xl mx-auto px-1">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-6 sm:mb-8 text-center text-white">
{STRINGS.buy.title}
</h1>
<div className="bg-gradient-to-r from-amber-900/60 via-amber-800/50 to-amber-900/60 border border-amber-500/50 rounded-xl p-6 sm:p-8 text-center">
<span className="text-5xl mb-4 block">🔧</span>
<h2 className="text-2xl font-bold text-amber-300 mb-3">Maintenance Mode</h2>
<p className="text-amber-200/80 text-lg">
{maintenanceMessage || 'System is under maintenance. Please try again later.'}
</p>
<button
onClick={() => router.push('/')}
className="mt-6 bg-amber-700 hover:bg-amber-600 text-white px-6 py-2 rounded-lg transition-colors"
>
Back to Home
</button>
</div>
</div>
);
}
return (
<div className="max-w-2xl mx-auto px-1">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-6 sm:mb-8 text-center text-white">
{STRINGS.buy.title}
</h1>
{!invoice ? (
/* Purchase Form */
<div className="bg-gray-900 rounded-xl p-8 border border-gray-800">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="bg-gray-900 rounded-xl p-5 sm:p-8 border border-gray-800">
<form onSubmit={handleSubmit} className="space-y-5 sm:space-y-6">
{/* Lightning Address */}
<div>
<label className="block text-gray-300 mb-2 font-medium">
@@ -321,7 +352,7 @@ export default function BuyPage() {
<button
type="submit"
disabled={loading}
className="w-full bg-bitcoin-orange hover:bg-orange-600 disabled:bg-gray-600 text-white py-4 rounded-lg text-lg font-bold transition-colors"
className="w-full bg-bitcoin-orange hover:bg-orange-600 active:bg-orange-700 disabled:bg-gray-600 text-white py-3.5 sm:py-4 rounded-lg text-base sm:text-lg font-bold transition-colors"
>
{loading ? 'Creating Invoice...' : STRINGS.buy.createInvoice}
</button>

View File

@@ -10,8 +10,17 @@
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Prevent horizontal overflow on mobile */
html, body {
overflow-x: hidden;
max-width: 100vw;
}
/* Touch-friendly tap targets */
@layer utilities {
.text-balance {
text-wrap: balance;
@@ -20,6 +29,39 @@ body {
.animate-fade-in {
animation: fade-in 0.5s ease-out forwards;
}
/* Mobile-friendly tap targets */
.touch-target {
min-height: 44px;
min-width: 44px;
}
/* Hide scrollbar while maintaining functionality */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Safe area padding for notched devices */
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-left {
padding-left: env(safe-area-inset-left);
}
.safe-right {
padding-right: env(safe-area-inset-right);
}
}
@keyframes fade-in {
@@ -33,7 +75,8 @@ body {
}
}
/* Custom scrollbar */
/* Custom scrollbar (desktop) */
@media (hover: hover) and (pointer: fine) {
::-webkit-scrollbar {
width: 8px;
}
@@ -50,4 +93,50 @@ body {
::-webkit-scrollbar-thumb:hover {
background: #555;
}
}
/* Mobile-specific styles */
@media (max-width: 640px) {
/* Larger touch targets on mobile */
button,
a,
input[type="button"],
input[type="submit"],
[role="button"] {
min-height: 44px;
}
/* Better input styling on mobile */
input, textarea, select {
font-size: 16px; /* Prevents zoom on iOS */
}
/* Smoother scrolling on mobile */
* {
-webkit-overflow-scrolling: touch;
}
}
/* Prevent iOS bounce/pull-to-refresh on certain elements */
.no-bounce {
overscroll-behavior: none;
}
/* Focus visible only for keyboard navigation */
@media (hover: none) and (pointer: coarse) {
*:focus {
outline: none;
}
}
/* Print styles */
@media print {
nav, footer, button {
display: none !important;
}
body {
background: white;
color: black;
}
}

View File

@@ -1,4 +1,4 @@
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';
@@ -7,9 +7,25 @@ import { Footer } from '@/components/Footer';
const inter = Inter({ subsets: ['latin'] });
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 5,
userScalable: true,
themeColor: '#0b0b0b',
};
export const metadata: Metadata = {
title: 'Lightning Lottery - Win Bitcoin',
description: 'Bitcoin Lightning Network powered lottery with instant payouts',
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Lightning Lotto',
},
formatDetection: {
telephone: false,
},
};
export default function RootLayout({

View File

@@ -27,9 +27,26 @@ export default function HomePage() {
const [ticketId, setTicketId] = useState('');
const [recentWinner, setRecentWinner] = useState<RecentWinner | null>(null);
const [showDrawAnimation, setShowDrawAnimation] = useState(false);
const [drawInProgress, setDrawInProgress] = useState(false);
const [drawJustCompleted, setDrawJustCompleted] = useState(false);
const [winnerBannerDismissed, setWinnerBannerDismissed] = useState(false);
const [isRecentWin, setIsRecentWin] = useState(false);
const [awaitingNextCycle, setAwaitingNextCycle] = useState(false);
const [pendingWinner, setPendingWinner] = useState<RecentWinner | null>(null);
const [maintenanceMode, setMaintenanceMode] = useState(false);
const [maintenancePending, setMaintenancePending] = useState(false);
const [maintenanceMessage, setMaintenanceMessage] = useState<string | null>(null);
const loadMaintenanceStatus = useCallback(async () => {
try {
const status = await api.getMaintenanceStatus();
setMaintenanceMode(status.maintenance_mode);
setMaintenancePending(status.maintenance_pending);
setMaintenanceMessage(status.message);
} catch {
// Ignore errors, assume not in maintenance
}
}, []);
const loadJackpot = useCallback(async () => {
try {
@@ -73,27 +90,113 @@ export default function HomePage() {
}, [drawJustCompleted]);
useEffect(() => {
loadMaintenanceStatus();
loadJackpot();
loadRecentWinner();
}, [loadJackpot, loadRecentWinner]);
}, [loadMaintenanceStatus, loadJackpot, loadRecentWinner]);
// Poll for draw completion when countdown reaches zero
// Detect when draw time passes and trigger draw animation (only if tickets were sold)
useEffect(() => {
if (!jackpot?.cycle?.scheduled_at) return;
if (!jackpot?.cycle?.scheduled_at || awaitingNextCycle) return;
const scheduledTime = new Date(jackpot.cycle.scheduled_at).getTime();
const hasTicketsSold = jackpot.cycle.pot_total_sats > 0;
const checkForDraw = () => {
const scheduledTime = new Date(jackpot.cycle.scheduled_at).getTime();
const now = Date.now();
// If we're past the scheduled time, start polling for the winner
if (now >= scheduledTime && !drawJustCompleted) {
loadRecentWinner();
// If we're past the scheduled time, start the draw
if (now >= scheduledTime) {
setAwaitingNextCycle(true);
setDrawInProgress(true);
// Only show draw animation if tickets were sold
if (hasTicketsSold) {
setShowDrawAnimation(true);
}
}
};
const interval = setInterval(checkForDraw, 5000);
const interval = setInterval(checkForDraw, 1000);
return () => clearInterval(interval);
}, [jackpot?.cycle?.scheduled_at, drawJustCompleted, loadRecentWinner]);
}, [jackpot?.cycle?.scheduled_at, jackpot?.cycle?.pot_total_sats, awaitingNextCycle]);
// When awaiting next cycle, poll for winner and next cycle
useEffect(() => {
if (!awaitingNextCycle) return;
const currentCycleId = jackpot?.cycle?.id;
let attempts = 0;
const maxAttempts = 12; // Try for up to 60 seconds (12 * 5s)
let foundWinner = false;
const checkForWinner = async () => {
try {
const response = await api.getPastWins(1, 0);
if (response.data?.wins?.length > 0) {
const latestWin = response.data.wins[0];
const winTime = new Date(latestWin.scheduled_at).getTime();
const now = Date.now();
const thirtySeconds = 30 * 1000;
// Check if this is a recent win (within 30 seconds)
if (now - winTime < thirtySeconds && !foundWinner) {
foundWinner = true;
setPendingWinner(latestWin);
setRecentWinner(latestWin);
setIsRecentWin(true);
}
}
} catch (err) {
console.error('Failed to check for winner:', err);
}
};
const tryFetchNextCycle = async () => {
attempts++;
// Check for winner each time
await checkForWinner();
try {
const response = await api.getNextJackpot();
if (response.data) {
// Check if we got a new cycle (different ID or scheduled time in future)
const newScheduledTime = new Date(response.data.cycle.scheduled_at).getTime();
const isNewCycle = response.data.cycle.id !== currentCycleId || newScheduledTime > Date.now();
if (isNewCycle) {
console.log('New cycle found, updating jackpot...');
setJackpot(response.data);
setAwaitingNextCycle(false);
setDrawJustCompleted(true);
// Don't hide animation here - let it complete naturally
// Animation will call handleAnimationComplete when done
return;
}
}
} catch (err) {
console.error('Failed to fetch next jackpot:', err);
}
// Keep trying if we haven't exceeded max attempts
if (attempts < maxAttempts) {
setTimeout(tryFetchNextCycle, 5000);
} else {
// Give up and just refresh with whatever we have
console.log('Max attempts reached, forcing refresh...');
setAwaitingNextCycle(false);
setDrawInProgress(false);
setShowDrawAnimation(false);
loadJackpot();
}
};
// Start polling after 5 seconds (give backend time to process draw)
// We poll in background while animation plays
const initialDelay = setTimeout(tryFetchNextCycle, 5000);
return () => clearTimeout(initialDelay);
}, [awaitingNextCycle, jackpot?.cycle?.id, loadJackpot]);
const handleCheckTicket = () => {
if (ticketId.trim()) {
@@ -103,14 +206,8 @@ export default function HomePage() {
const handleAnimationComplete = () => {
setShowDrawAnimation(false);
};
const handlePlayAgain = () => {
setDrawJustCompleted(false);
setWinnerBannerDismissed(true);
setIsRecentWin(false);
loadJackpot();
loadRecentWinner();
setDrawInProgress(false);
setPendingWinner(null);
};
const handleDismissWinnerBanner = () => {
@@ -146,33 +243,68 @@ export default function HomePage() {
// Only show winner banner if: recent win (within 60s), not dismissed, and animation not showing
const showWinnerBanner = isRecentWin && recentWinner && !showDrawAnimation && !winnerBannerDismissed;
// Check if we're waiting for the next round (countdown passed, waiting for next cycle)
const scheduledTime = jackpot?.cycle?.scheduled_at ? new Date(jackpot.cycle.scheduled_at).getTime() : 0;
const isWaitingForNextRound = (awaitingNextCycle || drawJustCompleted) && Date.now() >= scheduledTime;
return (
<div className="max-w-4xl mx-auto">
{/* Draw Animation Overlay */}
{showDrawAnimation && recentWinner && (
{showDrawAnimation && (
<DrawAnimation
winnerName={recentWinner.winner_name}
winningTicket={recentWinner.winning_ticket_serial}
potAmount={recentWinner.pot_after_fee_sats}
winnerName={pendingWinner?.winner_name || recentWinner?.winner_name}
winningTicket={pendingWinner?.winning_ticket_serial || recentWinner?.winning_ticket_serial}
potAmount={pendingWinner?.pot_after_fee_sats || recentWinner?.pot_after_fee_sats}
onComplete={handleAnimationComplete}
isDrawing={drawInProgress}
/>
)}
{/* Maintenance Mode Banner */}
{maintenanceMode && (
<div className="bg-gradient-to-r from-amber-900/60 via-amber-800/50 to-amber-900/60 border border-amber-500/50 rounded-xl sm:rounded-2xl p-4 sm:p-6 mb-6 sm:mb-8">
<div className="flex items-center justify-center gap-3">
<span className="text-3xl">🔧</span>
<div className="text-center">
<h3 className="text-xl font-bold text-amber-300">Maintenance Mode</h3>
<p className="text-amber-200/80 mt-1">
{maintenanceMessage || 'System is under maintenance. Please try again later.'}
</p>
</div>
</div>
</div>
)}
{/* Pending Maintenance Banner */}
{!maintenanceMode && maintenancePending && (
<div className="bg-gradient-to-r from-blue-900/40 via-blue-800/30 to-blue-900/40 border border-blue-500/50 rounded-xl sm:rounded-2xl p-4 sm:p-6 mb-6 sm:mb-8">
<div className="flex items-center justify-center gap-3">
<span className="text-2xl"></span>
<div className="text-center">
<h3 className="text-lg font-bold text-blue-300">Maintenance Scheduled</h3>
<p className="text-blue-200/80 mt-1">
Maintenance will begin after the current draw completes. Last chance to buy tickets!
</p>
</div>
</div>
</div>
)}
{/* Hero Section */}
<div className="text-center mb-12">
<h1 className="text-4xl md:text-6xl font-bold mb-4 text-white">
<div className="text-center mb-8 sm:mb-12">
<h1 className="text-3xl sm:text-4xl md:text-6xl font-bold mb-3 sm:mb-4 text-white">
{STRINGS.app.title}
</h1>
<p className="text-xl text-gray-400">{STRINGS.app.tagline}</p>
<p className="text-lg sm:text-xl text-gray-400">{STRINGS.app.tagline}</p>
</div>
{/* Recent Winner Banner - Only shown for 60 seconds after draw */}
{showWinnerBanner && (
<div className="bg-gradient-to-r from-yellow-900/40 via-yellow-800/30 to-yellow-900/40 border border-yellow-600/50 rounded-2xl p-6 mb-8 animate-fade-in relative">
<div className="bg-gradient-to-r from-yellow-900/40 via-yellow-800/30 to-yellow-900/40 border border-yellow-600/50 rounded-xl sm:rounded-2xl p-4 sm:p-6 mb-6 sm:mb-8 animate-fade-in relative">
{/* Close button */}
<button
onClick={handleDismissWinnerBanner}
className="absolute top-3 right-3 text-yellow-400/60 hover:text-yellow-400 transition-colors p-1"
className="absolute top-2 right-2 sm:top-3 sm:right-3 text-yellow-400/60 hover:text-yellow-400 transition-colors p-2"
aria-label="Dismiss"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -180,16 +312,16 @@ export default function HomePage() {
</svg>
</button>
<div className="text-center">
<div className="text-yellow-400 text-sm uppercase tracking-wider mb-2">
<div className="text-yellow-400 text-xs sm:text-sm uppercase tracking-wider mb-2">
🏆 Latest Winner
</div>
<div className="text-2xl md:text-3xl font-bold text-white mb-2">
<div className="text-xl sm:text-2xl md:text-3xl font-bold text-white mb-2">
{recentWinner.winner_name || 'Anon'}
</div>
<div className="text-yellow-400 text-xl font-mono mb-3">
<div className="text-yellow-400 text-lg sm:text-xl font-mono mb-2 sm:mb-3">
Won {recentWinner.pot_after_fee_sats.toLocaleString()} sats
</div>
<div className="text-gray-400 text-sm">
<div className="text-gray-400 text-xs sm:text-sm">
Ticket #{recentWinner.winning_ticket_serial.toLocaleString()}
</div>
</div>
@@ -197,53 +329,50 @@ export default function HomePage() {
)}
{/* Current Jackpot Card */}
<div className="bg-gray-900 rounded-2xl p-8 md:p-12 mb-8 border border-gray-800">
<h2 className="text-2xl font-semibold text-center mb-6 text-gray-300">
<div className="bg-gray-900 rounded-xl sm:rounded-2xl p-5 sm:p-8 md:p-12 mb-6 sm:mb-8 border border-gray-800">
<h2 className="text-xl sm:text-2xl font-semibold text-center mb-4 sm:mb-6 text-gray-300">
{STRINGS.home.currentJackpot}
</h2>
{/* Pot Display */}
<div className="mb-8">
<div className="mb-6 sm:mb-8">
<JackpotPotDisplay potTotalSats={jackpot.cycle.pot_total_sats} />
</div>
{/* Countdown */}
<div className="mb-8">
<div className="text-center text-gray-400 mb-4">
<div className="mb-6 sm:mb-8">
<div className="text-center text-gray-400 mb-3 sm:mb-4 text-sm sm:text-base">
{STRINGS.home.drawIn}
</div>
<div className="flex justify-center">
<JackpotCountdown scheduledAt={jackpot.cycle.scheduled_at} />
<div className="flex justify-center overflow-x-auto scrollbar-hide">
<JackpotCountdown
scheduledAt={jackpot.cycle.scheduled_at}
drawCompleted={awaitingNextCycle || drawJustCompleted}
/>
</div>
</div>
{/* Ticket Price */}
<div className="text-center text-gray-400 mb-8">
<div className="text-center text-gray-400 mb-6 sm:mb-8 text-sm sm:text-base">
Ticket Price: {jackpot.lottery.ticket_price_sats.toLocaleString()} sats
</div>
{/* Buy Button - Show Refresh only after draw */}
<div className="flex flex-col sm:flex-row justify-center gap-4">
{/* Buy Button - Hide when waiting for next round */}
{!isWaitingForNextRound && !maintenanceMode && (
<div className="flex justify-center">
<Link
href="/buy"
className="bg-bitcoin-orange hover:bg-orange-600 text-white px-12 py-4 rounded-lg text-xl font-bold transition-colors shadow-lg text-center"
className="bg-bitcoin-orange hover:bg-orange-600 active:bg-orange-700 text-white px-8 sm:px-12 py-3 sm:py-4 rounded-lg text-lg sm:text-xl font-bold transition-colors shadow-lg text-center w-full sm:w-auto"
>
{STRINGS.home.buyTickets}
</Link>
{drawJustCompleted && (
<button
onClick={handlePlayAgain}
className="bg-gray-700 hover:bg-gray-600 text-white px-8 py-4 rounded-lg text-lg font-medium transition-colors flex items-center justify-center gap-2"
>
<span>🔄</span> Refresh
</button>
)}
</div>
)}
</div>
{/* Check Ticket Section */}
<div className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
<h3 className="text-xl font-semibold text-center mb-4 text-gray-300">
<div className="bg-gray-900 rounded-xl sm:rounded-2xl p-5 sm:p-8 border border-gray-800">
<h3 className="text-lg sm:text-xl font-semibold text-center mb-4 text-gray-300">
{STRINGS.home.checkTicket}
</h3>
<div className="flex flex-col sm:flex-row gap-3">
@@ -252,12 +381,12 @@ export default function HomePage() {
value={ticketId}
onChange={(e) => setTicketId(e.target.value)}
placeholder={STRINGS.home.ticketIdPlaceholder}
className="flex-1 bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange"
className="flex-1 bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange text-base"
onKeyPress={(e) => e.key === 'Enter' && handleCheckTicket()}
/>
<button
onClick={handleCheckTicket}
className="bg-gray-700 hover:bg-gray-600 text-white px-8 py-3 rounded-lg font-medium transition-colors"
className="bg-gray-700 hover:bg-gray-600 active:bg-gray-500 text-white px-6 sm:px-8 py-3 rounded-lg font-medium transition-colors"
>
Check Status
</button>
@@ -265,25 +394,25 @@ export default function HomePage() {
</div>
{/* Info Section */}
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
<div className="text-4xl mb-3"></div>
<h4 className="text-lg font-semibold mb-2 text-white">Instant</h4>
<p className="text-gray-400 text-sm">
<div className="mt-8 sm:mt-12 grid grid-cols-1 sm:grid-cols-3 gap-4 sm:gap-6">
<div className="bg-gray-900 p-5 sm:p-6 rounded-lg border border-gray-800">
<div className="text-3xl sm:text-4xl mb-2 sm:mb-3"></div>
<h4 className="text-base sm:text-lg font-semibold mb-1 sm:mb-2 text-white">Instant</h4>
<p className="text-gray-400 text-xs sm:text-sm">
Lightning-fast ticket purchases and payouts
</p>
</div>
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
<div className="text-4xl mb-3">🔒</div>
<h4 className="text-lg font-semibold mb-2 text-white">Secure</h4>
<p className="text-gray-400 text-sm">
<div className="bg-gray-900 p-5 sm:p-6 rounded-lg border border-gray-800">
<div className="text-3xl sm:text-4xl mb-2 sm:mb-3">🔒</div>
<h4 className="text-base sm:text-lg font-semibold mb-1 sm:mb-2 text-white">Secure</h4>
<p className="text-gray-400 text-xs sm:text-sm">
Cryptographically secure random number generation
</p>
</div>
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
<div className="text-4xl mb-3">🎯</div>
<h4 className="text-lg font-semibold mb-2 text-white">Fair</h4>
<p className="text-gray-400 text-sm">
<div className="bg-gray-900 p-5 sm:p-6 rounded-lg border border-gray-800">
<div className="text-3xl sm:text-4xl mb-2 sm:mb-3">🎯</div>
<h4 className="text-base sm:text-lg font-semibold mb-1 sm:mb-2 text-white">Fair</h4>
<p className="text-gray-400 text-xs sm:text-sm">
Transparent draws with verifiable results
</p>
</div>

View File

@@ -13,6 +13,7 @@ interface PastWin {
pot_total_sats: number;
pot_after_fee_sats: number | null;
winner_name: string;
winner_address: string | null;
winning_ticket_serial: number | null;
}
@@ -87,13 +88,19 @@ export default function PastWinsPage() {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-gray-400 mb-1">{STRINGS.pastWins.winner}</div>
<div className="text-white font-semibold">
{win.winner_name || 'Anon'}
</div>
</div>
<div>
<div className="text-gray-400 mb-1">{STRINGS.pastWins.address}</div>
<div className="text-white font-mono text-xs">
{win.winner_address || 'N/A'}
</div>
</div>
<div>
<div className="text-gray-400 mb-1">{STRINGS.pastWins.ticket}</div>
<div className="text-white">

View File

@@ -18,6 +18,21 @@ export default function TicketStatusPage() {
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<any>(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const [copied, setCopied] = useState(false);
const ticketUrl = typeof window !== 'undefined'
? `${window.location.origin}/tickets/${ticketId}`
: '';
const copyLink = async () => {
try {
await navigator.clipboard.writeText(ticketUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
useEffect(() => {
loadTicketStatus();
@@ -81,17 +96,59 @@ export default function TicketStatusPage() {
const { purchase, tickets, cycle, result } = data;
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white">
<div className="max-w-4xl mx-auto px-1">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-6 sm:mb-8 text-center text-white">
{STRINGS.ticket.title}
</h1>
{/* Save This Link */}
<div className="bg-gradient-to-r from-blue-900/30 to-purple-900/30 border border-blue-700/50 rounded-xl p-4 sm:p-6 mb-5 sm:mb-6">
<div className="flex items-start gap-3 sm:gap-4">
<div className="text-2xl sm:text-3xl">🔖</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white mb-1 sm:mb-2">Save This Link!</h3>
<p className="text-gray-300 text-xs sm:text-sm mb-3">
Bookmark or save this page to check if you've won after the draw. This is your only way to view your ticket status.
</p>
<div className="flex flex-col gap-2">
<div className="bg-gray-800/80 rounded-lg px-3 py-2 font-mono text-xs sm:text-sm text-gray-300 break-all overflow-x-auto">
{ticketUrl || `/tickets/${ticketId}`}
</div>
<button
onClick={copyLink}
className={`w-full sm:w-auto px-4 py-2.5 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${
copied
? 'bg-green-600 text-white'
: 'bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-white'
}`}
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Copy Link
</>
)}
</button>
</div>
</div>
</div>
</div>
{/* Purchase Info */}
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-gray-900 rounded-xl p-4 sm:p-6 mb-5 sm:mb-6 border border-gray-800">
<div className="grid grid-cols-2 gap-3 sm:gap-4 text-xs sm:text-sm">
<div>
<span className="text-gray-400">Purchase ID:</span>
<div className="text-white font-mono break-all">{purchase.id}</div>
<div className="text-white font-mono break-all text-xs sm:text-sm">{purchase.id}</div>
</div>
<div>
<span className="text-gray-400">Status:</span>
@@ -110,15 +167,15 @@ export default function TicketStatusPage() {
{/* Payment Status */}
{purchase.invoice_status === 'pending' && (
<div className="bg-yellow-900/30 text-yellow-200 px-6 py-4 rounded-lg mb-6 text-center">
<div className="bg-yellow-900/30 text-yellow-200 px-4 sm:px-6 py-3 sm:py-4 rounded-lg mb-5 sm:mb-6 text-center text-sm sm:text-base">
{STRINGS.ticket.waiting}
</div>
)}
{/* Tickets */}
{purchase.ticket_issue_status === 'issued' && (
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
<h2 className="text-xl font-semibold mb-4 text-gray-300">
<div className="bg-gray-900 rounded-xl p-4 sm:p-6 mb-5 sm:mb-6 border border-gray-800">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 text-gray-300">
{STRINGS.ticket.ticketNumbers}
</h2>
<TicketList tickets={tickets} />
@@ -126,43 +183,45 @@ export default function TicketStatusPage() {
)}
{/* Draw Info */}
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
<h2 className="text-xl font-semibold mb-4 text-gray-300">
<div className="bg-gray-900 rounded-xl p-4 sm:p-6 mb-5 sm:mb-6 border border-gray-800">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 text-gray-300">
Draw Information
</h2>
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
<div>
<span className="text-gray-400">Draw Time:</span>
<div className="text-white">{formatDateTime(cycle.scheduled_at)}</div>
<span className="text-gray-400 text-sm">Draw Time:</span>
<div className="text-white text-sm sm:text-base">{formatDateTime(cycle.scheduled_at)}</div>
</div>
<div>
<span className="text-gray-400">Current Pot:</span>
<div className="text-2xl font-bold text-bitcoin-orange">
<span className="text-gray-400 text-sm">Current Pot:</span>
<div className="text-xl sm:text-2xl font-bold text-bitcoin-orange">
{cycle.pot_total_sats.toLocaleString()} sats
</div>
</div>
{cycle.status !== 'completed' && (
<div>
<span className="text-gray-400 block mb-2">Time Until Draw:</span>
<span className="text-gray-400 text-sm block mb-2">Time Until Draw:</span>
<div className="overflow-x-auto scrollbar-hide">
<JackpotCountdown scheduledAt={cycle.scheduled_at} />
</div>
</div>
)}
</div>
</div>
{/* Results */}
{result.has_drawn && (
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h2 className="text-xl font-semibold mb-4 text-gray-300">
<div className="bg-gray-900 rounded-xl p-4 sm:p-6 border border-gray-800">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 text-gray-300">
Draw Results
</h2>
{result.is_winner ? (
<div>
<div className="bg-green-900/30 text-green-200 px-6 py-4 rounded-lg mb-4 text-center text-2xl font-bold">
<div className="bg-green-900/30 text-green-200 px-4 sm:px-6 py-3 sm:py-4 rounded-lg mb-4 text-center text-xl sm:text-2xl font-bold">
🎉 {STRINGS.ticket.congratulations}
</div>
{result.payout && (
@@ -174,10 +233,10 @@ export default function TicketStatusPage() {
</div>
) : (
<div>
<div className="bg-gray-800 px-6 py-4 rounded-lg mb-4 text-center">
<div className="text-gray-400 mb-2">{STRINGS.ticket.betterLuck}</div>
<div className="bg-gray-800 px-4 sm:px-6 py-3 sm:py-4 rounded-lg mb-4 text-center">
<div className="text-gray-400 mb-2 text-sm sm:text-base">{STRINGS.ticket.betterLuck}</div>
{cycle.winning_ticket_id && (
<div className="text-gray-300">
<div className="text-gray-300 text-sm sm:text-base">
{STRINGS.ticket.winningTicket}: <span className="font-bold text-bitcoin-orange">#{cycle.winning_ticket_id.substring(0, 8)}</span>
</div>
)}

View File

@@ -3,10 +3,12 @@
import { useState, useEffect, useCallback } from 'react';
interface DrawAnimationProps {
winnerName: string;
winningTicket: number;
potAmount: number;
winnerName?: string;
winningTicket?: number;
potAmount?: number;
onComplete: () => void;
// If true, show "drawing" animation without winner info
isDrawing?: boolean;
}
export function DrawAnimation({
@@ -14,11 +16,14 @@ export function DrawAnimation({
winningTicket,
potAmount,
onComplete,
isDrawing = false,
}: DrawAnimationProps) {
const [phase, setPhase] = useState<'spinning' | 'revealing' | 'winner' | 'done'>('spinning');
const [phase, setPhase] = useState<'spinning' | 'revealing' | 'winner' | 'no-winner' | 'done'>('spinning');
const [displayTicket, setDisplayTicket] = useState(0);
const [showConfetti, setShowConfetti] = useState(false);
const hasWinner = winnerName !== undefined && winningTicket !== undefined && potAmount !== undefined;
// Generate random ticket numbers during spin
useEffect(() => {
if (phase !== 'spinning') return;
@@ -27,20 +32,40 @@ export function DrawAnimation({
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
}, 50);
// After 2.5 seconds, start revealing
// After 2.5 seconds, start revealing (only if we have winner info)
const revealTimeout = setTimeout(() => {
if (hasWinner) {
setPhase('revealing');
}
// If no winner, keep spinning until we get data or timeout
}, 2500);
return () => {
clearInterval(spinInterval);
clearTimeout(revealTimeout);
};
}, [phase]);
}, [phase, hasWinner]);
// When winner info becomes available during spinning, transition to revealing
useEffect(() => {
if (phase === 'spinning' && hasWinner) {
const timer = setTimeout(() => {
setPhase('revealing');
}, 500);
return () => clearTimeout(timer);
}
}, [phase, hasWinner]);
// If isDrawing becomes false and we have no winner, show no-winner phase
useEffect(() => {
if (!isDrawing && !hasWinner && phase === 'spinning') {
setPhase('no-winner');
}
}, [isDrawing, hasWinner, phase]);
// Slow down and reveal actual number
useEffect(() => {
if (phase !== 'revealing') return;
if (phase !== 'revealing' || !winningTicket) return;
let speed = 50;
let iterations = 0;
@@ -69,11 +94,24 @@ export function DrawAnimation({
setShowConfetti(true);
// Auto-dismiss after 6 seconds
// Auto-dismiss after 15 seconds (give time to load next cycle)
const dismissTimeout = setTimeout(() => {
setPhase('done');
onComplete();
}, 6000);
}, 15000);
return () => clearTimeout(dismissTimeout);
}, [phase, onComplete]);
// Handle no-winner phase
useEffect(() => {
if (phase !== 'no-winner') return;
// Auto-dismiss after 3 seconds
const dismissTimeout = setTimeout(() => {
setPhase('done');
onComplete();
}, 3000);
return () => clearTimeout(dismissTimeout);
}, [phase, onComplete]);
@@ -88,7 +126,7 @@ export function DrawAnimation({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
onClick={phase === 'winner' ? handleDismiss : undefined}
onClick={phase === 'winner' || phase === 'no-winner' ? handleDismiss : undefined}
>
{/* Confetti Effect */}
{showConfetti && (
@@ -109,17 +147,17 @@ export function DrawAnimation({
</div>
)}
<div className="text-center px-6 max-w-lg">
<div className="text-center px-4 sm:px-6 max-w-lg w-full">
{/* Spinning Phase */}
{(phase === 'spinning' || phase === 'revealing') && (
<>
<div className="text-2xl text-yellow-400 mb-4 animate-pulse">
<div className="text-xl sm:text-2xl text-yellow-400 mb-3 sm:mb-4 animate-pulse">
🎰 Drawing Winner...
</div>
<div className="bg-gray-900 rounded-2xl p-8 border-2 border-yellow-500/50 shadow-2xl shadow-yellow-500/20">
<div className="text-gray-400 text-sm mb-2">Ticket Number</div>
<div className="bg-gray-900 rounded-xl sm:rounded-2xl p-5 sm:p-8 border-2 border-yellow-500/50 shadow-2xl shadow-yellow-500/20">
<div className="text-gray-400 text-xs sm:text-sm mb-2">Ticket Number</div>
<div
className={`text-5xl md:text-6xl font-mono font-bold text-bitcoin-orange ${
className={`text-3xl sm:text-5xl md:text-6xl font-mono font-bold text-bitcoin-orange break-all ${
phase === 'spinning' ? 'animate-number-spin' : ''
}`}
>
@@ -130,28 +168,49 @@ export function DrawAnimation({
)}
{/* Winner Phase */}
{phase === 'winner' && (
{phase === 'winner' && hasWinner && (
<div className="animate-winner-reveal">
<div className="text-4xl mb-4">🎉🏆🎉</div>
<div className="text-3xl md:text-4xl font-bold text-yellow-400 mb-6">
<div className="text-3xl sm:text-4xl mb-3 sm:mb-4">🎉🏆🎉</div>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-yellow-400 mb-4 sm:mb-6">
We Have a Winner!
</div>
<div className="bg-gradient-to-br from-yellow-900/60 to-orange-900/60 rounded-2xl p-8 border-2 border-yellow-500 shadow-2xl shadow-yellow-500/30">
<div className="text-gray-300 text-sm mb-1">Winner</div>
<div className="text-3xl md:text-4xl font-bold text-white mb-4">
<div className="bg-gradient-to-br from-yellow-900/60 to-orange-900/60 rounded-xl sm:rounded-2xl p-5 sm:p-8 border-2 border-yellow-500 shadow-2xl shadow-yellow-500/30">
<div className="text-gray-300 text-xs sm:text-sm mb-1">Winner</div>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-white mb-3 sm:mb-4 break-all">
{winnerName || 'Anon'}
</div>
<div className="text-gray-300 text-sm mb-1">Winning Ticket</div>
<div className="text-2xl font-mono text-bitcoin-orange mb-4">
#{winningTicket.toLocaleString()}
<div className="text-gray-300 text-xs sm:text-sm mb-1">Winning Ticket</div>
<div className="text-xl sm:text-2xl font-mono text-bitcoin-orange mb-3 sm:mb-4">
#{winningTicket!.toLocaleString()}
</div>
<div className="text-gray-300 text-sm mb-1">Prize</div>
<div className="text-4xl md:text-5xl font-bold text-green-400">
{potAmount.toLocaleString()} sats
<div className="text-gray-300 text-xs sm:text-sm mb-1">Prize</div>
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-green-400">
{potAmount!.toLocaleString()} sats
</div>
</div>
<div className="mt-6 text-gray-400 text-sm animate-pulse">
Click anywhere to continue
<div className="mt-4 sm:mt-6 text-gray-400 text-xs sm:text-sm animate-pulse">
Tap anywhere to continue
</div>
</div>
)}
{/* No Winner Phase (no tickets sold) */}
{phase === 'no-winner' && (
<div className="animate-winner-reveal">
<div className="text-3xl sm:text-4xl mb-3 sm:mb-4">😔</div>
<div className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-400 mb-4 sm:mb-6">
No Tickets This Round
</div>
<div className="bg-gray-900 rounded-xl sm:rounded-2xl p-5 sm:p-8 border-2 border-gray-600 shadow-2xl">
<div className="text-gray-300 text-base sm:text-lg mb-3 sm:mb-4">
No tickets were sold for this draw.
</div>
<div className="text-bitcoin-orange text-lg sm:text-xl font-semibold">
Next draw starting soon!
</div>
</div>
<div className="mt-4 sm:mt-6 text-gray-400 text-xs sm:text-sm animate-pulse">
Tap anywhere to continue
</div>
</div>
)}
@@ -211,4 +270,3 @@ export function DrawAnimation({
</div>
);
}

View File

@@ -2,22 +2,22 @@ import Link from 'next/link';
export function Footer() {
return (
<footer className="bg-gray-900 border-t border-gray-800 py-8 mt-12">
<footer className="bg-gray-900 border-t border-gray-800 py-6 sm:py-8 mt-8 sm:mt-12 safe-bottom">
<div className="container mx-auto px-4">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="text-gray-400 text-sm mb-4 md:mb-0">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="text-gray-400 text-xs sm:text-sm text-center md:text-left">
© 2025 Lightning Lottery. Powered by Bitcoin Lightning Network.
</div>
<div className="flex space-x-6">
<Link
href="/about"
className="text-gray-400 hover:text-white transition-colors"
className="text-gray-400 hover:text-white transition-colors text-sm py-2"
>
About
</Link>
<Link
href="/past-wins"
className="text-gray-400 hover:text-white transition-colors"
className="text-gray-400 hover:text-white transition-colors text-sm py-2"
>
Past Winners
</Link>

View File

@@ -5,9 +5,10 @@ import { formatCountdown } from '@/lib/format';
interface JackpotCountdownProps {
scheduledAt: string;
drawCompleted?: boolean;
}
export function JackpotCountdown({ scheduledAt }: JackpotCountdownProps) {
export function JackpotCountdown({ scheduledAt, drawCompleted = false }: JackpotCountdownProps) {
const [countdown, setCountdown] = useState(formatCountdown(scheduledAt));
useEffect(() => {
@@ -19,38 +20,45 @@ export function JackpotCountdown({ scheduledAt }: JackpotCountdownProps) {
}, [scheduledAt]);
if (countdown.total <= 0) {
return <div className="text-2xl font-bold text-yellow-500">Drawing Now!</div>;
if (drawCompleted) {
return (
<div className="text-2xl font-bold text-gray-400 animate-pulse">
Waiting for next round...
</div>
);
}
return <div className="text-2xl font-bold text-yellow-500 animate-pulse">🎰 Drawing Now!</div>;
}
return (
<div className="flex space-x-4" role="timer" aria-live="polite">
<div className="flex space-x-2 sm:space-x-4" role="timer" aria-live="polite">
{countdown.days > 0 && (
<div className="flex flex-col items-center">
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-bitcoin-orange">
{countdown.days}
</div>
<div className="text-sm text-gray-400">days</div>
<div className="text-xs sm:text-sm text-gray-400">days</div>
</div>
)}
<div className="flex flex-col items-center">
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-bitcoin-orange">
{countdown.hours.toString().padStart(2, '0')}
</div>
<div className="text-sm text-gray-400">hours</div>
<div className="text-xs sm:text-sm text-gray-400">hours</div>
</div>
<div className="text-4xl md:text-5xl font-bold text-gray-500">:</div>
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-gray-500">:</div>
<div className="flex flex-col items-center">
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-bitcoin-orange">
{countdown.minutes.toString().padStart(2, '0')}
</div>
<div className="text-sm text-gray-400">minutes</div>
<div className="text-xs sm:text-sm text-gray-400">min</div>
</div>
<div className="text-4xl md:text-5xl font-bold text-gray-500">:</div>
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-gray-500">:</div>
<div className="flex flex-col items-center">
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-bitcoin-orange">
{countdown.seconds.toString().padStart(2, '0')}
</div>
<div className="text-sm text-gray-400">seconds</div>
<div className="text-xs sm:text-sm text-gray-400">sec</div>
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import { formatSats, satsToBTC } from '@/lib/format';
import { formatSats } from '@/lib/format';
interface JackpotPotDisplayProps {
potTotalSats: number;
@@ -7,12 +7,9 @@ interface JackpotPotDisplayProps {
export function JackpotPotDisplay({ potTotalSats }: JackpotPotDisplayProps) {
return (
<div className="text-center">
<div className="text-5xl md:text-7xl font-bold text-bitcoin-orange mb-2">
<div className="text-4xl sm:text-5xl md:text-7xl font-bold text-bitcoin-orange">
{formatSats(potTotalSats)}
<span className="text-3xl md:text-4xl ml-2 text-gray-400">sats</span>
</div>
<div className="text-xl md:text-2xl text-gray-400">
{satsToBTC(potTotalSats)} BTC
<span className="text-2xl sm:text-3xl md:text-4xl ml-1 sm:ml-2 text-gray-400">sats</span>
</div>
</div>
);

View File

@@ -27,7 +27,7 @@ export function LightningInvoiceCard({
};
return (
<div className="bg-white p-6 rounded-lg shadow-lg relative overflow-hidden">
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-lg relative overflow-hidden">
{/* QR Code Container */}
<div className="flex justify-center mb-4 relative">
<div
@@ -37,9 +37,10 @@ export function LightningInvoiceCard({
>
<QRCodeSVG
value={paymentRequest.toUpperCase()}
size={260}
size={typeof window !== 'undefined' && window.innerWidth < 400 ? 200 : 260}
level="M"
includeMargin={true}
className="w-full max-w-[260px] h-auto"
/>
</div>
@@ -87,19 +88,19 @@ export function LightningInvoiceCard({
showPaidAnimation ? 'opacity-100' : 'opacity-0 h-0 overflow-hidden'
}`}
>
<div className="text-green-600 font-bold text-lg">Payment Received!</div>
<div className="text-green-600 font-bold text-base sm:text-lg">Payment Received!</div>
</div>
{/* Amount */}
<div className="text-center mb-4">
<div className="text-2xl font-bold text-gray-900">
<div className="text-xl sm:text-2xl font-bold text-gray-900">
{amountSats.toLocaleString()} sats
</div>
</div>
{/* Invoice */}
<div className="mb-4">
<div className="bg-gray-100 p-3 rounded text-xs break-all text-gray-700 max-h-24 overflow-y-auto">
<div className="bg-gray-100 p-2 sm:p-3 rounded text-[10px] sm:text-xs break-all text-gray-700 max-h-20 sm:max-h-24 overflow-y-auto">
{paymentRequest}
</div>
</div>
@@ -108,10 +109,10 @@ export function LightningInvoiceCard({
<button
onClick={handleCopy}
disabled={showPaidAnimation}
className={`w-full py-3 rounded-lg font-medium transition-all duration-300 ${
className={`w-full py-3 rounded-lg font-medium transition-all duration-300 text-sm sm:text-base ${
showPaidAnimation
? 'bg-green-500 text-white cursor-default'
: 'bg-bitcoin-orange hover:bg-orange-600 text-white'
: 'bg-bitcoin-orange hover:bg-orange-600 active:bg-orange-700 text-white'
}`}
>
{showPaidAnimation ? '✓ Paid' : copied ? '✓ Copied!' : '📋 Copy Invoice'}

View File

@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useAppSelector } from '@/store/hooks';
import { NostrLoginButton } from './NostrLoginButton';
import { shortNpub, hexToNpub } from '@/lib/nostr';
@@ -9,9 +9,87 @@ import STRINGS from '@/constants/strings';
export function TopBar() {
const user = useAppSelector((state) => state.user);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// Close menu when clicking outside or pressing escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMobileMenuOpen(false);
};
if (mobileMenuOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [mobileMenuOpen]);
const closeMenu = () => setMobileMenuOpen(false);
const NavLinks = ({ mobile = false }: { mobile?: boolean }) => (
<>
<Link
href="/"
onClick={closeMenu}
className={`${
mobile
? 'block py-3 px-4 text-lg text-gray-200 hover:bg-gray-800 rounded-lg transition-colors'
: 'text-gray-300 hover:text-white transition-colors'
}`}
>
Home
</Link>
<Link
href="/buy"
onClick={closeMenu}
className={`${
mobile
? 'block py-3 px-4 text-lg text-gray-200 hover:bg-gray-800 rounded-lg transition-colors'
: 'text-gray-300 hover:text-white transition-colors'
}`}
>
Buy Tickets
</Link>
<Link
href="/past-wins"
onClick={closeMenu}
className={`${
mobile
? 'block py-3 px-4 text-lg text-gray-200 hover:bg-gray-800 rounded-lg transition-colors'
: 'text-gray-300 hover:text-white transition-colors'
}`}
>
Past Winners
</Link>
{user.authenticated ? (
<Link
href="/dashboard"
onClick={closeMenu}
className={`${
mobile
? 'block py-3 px-4 text-lg text-gray-200 hover:bg-gray-800 rounded-lg transition-colors'
: 'text-gray-300 hover:text-white transition-colors'
}`}
>
Dashboard
</Link>
) : (
<div className={mobile ? 'py-3 px-4' : ''} onClick={closeMenu}>
<NostrLoginButton />
</div>
)}
</>
);
return (
<nav className="bg-gray-900 border-b border-gray-800">
<nav className="bg-gray-900 border-b border-gray-800 sticky top-0 z-40">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* Logo */}
@@ -22,41 +100,106 @@ export function TopBar() {
</span>
</Link>
{/* Navigation */}
<div className="flex items-center space-x-6">
<Link
href="/"
className="text-gray-300 hover:text-white transition-colors"
>
Home
</Link>
<Link
href="/buy"
className="text-gray-300 hover:text-white transition-colors"
>
Buy Tickets
</Link>
<Link
href="/past-wins"
className="text-gray-300 hover:text-white transition-colors"
>
Past Winners
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-6">
<NavLinks />
</div>
{user.authenticated ? (
<Link
href="/dashboard"
className="text-gray-300 hover:text-white transition-colors"
{/* Mobile Menu Button */}
<button
type="button"
className="md:hidden p-2 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-bitcoin-orange rounded-lg"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileMenuOpen}
>
Dashboard
</Link>
{mobileMenuOpen ? (
// X icon
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
) : (
<NostrLoginButton />
// Hamburger icon
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
)}
</button>
</div>
</div>
{/* Mobile Menu Overlay */}
{mobileMenuOpen && (
<div
className="fixed inset-0 bg-black/60 z-40 md:hidden"
onClick={closeMenu}
aria-hidden="true"
/>
)}
{/* Mobile Menu Panel */}
<div
className={`fixed top-0 right-0 h-full w-72 bg-gray-900 border-l border-gray-800 z-50 transform transition-transform duration-300 ease-in-out md:hidden ${
mobileMenuOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Mobile Menu Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<span className="text-lg font-semibold text-white">Menu</span>
<button
type="button"
className="p-2 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-bitcoin-orange rounded-lg"
onClick={closeMenu}
aria-label="Close menu"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Mobile Menu Links */}
<div className="p-4 space-y-2">
<NavLinks mobile />
</div>
{/* Mobile Menu Footer */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-800">
<div className="text-center text-gray-500 text-sm">
Lightning Lotto
</div>
</div>
</div>
</nav>
);
}

View File

@@ -67,6 +67,7 @@ export const STRINGS = {
description: 'Recent jackpots and their champions.',
noWins: 'No completed jackpots yet. Check back soon!',
winner: 'Winner',
address: 'Lightning Address',
ticket: 'Ticket #',
pot: 'Pot',
drawTime: 'Draw Time',

View File

@@ -157,6 +157,16 @@ class ApiClient {
return this.request(`/jackpot/past-wins?${params.toString()}`);
}
async getMaintenanceStatus(): Promise<{ maintenance_mode: boolean; maintenance_pending: boolean; message: string | null }> {
try {
const response = await this.request<{ data: { maintenance_mode: boolean; maintenance_pending: boolean; message: string | null } }>('/status/maintenance');
return response.data;
} catch {
// If endpoint doesn't exist or fails, assume not in maintenance
return { maintenance_mode: false, maintenance_pending: false, message: null };
}
}
// Auth endpoints
async nostrAuth(nostrPubkey: string, signedMessage: string, nonce: string) {
return this.request('/auth/nostr', {

View File

@@ -14,6 +14,12 @@ logs/
*.log
npm-debug.log*
# Database
data/
*.db
*.db-wal
*.db-shm
# IDE
.vscode/
.idea/
@@ -26,3 +32,4 @@ Thumbs.db
*.swp
*.swo

View File

@@ -42,3 +42,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["node", "dist/index.js"]

View File

@@ -144,3 +144,4 @@ User states:
MIT

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

@@ -7,8 +7,8 @@ API_BASE_URL=http://localhost:3000
# Frontend URL (for generating ticket links)
FRONTEND_BASE_URL=http://localhost:3001
# Redis Configuration (optional - falls back to in-memory if not set)
REDIS_URL=redis://localhost:6379
# SQLite Database Path (optional - defaults to ./data/bot.db)
# BOT_DATABASE_PATH=./data/bot.db
# Bot Configuration
MAX_TICKETS_PER_PURCHASE=100

View File

@@ -10,13 +10,14 @@
"license": "MIT",
"dependencies": {
"axios": "^1.6.2",
"better-sqlite3": "^9.4.3",
"dotenv": "^16.3.1",
"ioredis": "^5.3.2",
"node-telegram-bot-api": "^0.64.0",
"qrcode": "^1.5.3",
"winston": "^3.11.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^20.10.4",
"@types/node-telegram-bot-api": "^0.64.2",
"@types/qrcode": "^1.5.5",
@@ -136,12 +137,6 @@
"kuler": "^2.0.0"
}
},
"node_modules/@ioredis/commands": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==",
"license": "MIT"
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -208,6 +203,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/caseless": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
@@ -535,6 +540,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@@ -544,6 +569,17 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/better-sqlite3": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -557,6 +593,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz",
@@ -597,6 +642,30 @@
"node": ">=8"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -684,6 +753,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@@ -695,15 +770,6 @@
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
@@ -849,6 +915,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -871,6 +938,30 @@
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -914,13 +1005,13 @@
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
"node": ">=8"
}
},
"node_modules/diff": {
@@ -1144,6 +1235,15 @@
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
"license": "MIT"
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -1188,6 +1288,12 @@
"node": ">=0.10.0"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1282,6 +1388,12 @@
"node": ">= 0.12"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1416,6 +1528,12 @@
"assert-plus": "^1.0.0"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -1584,6 +1702,26 @@
"node": ">=0.10"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -1597,6 +1735,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -1611,30 +1755,6 @@
"node": ">= 0.4"
}
},
"node_modules/ioredis": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.4.0",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -2106,18 +2226,6 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/logform": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
@@ -2184,6 +2292,18 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2197,12 +2317,45 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.85.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-telegram-bot-api": {
"version": "0.64.0",
"resolved": "https://registry.npmjs.org/node-telegram-bot-api/-/node-telegram-bot-api-0.64.0.tgz",
@@ -2439,6 +2592,42 @@
"node": ">= 0.4"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prebuild-install/node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -2527,6 +2716,21 @@
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -2561,27 +2765,6 @@
"node": ">=8.10.0"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -2878,7 +3061,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -3011,6 +3193,51 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -3058,12 +3285,6 @@
"node": "*"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/stealthy-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz",
@@ -3183,6 +3404,15 @@
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -3196,6 +3426,69 @@
"node": ">=4"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tar-stream/node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/tar-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",

View File

@@ -19,13 +19,14 @@
"license": "MIT",
"dependencies": {
"axios": "^1.6.2",
"better-sqlite3": "^9.4.3",
"dotenv": "^16.3.1",
"ioredis": "^5.3.2",
"node-telegram-bot-api": "^0.64.0",
"qrcode": "^1.5.3",
"winston": "^3.11.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^20.10.4",
"@types/node-telegram-bot-api": "^0.64.2",
"@types/qrcode": "^1.5.5",
@@ -35,3 +36,4 @@
}
}

View File

@@ -31,8 +31,8 @@ export const config = {
frontend: {
baseUrl: optional('FRONTEND_BASE_URL', 'http://localhost:3001'),
},
redis: {
url: process.env.REDIS_URL || null,
database: {
path: process.env.BOT_DATABASE_PATH || null, // Defaults to ./data/bot.db in database.ts
},
bot: {
maxTicketsPerPurchase: optionalInt('MAX_TICKETS_PER_PURCHASE', 100),
@@ -48,3 +48,4 @@ export const config = {
export default config;

View File

@@ -1,8 +1,8 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { logger, logUserAction } from '../services/logger';
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards';
import { isValidLightningAddress } from '../utils/format';
import { getMainMenuKeyboard, getLightningAddressKeyboard, getCancelKeyboard } from '../utils/keyboards';
import { isValidLightningAddress, verifyLightningAddress } from '../utils/format';
import { messages } from '../messages';
/**
@@ -14,6 +14,7 @@ export async function handleAddressCommand(
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
const username = msg.from?.username;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
@@ -31,12 +32,12 @@ export async function handleAddressCommand(
}
const message = user.lightningAddress
? messages.address.currentAddress(user.lightningAddress)
: messages.address.noAddressSet;
? messages.address.currentAddressWithOptions(user.lightningAddress, username)
: messages.address.noAddressSetWithOptions(username);
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
reply_markup: getLightningAddressKeyboard(username),
});
await stateManager.updateUserState(userId, 'updating_address');
@@ -55,6 +56,7 @@ export async function handleLightningAddressInput(
): Promise<boolean> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
const username = msg.from?.username;
const text = msg.text?.trim();
if (!userId || !text) return false;
@@ -73,7 +75,19 @@ export async function handleLightningAddressInput(
if (!isValidLightningAddress(text)) {
await bot.sendMessage(chatId, messages.address.invalidFormat, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
reply_markup: getLightningAddressKeyboard(username),
});
return true;
}
// Verify the lightning address actually works
await bot.sendMessage(chatId, messages.address.verifying);
const verification = await verifyLightningAddress(text);
if (!verification.valid) {
await bot.sendMessage(chatId, messages.address.verificationFailed(text, verification.error), {
parse_mode: 'Markdown',
reply_markup: getLightningAddressKeyboard(username),
});
return true;
}
@@ -81,7 +95,7 @@ export async function handleLightningAddressInput(
// Save the lightning address
await stateManager.updateLightningAddress(userId, text);
logUserAction(userId, 'Lightning address updated');
logUserAction(userId, 'Lightning address updated', { address: text });
const responseMessage = user.state === 'awaiting_lightning_address'
? messages.address.firstTimeSuccess(text)
@@ -100,7 +114,85 @@ export async function handleLightningAddressInput(
}
}
/**
* Handle lightning address selection callback (21Tipbot/Bittip)
*/
export async function handleLightningAddressCallback(
bot: TelegramBot,
query: TelegramBot.CallbackQuery,
action: string
): Promise<void> {
const chatId = query.message?.chat.id;
const userId = query.from.id;
const username = query.from.username;
if (!chatId) return;
await bot.answerCallbackQuery(query.id);
try {
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst);
return;
}
// Check if user has a username
if (!username) {
await bot.sendMessage(chatId, messages.address.noUsername, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
});
return;
}
// Generate address based on selection
let address: string;
let serviceName: string;
if (action === '21tipbot') {
address = `${username}@twentyone.tips`;
serviceName = '21Tipbot';
} else if (action === 'bittip') {
address = `${username}@btip.nl`;
serviceName = 'Bittip';
} else {
return;
}
// Verify the address
await bot.sendMessage(chatId, messages.address.verifyingService(serviceName, address));
const verification = await verifyLightningAddress(address);
if (!verification.valid) {
await bot.sendMessage(chatId, messages.address.serviceNotSetup(serviceName, verification.error), {
parse_mode: 'Markdown',
reply_markup: getLightningAddressKeyboard(username),
});
return;
}
// Save the address
await stateManager.updateLightningAddress(userId, address);
logUserAction(userId, 'Lightning address set via tipbot', { address, serviceName });
const responseMessage = user.state === 'awaiting_lightning_address'
? messages.address.firstTimeSuccess(address)
: messages.address.updateSuccess(address);
await bot.sendMessage(chatId, responseMessage, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
} catch (error) {
logger.error('Error in handleLightningAddressCallback', { error, userId, action });
await bot.sendMessage(chatId, messages.errors.generic);
}
}
export default {
handleAddressCommand,
handleLightningAddressInput,
handleLightningAddressCallback,
};

View File

@@ -10,11 +10,19 @@ import {
getViewTicketKeyboard,
getMainMenuKeyboard,
getCancelKeyboard,
getReplyMarkupForChat,
} from '../utils/keyboards';
import { formatSats, formatDate, formatTimeUntil } from '../utils/format';
import { PendingPurchaseData, AwaitingPaymentData } from '../types';
import { messages } from '../messages';
/**
* Check if chat is a group (negative ID for supergroups, or check type)
*/
function isGroupChat(chatId: number): boolean {
return chatId < 0;
}
/**
* Handle /buy command or "Buy Tickets" button
*/
@@ -33,6 +41,26 @@ export async function handleBuyCommand(
logUserAction(userId, 'Initiated ticket purchase');
try {
// Check maintenance mode first
const maintenance = await apiClient.checkMaintenanceStatus();
if (maintenance.enabled) {
await bot.sendMessage(
chatId,
messages.errors.maintenance(maintenance.message || 'System is under maintenance.'),
{ parse_mode: 'Markdown' }
);
return;
}
// Warn if maintenance is pending (but still allow purchase)
if (maintenance.pending) {
await bot.sendMessage(
chatId,
messages.errors.maintenancePending,
{ parse_mode: 'Markdown' }
);
}
const user = await stateManager.getUser(userId);
if (!user) {
@@ -44,7 +72,9 @@ export async function handleBuyCommand(
if (!user.lightningAddress) {
await bot.sendMessage(chatId, messages.address.needAddressFirst, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
reply_markup: isGroupChat(chatId)
? getReplyMarkupForChat(chatId)
: getCancelKeyboard(),
});
await stateManager.updateUserState(userId, 'awaiting_lightning_address');
return;
@@ -54,7 +84,10 @@ export async function handleBuyCommand(
const jackpot = await apiClient.getNextJackpot();
if (!jackpot) {
await bot.sendMessage(chatId, messages.buy.noActiveJackpot, { parse_mode: 'Markdown' });
await bot.sendMessage(chatId, messages.buy.noActiveJackpot, {
parse_mode: 'Markdown',
reply_markup: getReplyMarkupForChat(chatId),
});
return;
}
@@ -69,7 +102,9 @@ export async function handleBuyCommand(
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: getTicketAmountKeyboard(),
reply_markup: isGroupChat(chatId)
? getReplyMarkupForChat(chatId)
: getTicketAmountKeyboard(),
});
// Store jackpot info in state for later use
@@ -168,7 +203,11 @@ export async function handleCustomTicketAmount(
await bot.sendMessage(
chatId,
messages.buy.invalidNumber(config.bot.maxTicketsPerPurchase),
{ reply_markup: getCancelKeyboard() }
{
reply_markup: isGroupChat(chatId)
? getReplyMarkupForChat(chatId)
: getCancelKeyboard(),
}
);
return true;
}
@@ -177,7 +216,11 @@ export async function handleCustomTicketAmount(
await bot.sendMessage(
chatId,
messages.buy.tooManyTickets(config.bot.maxTicketsPerPurchase),
{ reply_markup: getCancelKeyboard() }
{
reply_markup: isGroupChat(chatId)
? getReplyMarkupForChat(chatId)
: getCancelKeyboard(),
}
);
return true;
}
@@ -273,11 +316,11 @@ export async function handlePurchaseConfirmation(
logUserAction(userId, 'Confirmed purchase', { tickets: pendingData.ticketCount });
// Create invoice
// Create invoice with user's display name (defaults to @username)
const purchaseResult = await apiClient.buyTickets(
pendingData.ticketCount,
user.lightningAddress,
userId
stateManager.getDisplayName(user)
);
logPaymentEvent(userId, purchaseResult.ticket_purchase_id, 'created', {
@@ -295,21 +338,23 @@ export async function handlePurchaseConfirmation(
parse_mode: 'Markdown',
});
// Send QR code
await bot.sendPhoto(chatId, qrBuffer, {
// Send QR code with caption
const qrMessage = await bot.sendPhoto(chatId, qrBuffer, {
caption: messages.buy.invoiceCaption(
pendingData.ticketCount,
formatSats(pendingData.totalAmount),
purchaseResult.invoice.payment_request,
config.bot.invoiceExpiryMinutes
),
parse_mode: 'Markdown',
reply_markup: getViewTicketKeyboard(
purchaseResult.ticket_purchase_id,
purchaseResult.public_url
),
});
// Send invoice string as separate message for easy copying
const invoiceMessage = await bot.sendMessage(
chatId,
messages.buy.invoiceString(purchaseResult.invoice.payment_request),
{ parse_mode: 'Markdown' }
);
// Store purchase and start polling
const paymentData: AwaitingPaymentData = {
...pendingData,
@@ -317,17 +362,20 @@ export async function handlePurchaseConfirmation(
paymentRequest: purchaseResult.invoice.payment_request,
publicUrl: purchaseResult.public_url,
pollStartTime: Date.now(),
headerMessageId: messageId,
invoiceMessageId: invoiceMessage.message_id,
qrMessageId: qrMessage.message_id,
};
await stateManager.storePurchase(userId, purchaseResult.ticket_purchase_id, paymentData);
await stateManager.updateUserState(userId, 'awaiting_invoice_payment', paymentData);
// Start payment polling
pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id);
// Start payment polling - pass all message IDs to delete on completion
pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id, messageId, qrMessage.message_id, invoiceMessage.message_id);
} catch (error) {
logger.error('Error in handlePurchaseConfirmation', { error, userId });
await bot.sendMessage(chatId, messages.errors.invoiceCreationFailed, {
reply_markup: getMainMenuKeyboard(),
reply_markup: getReplyMarkupForChat(chatId, getMainMenuKeyboard()),
});
await stateManager.clearUserStateData(userId);
}
@@ -340,7 +388,10 @@ async function pollPaymentStatus(
bot: TelegramBot,
chatId: number,
userId: number,
purchaseId: string
purchaseId: string,
headerMessageId?: number,
qrMessageId?: number,
invoiceMessageId?: number
): Promise<void> {
const pollInterval = config.bot.paymentPollIntervalMs;
const timeout = config.bot.paymentPollTimeoutMs;
@@ -348,14 +399,44 @@ async function pollPaymentStatus(
logPaymentEvent(userId, purchaseId, 'polling');
// Helper to delete all invoice-related messages
const deleteInvoiceMessages = async () => {
// Delete in reverse order (bottom to top)
if (invoiceMessageId) {
try {
await bot.deleteMessage(chatId, invoiceMessageId);
} catch (e) {
// Ignore if message already deleted
}
}
if (qrMessageId) {
try {
await bot.deleteMessage(chatId, qrMessageId);
} catch (e) {
// Ignore if message already deleted
}
}
if (headerMessageId) {
try {
await bot.deleteMessage(chatId, headerMessageId);
} catch (e) {
// Ignore if message already deleted
}
}
};
const checkPayment = async (): Promise<void> => {
try {
// Check if we've timed out
if (Date.now() - startTime > timeout) {
logPaymentEvent(userId, purchaseId, 'expired');
// Delete the invoice messages
await deleteInvoiceMessages();
await bot.sendMessage(chatId, messages.buy.invoiceExpired, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
reply_markup: getReplyMarkupForChat(chatId, getMainMenuKeyboard()),
});
await stateManager.clearUserStateData(userId);
return;
@@ -374,6 +455,12 @@ async function pollPaymentStatus(
tickets: status.tickets.length,
});
// Delete the invoice messages
await deleteInvoiceMessages();
// Track user as cycle participant for notifications
await stateManager.addCycleParticipant(status.purchase.cycle_id, userId, purchaseId);
// Payment received!
const ticketNumbers = status.tickets
.map((t) => `#${t.serial_number.toString().padStart(4, '0')}`)
@@ -397,9 +484,13 @@ async function pollPaymentStatus(
if (status.purchase.invoice_status === 'expired') {
logPaymentEvent(userId, purchaseId, 'expired');
// Delete the invoice messages
await deleteInvoiceMessages();
await bot.sendMessage(chatId, messages.buy.invoiceExpiredShort, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
reply_markup: getReplyMarkupForChat(chatId, getMainMenuKeyboard()),
});
await stateManager.clearUserStateData(userId);
return;

View File

@@ -2,6 +2,21 @@ import TelegramBot from 'node-telegram-bot-api';
import { groupStateManager } from '../services/groupState';
import { logger, logUserAction } from '../services/logger';
import { messages } from '../messages';
import { getReplyMarkupForChat } from '../utils/keyboards';
import {
GroupSettings,
REMINDER_PRESETS,
ANNOUNCEMENT_DELAY_OPTIONS,
NEW_JACKPOT_DELAY_OPTIONS,
DEFAULT_GROUP_REMINDER_SLOTS,
ReminderTime,
formatReminderTime,
reminderTimeToMinutes
} from '../types/groups';
// Track settings messages for auto-deletion
const settingsMessageTimeouts: Map<string, NodeJS.Timeout> = new Map();
const SETTINGS_MESSAGE_TTL = 2 * 60 * 1000; // 2 minutes
/**
* Check if a user is an admin in a group
@@ -38,6 +53,7 @@ export async function handleBotAddedToGroup(
await bot.sendMessage(chatId, messages.groups.welcome(chatTitle), {
parse_mode: 'Markdown',
reply_markup: getReplyMarkupForChat(chatId),
});
} catch (error) {
logger.error('Failed to register group', { error, chatId });
@@ -63,6 +79,7 @@ export async function handleBotRemovedFromGroup(
/**
* Handle /settings command (group admin only)
* Opens settings in private DM, not in the group
*/
export async function handleGroupSettings(
bot: TelegramBot,
@@ -82,7 +99,9 @@ export async function handleGroupSettings(
// Check if user is admin
const isAdmin = await isGroupAdmin(bot, chatId, userId);
if (!isAdmin) {
await bot.sendMessage(chatId, messages.groups.adminOnly);
await bot.sendMessage(chatId, messages.groups.adminOnly, {
reply_markup: getReplyMarkupForChat(chatId),
});
return;
}
@@ -102,40 +121,122 @@ export async function handleGroupSettings(
const currentSettings = settings || await groupStateManager.getGroup(chatId);
if (!currentSettings) {
await bot.sendMessage(chatId, messages.errors.generic);
await bot.sendMessage(chatId, messages.errors.generic, {
reply_markup: getReplyMarkupForChat(chatId),
});
return;
}
await bot.sendMessage(
chatId,
// Send settings to user's private DM
try {
const sentMessage = await bot.sendMessage(
userId,
messages.groups.settingsOverview(currentSettings),
{
parse_mode: 'Markdown',
reply_markup: getGroupSettingsKeyboard(currentSettings),
}
);
// Schedule auto-delete after 2 minutes
scheduleSettingsMessageDeletion(bot, userId, sentMessage.message_id);
// Notify in the group that settings were sent to DM
await bot.sendMessage(
chatId,
`⚙️ @${msg.from?.username || 'Admin'}, I've sent the group settings to your DMs!`,
{
reply_markup: getReplyMarkupForChat(chatId),
}
);
} catch (dmError) {
// If DM fails (user hasn't started the bot), prompt them to start it
logger.warn('Failed to send settings DM', { error: dmError, userId });
await bot.sendMessage(
chatId,
`⚙️ @${msg.from?.username || 'Admin'}, please start a private chat with me first (@LightningLottoBot) so I can send you the settings.`,
{
reply_markup: getReplyMarkupForChat(chatId),
}
);
}
} catch (error) {
logger.error('Error in handleGroupSettings', { error, chatId });
await bot.sendMessage(chatId, messages.errors.generic);
await bot.sendMessage(chatId, messages.errors.generic, {
reply_markup: getReplyMarkupForChat(chatId),
});
}
}
/**
* Schedule deletion of settings message after 2 minutes
*/
function scheduleSettingsMessageDeletion(
bot: TelegramBot,
chatId: number,
messageId: number
): void {
const key = `${chatId}:${messageId}`;
// Clear any existing timeout for this message
const existingTimeout = settingsMessageTimeouts.get(key);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
// Schedule new deletion
const timeout = setTimeout(async () => {
try {
await bot.deleteMessage(chatId, messageId);
logger.debug('Auto-deleted settings message', { chatId, messageId });
} catch (error) {
// Ignore errors (message might already be deleted)
}
settingsMessageTimeouts.delete(key);
}, SETTINGS_MESSAGE_TTL);
settingsMessageTimeouts.set(key, timeout);
}
/**
* Extract group ID from the end of a callback action string
* e.g., "toggle_enabled_-123456789" -> { action: "toggle_enabled", groupId: -123456789 }
*/
function parseGroupAction(fullAction: string): { action: string; groupId: number } | null {
// Match action followed by underscore and group ID (negative or positive number)
const match = fullAction.match(/^(.+)_(-?\d+)$/);
if (!match) return null;
const groupId = parseInt(match[2], 10);
if (isNaN(groupId)) return null;
return { action: match[1], groupId };
}
/**
* Handle group settings toggle callback
* Settings can now be managed from DMs, so group ID is included in callback data
*/
export async function handleGroupSettingsCallback(
bot: TelegramBot,
query: TelegramBot.CallbackQuery,
action: string
): Promise<void> {
const chatId = query.message?.chat.id;
const dmChatId = query.message?.chat.id;
const userId = query.from.id;
const messageId = query.message?.message_id;
if (!chatId || !messageId) return;
if (!dmChatId || !messageId) return;
// Check if user is admin
const isAdmin = await isGroupAdmin(bot, chatId, userId);
// Parse the action to extract the group ID
const parsed = parseGroupAction(action);
if (!parsed) {
await bot.answerCallbackQuery(query.id, { text: 'Invalid action' });
return;
}
const { action: settingAction, groupId } = parsed;
// Check if user is admin of the target group
const isAdmin = await isGroupAdmin(bot, groupId, userId);
if (!isAdmin) {
await bot.answerCallbackQuery(query.id, {
text: messages.groups.adminOnly,
@@ -144,10 +245,23 @@ export async function handleGroupSettingsCallback(
return;
}
try {
let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed';
// Refresh auto-delete timer on any interaction (in the DM)
scheduleSettingsMessageDeletion(bot, dmChatId, messageId);
switch (action) {
try {
const currentSettings = await groupStateManager.getGroup(groupId);
if (!currentSettings) {
await bot.answerCallbackQuery(query.id, { text: 'Group not found' });
return;
}
let updatedSettings: GroupSettings | null = null;
// Handle toggle actions
if (settingAction.startsWith('toggle_')) {
let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed' | 'newJackpotAnnouncement' | 'reminder1Enabled' | 'reminder2Enabled' | 'reminder3Enabled';
switch (settingAction) {
case 'toggle_enabled':
setting = 'enabled';
break;
@@ -160,109 +274,310 @@ export async function handleGroupSettingsCallback(
case 'toggle_purchases':
setting = 'ticketPurchaseAllowed';
break;
case 'toggle_newjackpot':
setting = 'newJackpotAnnouncement';
break;
case 'toggle_reminder1':
setting = 'reminder1Enabled';
break;
case 'toggle_reminder2':
setting = 'reminder2Enabled';
break;
case 'toggle_reminder3':
setting = 'reminder3Enabled';
break;
default:
await bot.answerCallbackQuery(query.id);
return;
}
const currentSettings = await groupStateManager.getGroup(chatId);
if (!currentSettings) {
await bot.answerCallbackQuery(query.id, { text: 'Group not found' });
return;
const currentValue = currentSettings[setting] !== false; // Default true for new settings
const newValue = !currentValue;
updatedSettings = await groupStateManager.updateSetting(groupId, setting, newValue);
if (updatedSettings) {
logUserAction(userId, 'Updated group setting', {
groupId,
setting,
newValue,
});
await bot.answerCallbackQuery(query.id, {
text: `${setting} ${newValue ? 'enabled' : 'disabled'}`,
});
}
}
const newValue = !currentSettings[setting];
const updatedSettings = await groupStateManager.updateSetting(chatId, setting, newValue);
// Legacy handlers removed - now using 3-slot reminder system with toggle_reminder1/2/3 and time adjustments
// Handle announcement delay selection (announce_delay_30 -> seconds=30)
const announceDelayMatch = settingAction.match(/^announce_delay_(\d+)$/);
if (announceDelayMatch) {
const seconds = parseInt(announceDelayMatch[1], 10);
if (!isNaN(seconds)) {
updatedSettings = await groupStateManager.updateAnnouncementDelay(groupId, seconds);
if (updatedSettings) {
logUserAction(userId, 'Updated announcement delay', { groupId, seconds });
await bot.answerCallbackQuery(query.id, {
text: seconds === 0 ? 'Announce immediately' : `Announce ${seconds}s after draw`
});
}
}
}
// Handle new jackpot delay selection (newjackpot_delay_5 -> minutes=5)
const newJackpotDelayMatch = settingAction.match(/^newjackpot_delay_(\d+)$/);
if (newJackpotDelayMatch) {
const minutes = parseInt(newJackpotDelayMatch[1], 10);
if (!isNaN(minutes)) {
updatedSettings = await groupStateManager.updateNewJackpotDelay(groupId, minutes);
if (updatedSettings) {
logUserAction(userId, 'Updated new jackpot delay', { groupId, 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 = settingAction.match(/^reminder(\d)_(add|sub)_(\d+)_(minutes|hours|days)$/);
if (reminderTimeMatch) {
const slot = parseInt(reminderTimeMatch[1], 10) as 1 | 2 | 3;
const operation = reminderTimeMatch[2] as 'add' | 'sub';
const amount = parseInt(reminderTimeMatch[3], 10);
const unit = reminderTimeMatch[4] as 'minutes' | 'hours' | 'days';
// Get current time for this slot
const currentTimeKey = `reminder${slot}Time` as 'reminder1Time' | 'reminder2Time' | 'reminder3Time';
const defaultTimes: Record<string, ReminderTime> = {
reminder1Time: { value: 1, unit: 'hours' },
reminder2Time: { value: 1, unit: 'days' },
reminder3Time: { value: 6, unit: 'days' },
};
const currentTime = currentSettings[currentTimeKey] || defaultTimes[currentTimeKey];
// Convert to minutes for calculation
const currentMinutes = reminderTimeToMinutes(currentTime);
const adjustMinutes = unit === 'minutes' ? amount : unit === 'hours' ? amount * 60 : amount * 60 * 24;
const newMinutes = operation === 'add'
? currentMinutes + adjustMinutes
: Math.max(1, currentMinutes - adjustMinutes); // Minimum 1 minute
// Convert back to best unit
let newTime: ReminderTime;
if (newMinutes >= 1440 && newMinutes % 1440 === 0) {
newTime = { value: newMinutes / 1440, unit: 'days' };
} else if (newMinutes >= 60 && newMinutes % 60 === 0) {
newTime = { value: newMinutes / 60, unit: 'hours' };
} else {
newTime = { value: newMinutes, unit: 'minutes' };
}
updatedSettings = await groupStateManager.updateReminderTime(groupId, slot, newTime);
if (updatedSettings) {
logUserAction(userId, 'Updated reminder time', { groupId, slot, newTime });
await bot.answerCallbackQuery(query.id, {
text: `Reminder ${slot}: ${formatReminderTime(newTime)} before draw`
});
}
}
if (!updatedSettings) {
await bot.answerCallbackQuery(query.id, { text: 'Failed to update' });
return;
}
logUserAction(userId, 'Updated group setting', {
groupId: chatId,
setting,
newValue,
});
await bot.answerCallbackQuery(query.id, {
text: `${setting} ${newValue ? 'enabled' : 'disabled'}`,
});
// Update the message with new settings
// Update the message with new settings (in the DM)
await bot.editMessageText(
messages.groups.settingsOverview(updatedSettings),
{
chat_id: chatId,
chat_id: dmChatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: getGroupSettingsKeyboard(updatedSettings),
}
);
} catch (error) {
logger.error('Error in handleGroupSettingsCallback', { error, chatId, action });
logger.error('Error in handleGroupSettingsCallback', { error, groupId, action });
await bot.answerCallbackQuery(query.id, { text: 'Error updating settings' });
}
}
/**
* Format delay option for display (seconds)
*/
function formatDelayOption(seconds: number): string {
if (seconds === 0) return 'Instant';
if (seconds >= 60) {
const minutes = seconds / 60;
return minutes === 1 ? '1 min' : `${minutes} min`;
}
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
*/
function getReminderTimeAdjustButtons(slot: number, currentTime: ReminderTime, groupId: number): TelegramBot.InlineKeyboardButton[] {
return [
{ text: '1m', callback_data: `group_reminder${slot}_sub_1_minutes_${groupId}` },
{ text: '+1m', callback_data: `group_reminder${slot}_add_1_minutes_${groupId}` },
{ text: '1h', callback_data: `group_reminder${slot}_sub_1_hours_${groupId}` },
{ text: '+1h', callback_data: `group_reminder${slot}_add_1_hours_${groupId}` },
{ text: '1d', callback_data: `group_reminder${slot}_sub_1_days_${groupId}` },
{ text: '+1d', callback_data: `group_reminder${slot}_add_1_days_${groupId}` },
];
}
/**
* Check if a reminder time is already set
*/
function hasReminder(settings: GroupSettings, rt: ReminderTime): boolean {
if (!settings.reminderTimes) return false;
return settings.reminderTimes.some(
r => r.value === rt.value && r.unit === rt.unit
);
}
/**
* Generate inline keyboard for group settings
* groupId is included in callback data so settings can be managed from DMs
*/
function getGroupSettingsKeyboard(settings: {
enabled: boolean;
drawAnnouncements: boolean;
reminders: boolean;
ticketPurchaseAllowed: boolean;
}): TelegramBot.InlineKeyboardMarkup {
const onOff = (val: boolean) => val ? '✅' : '❌';
function getGroupSettingsKeyboard(settings: GroupSettings): TelegramBot.InlineKeyboardMarkup {
const onOff = (val: boolean | undefined) => val !== false ? '✅' : '❌';
const selected = (current: number, option: number) => current === option ? '●' : '○';
const gid = settings.groupId; // Include group ID in all callbacks
return {
inline_keyboard: [
const keyboard: TelegramBot.InlineKeyboardButton[][] = [
[{
text: `${onOff(settings.enabled)} Bot Enabled`,
callback_data: 'group_toggle_enabled',
callback_data: `group_toggle_enabled_${gid}`,
}],
[{
text: `${onOff(settings.drawAnnouncements)} Draw Announcements`,
callback_data: 'group_toggle_announcements',
text: `${onOff(settings.newJackpotAnnouncement)} New Jackpot Announcement`,
callback_data: `group_toggle_newjackpot_${gid}`,
}],
[{
];
// 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}_${gid}`,
}))
);
}
keyboard.push([{
text: `${onOff(settings.drawAnnouncements)} Draw Result Announcements`,
callback_data: `group_toggle_announcements_${gid}`,
}]);
// Add announcement delay options if announcements are enabled
if (settings.drawAnnouncements) {
keyboard.push(
ANNOUNCEMENT_DELAY_OPTIONS.map(seconds => ({
text: `${selected(settings.announcementDelaySeconds || 0, seconds)} ${formatDelayOption(seconds)}`,
callback_data: `group_announce_delay_${seconds}_${gid}`,
}))
);
}
keyboard.push([{
text: `${onOff(settings.reminders)} Draw Reminders`,
callback_data: 'group_toggle_reminders',
}],
callback_data: `group_toggle_reminders_${gid}`,
}]);
// Add 3-tier reminder options if reminders are enabled
if (settings.reminders) {
// Get default values with fallback for migration
const r1Enabled = settings.reminder1Enabled !== false;
const r2Enabled = settings.reminder2Enabled === true;
const r3Enabled = settings.reminder3Enabled === true;
// Get times with defaults
const r1Time = settings.reminder1Time || { value: 1, unit: 'hours' as const };
const r2Time = settings.reminder2Time || { value: 1, unit: 'days' as const };
const r3Time = settings.reminder3Time || { value: 6, unit: 'days' as const };
// Reminder 1
keyboard.push([{
text: `${onOff(r1Enabled)} Reminder 1: ${formatReminderTime(r1Time)} before`,
callback_data: `group_toggle_reminder1_${gid}`,
}]);
if (r1Enabled) {
keyboard.push(getReminderTimeAdjustButtons(1, r1Time, gid));
}
// Reminder 2
keyboard.push([{
text: `${onOff(r2Enabled)} Reminder 2: ${formatReminderTime(r2Time)} before`,
callback_data: `group_toggle_reminder2_${gid}`,
}]);
if (r2Enabled) {
keyboard.push(getReminderTimeAdjustButtons(2, r2Time, gid));
}
// Reminder 3
keyboard.push([{
text: `${onOff(r3Enabled)} Reminder 3: ${formatReminderTime(r3Time)} before`,
callback_data: `group_toggle_reminder3_${gid}`,
}]);
if (r3Enabled) {
keyboard.push(getReminderTimeAdjustButtons(3, r3Time, gid));
}
}
keyboard.push(
[{
text: `${onOff(settings.ticketPurchaseAllowed)} Allow Ticket Purchases`,
callback_data: 'group_toggle_purchases',
callback_data: `group_toggle_purchases_${gid}`,
}],
[{
text: '🔄 Refresh',
callback_data: 'group_refresh',
}],
],
};
callback_data: `group_refresh_${gid}`,
}]
);
return { inline_keyboard: keyboard };
}
/**
* Handle refresh callback
* groupId is extracted from callback data since settings are managed in DMs
*/
export async function handleGroupRefresh(
bot: TelegramBot,
query: TelegramBot.CallbackQuery
query: TelegramBot.CallbackQuery,
groupId: number
): Promise<void> {
const chatId = query.message?.chat.id;
const dmChatId = query.message?.chat.id;
const messageId = query.message?.message_id;
if (!chatId || !messageId) return;
if (!dmChatId || !messageId) return;
// Refresh auto-delete timer
scheduleSettingsMessageDeletion(bot, dmChatId, messageId);
await bot.answerCallbackQuery(query.id, { text: 'Refreshed!' });
const settings = await groupStateManager.getGroup(chatId);
const settings = await groupStateManager.getGroup(groupId);
if (!settings) return;
await bot.editMessageText(
messages.groups.settingsOverview(settings),
{
chat_id: chatId,
chat_id: dmChatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: getGroupSettingsKeyboard(settings),
@@ -282,7 +597,10 @@ export async function broadcastDrawAnnouncement(
for (const group of groups) {
try {
await bot.sendMessage(group.groupId, announcement, { parse_mode: 'Markdown' });
await bot.sendMessage(group.groupId, announcement, {
parse_mode: 'Markdown',
reply_markup: getReplyMarkupForChat(group.groupId),
});
sent++;
} catch (error) {
logger.error('Failed to send announcement to group', {
@@ -312,7 +630,10 @@ export async function broadcastDrawReminder(
for (const group of groups) {
try {
await bot.sendMessage(group.groupId, reminder, { parse_mode: 'Markdown' });
await bot.sendMessage(group.groupId, reminder, {
parse_mode: 'Markdown',
reply_markup: getReplyMarkupForChat(group.groupId),
});
sent++;
} catch (error) {
logger.error('Failed to send reminder to group', {
@@ -339,3 +660,4 @@ export default {
broadcastDrawReminder,
};

View File

@@ -1,26 +1,36 @@
import TelegramBot from 'node-telegram-bot-api';
import { logUserAction } from '../services/logger';
import { getMainMenuKeyboard } from '../utils/keyboards';
import { getMainMenuKeyboard, getReplyMarkupForChat } from '../utils/keyboards';
import { messages } from '../messages';
/**
* Handle /help command
* Handle /lottohelp command
*/
export async function handleHelpCommand(
bot: TelegramBot,
msg: TelegramBot.Message
msg: TelegramBot.Message,
isGroup: boolean = false
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (userId) {
logUserAction(userId, 'Viewed help');
logUserAction(userId, 'Viewed help', { isGroup });
}
if (isGroup) {
// Show group-specific help with admin commands
await bot.sendMessage(chatId, messages.help.groupMessage, {
parse_mode: 'Markdown',
reply_markup: getReplyMarkupForChat(chatId),
});
} else {
// Show user help in DM
await bot.sendMessage(chatId, messages.help.message, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
}
}
export default handleHelpCommand;

View File

@@ -2,6 +2,7 @@ export { handleStart } from './start';
export {
handleAddressCommand,
handleLightningAddressInput,
handleLightningAddressCallback,
} from './address';
export {
handleBuyCommand,
@@ -11,6 +12,7 @@ export {
} from './buy';
export {
handleTicketsCommand,
handleTicketsPage,
handleViewTicket,
handleStatusCheck,
} from './tickets';
@@ -21,6 +23,11 @@ export {
handleCancel,
handleMenuCallback,
} from './menu';
export {
handleSettingsCommand,
handleSettingsCallback,
handleDisplayNameInput,
} from './settings';
export {
handleBotAddedToGroup,
handleBotRemovedFromGroup,

View File

@@ -0,0 +1,173 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { logger, logUserAction } from '../services/logger';
import { messages } from '../messages';
import { getMainMenuKeyboard, getSettingsKeyboard } from '../utils/keyboards';
import { NotificationPreferences, DEFAULT_NOTIFICATIONS } from '../types';
/**
* Handle /settings command (private chat only)
*/
export async function handleSettingsCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (!userId) return;
// Only works in private chats
if (msg.chat.type !== 'private') {
await bot.sendMessage(chatId, '❌ This command only works in private chat. Message me directly!');
return;
}
logUserAction(userId, 'Viewed settings');
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst, {
reply_markup: getMainMenuKeyboard(),
});
return;
}
// Ensure notifications object exists
const notifications = user.notifications || { ...DEFAULT_NOTIFICATIONS };
const displayName = stateManager.getDisplayName(user);
await bot.sendMessage(
chatId,
messages.settings.overview(displayName, notifications),
{
parse_mode: 'Markdown',
reply_markup: getSettingsKeyboard(displayName, notifications),
}
);
}
/**
* Handle settings callback
*/
export async function handleSettingsCallback(
bot: TelegramBot,
query: TelegramBot.CallbackQuery,
action: string
): Promise<void> {
const chatId = query.message?.chat.id;
const userId = query.from.id;
const messageId = query.message?.message_id;
if (!chatId || !messageId) return;
const user = await stateManager.getUser(userId);
if (!user) {
await bot.answerCallbackQuery(query.id, { text: 'Please /start first' });
return;
}
try {
// Handle notification toggles
if (action.startsWith('toggle_notif_')) {
const setting = action.replace('toggle_notif_', '') as keyof NotificationPreferences;
const currentNotifications = user.notifications || { ...DEFAULT_NOTIFICATIONS };
const newValue = !currentNotifications[setting];
const updatedUser = await stateManager.updateNotifications(userId, { [setting]: newValue });
if (updatedUser) {
logUserAction(userId, 'Updated notification setting', { setting, newValue });
await bot.answerCallbackQuery(query.id, {
text: `${setting} ${newValue ? 'enabled' : 'disabled'}`,
});
// Update message
const displayName = stateManager.getDisplayName(updatedUser);
await bot.editMessageText(
messages.settings.overview(displayName, updatedUser.notifications),
{
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: getSettingsKeyboard(displayName, updatedUser.notifications),
}
);
}
return;
}
// Handle display name change
if (action === 'change_name') {
await bot.answerCallbackQuery(query.id);
await stateManager.updateUserState(userId, 'awaiting_display_name');
await bot.sendMessage(
chatId,
messages.settings.enterDisplayName,
{ parse_mode: 'Markdown' }
);
return;
}
// Handle back to menu
if (action === 'back_menu') {
await bot.answerCallbackQuery(query.id);
await bot.deleteMessage(chatId, messageId);
await bot.sendMessage(chatId, messages.menu.header, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
return;
}
await bot.answerCallbackQuery(query.id);
} catch (error) {
logger.error('Error in handleSettingsCallback', { error, userId, action });
await bot.answerCallbackQuery(query.id, { text: 'Error updating settings' });
}
}
/**
* Handle display name input
*/
export async function handleDisplayNameInput(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
const text = msg.text?.trim();
if (!userId || !text) return;
// Validate display name (max 20 chars, alphanumeric + spaces + some symbols)
if (text.length > 20) {
await bot.sendMessage(chatId, messages.settings.nameTooLong);
return;
}
// Clean the display name (allow @ for usernames)
const cleanName = text.replace(/[^\w\s\-_.@]/g, '').trim() || 'Anon';
await stateManager.updateDisplayName(userId, cleanName);
logUserAction(userId, 'Set display name', { displayName: cleanName });
const user = await stateManager.getUser(userId);
const notifications = user?.notifications || { ...DEFAULT_NOTIFICATIONS };
await bot.sendMessage(
chatId,
messages.settings.nameUpdated(cleanName),
{
parse_mode: 'Markdown',
reply_markup: getSettingsKeyboard(cleanName, notifications),
}
);
}
export default {
handleSettingsCommand,
handleSettingsCallback,
handleDisplayNameInput,
};

View File

@@ -1,7 +1,7 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { logger, logUserAction } from '../services/logger';
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards';
import { getMainMenuKeyboard, getLightningAddressKeyboard } from '../utils/keyboards';
import { messages } from '../messages';
/**
@@ -10,6 +10,7 @@ import { messages } from '../messages';
export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
const username = msg.from?.username;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
@@ -17,7 +18,7 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P
}
logUserAction(userId, 'Started bot', {
username: msg.from?.username,
username: username,
firstName: msg.from?.first_name,
});
@@ -29,7 +30,7 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P
// Create new user
user = await stateManager.createUser(
userId,
msg.from?.username,
username,
msg.from?.first_name,
msg.from?.last_name
);
@@ -40,9 +41,9 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P
// Check if lightning address is set
if (!user.lightningAddress) {
await bot.sendMessage(chatId, messages.start.needAddress, {
await bot.sendMessage(chatId, messages.start.needAddressWithOptions(username), {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
reply_markup: getLightningAddressKeyboard(username),
});
await stateManager.updateUserState(userId, 'awaiting_lightning_address');

View File

@@ -7,12 +7,25 @@ import { getMainMenuKeyboard, getViewTicketKeyboard } from '../utils/keyboards';
import { formatSats, formatDate } from '../utils/format';
import { messages } from '../messages';
const TICKETS_PER_PAGE = 5;
interface PurchaseInfo {
id: string;
cycleId: string;
ticketCount: number;
scheduledAt: string;
invoiceStatus: string;
isWinner: boolean;
hasDrawn: boolean;
}
/**
* Handle /tickets command or "My Tickets" button
*/
export async function handleTicketsCommand(
bot: TelegramBot,
msg: TelegramBot.Message
msg: TelegramBot.Message,
page: number = 0
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
@@ -22,8 +35,46 @@ export async function handleTicketsCommand(
return;
}
logUserAction(userId, 'Viewed tickets');
logUserAction(userId, 'Viewed tickets', { page });
await sendTicketsList(bot, chatId, userId, page);
}
/**
* Handle tickets page navigation callback
*/
export async function handleTicketsPage(
bot: TelegramBot,
query: TelegramBot.CallbackQuery,
page: number
): Promise<void> {
const chatId = query.message?.chat.id;
const userId = query.from.id;
const messageId = query.message?.message_id;
if (!chatId || !messageId) return;
await bot.answerCallbackQuery(query.id);
// Delete old message and send new one
try {
await bot.deleteMessage(chatId, messageId);
} catch (e) {
// Ignore delete errors
}
await sendTicketsList(bot, chatId, userId, page);
}
/**
* Send tickets list with pagination
*/
async function sendTicketsList(
bot: TelegramBot,
chatId: number,
userId: number,
page: number
): Promise<void> {
try {
const user = await stateManager.getUser(userId);
@@ -32,8 +83,12 @@ export async function handleTicketsCommand(
return;
}
// Get user's purchase IDs from state
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 10);
// Get current cycle to identify current round tickets
const currentJackpot = await apiClient.getNextJackpot();
const currentCycleId = currentJackpot?.cycle?.id;
// Get ALL user's purchase IDs from state (increase limit for pagination)
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 100);
if (purchaseIds.length === 0) {
await bot.sendMessage(chatId, messages.tickets.empty, {
@@ -44,21 +99,21 @@ export async function handleTicketsCommand(
}
// Fetch status for each purchase
const purchases: Array<{
id: string;
ticketCount: number;
scheduledAt: string;
invoiceStatus: string;
isWinner: boolean;
hasDrawn: boolean;
}> = [];
const allPurchases: PurchaseInfo[] = [];
for (const purchaseId of purchaseIds) {
try {
const status = await apiClient.getTicketStatus(purchaseId);
if (status) {
purchases.push({
// Skip expired invoices - don't show tickets that were never paid
if (status.purchase.invoice_status === 'expired') {
logger.debug('Skipping expired invoice', { purchaseId });
continue;
}
allPurchases.push({
id: status.purchase.id,
cycleId: status.purchase.cycle_id,
ticketCount: status.purchase.number_of_tickets,
scheduledAt: status.cycle.scheduled_at,
invoiceStatus: status.purchase.invoice_status,
@@ -72,7 +127,7 @@ export async function handleTicketsCommand(
}
}
if (purchases.length === 0) {
if (allPurchases.length === 0) {
await bot.sendMessage(chatId, messages.tickets.notFound, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
@@ -80,56 +135,134 @@ export async function handleTicketsCommand(
return;
}
// Format purchases list
let message = messages.tickets.header;
// Separate current round and past tickets
const currentRoundTickets = allPurchases.filter(p =>
p.cycleId === currentCycleId && !p.hasDrawn
);
const pastTickets = allPurchases.filter(p =>
p.cycleId !== currentCycleId || p.hasDrawn
);
for (let i = 0; i < purchases.length; i++) {
const p = purchases[i];
// Sort past tickets by date (newest first)
pastTickets.sort((a, b) =>
new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime()
);
// Build message
let message = '';
const inlineKeyboard: TelegramBot.InlineKeyboardButton[][] = [];
// Current round section (always shown on page 0)
if (page === 0 && currentRoundTickets.length > 0) {
message += `🎯 *Current Round*\n\n`;
for (const p of currentRoundTickets) {
const drawDate = new Date(p.scheduledAt);
const statusInfo = getStatusInfo(p);
message += `${statusInfo.emoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} Draw: ${formatDate(drawDate)}\n`;
let statusEmoji: string;
let statusText: string;
if (p.invoiceStatus === 'pending') {
statusEmoji = '⏳';
statusText = messages.tickets.statusPending;
} else if (p.invoiceStatus === 'expired') {
statusEmoji = '❌';
statusText = messages.tickets.statusExpired;
} else if (!p.hasDrawn) {
statusEmoji = '🎟';
statusText = messages.tickets.statusActive;
} else if (p.isWinner) {
statusEmoji = '🏆';
statusText = messages.tickets.statusWon;
} else {
statusEmoji = '😔';
statusText = messages.tickets.statusLost;
}
message += `${i + 1}. ${statusEmoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} ${formatDate(drawDate)} ${statusText}\n`;
}
message += messages.tickets.tapForDetails;
// Create inline buttons for each purchase
const inlineKeyboard = purchases.map((p, i) => [{
text: `${i + 1}. View Ticket #${p.id.substring(0, 8)}...`,
inlineKeyboard.push([{
text: `🎟 View Current Tickets #${p.id.substring(0, 8)}...`,
callback_data: `view_ticket_${p.id}`,
}]);
}
if (pastTickets.length > 0) {
message += `\n📜 *Past Tickets*\n\n`;
}
} else if (page === 0) {
message += messages.tickets.header;
} else {
message += `📜 *Past Tickets (Page ${page + 1})*\n\n`;
}
// Calculate pagination for past tickets
const startIdx = page === 0 && currentRoundTickets.length > 0
? 0
: page * TICKETS_PER_PAGE - (currentRoundTickets.length > 0 ? 0 : 0);
const adjustedStartIdx = page === 0 ? 0 : (page - 1) * TICKETS_PER_PAGE + (currentRoundTickets.length > 0 ? TICKETS_PER_PAGE : 0);
const pageStartIdx = page === 0 ? 0 : (page - (currentRoundTickets.length > 0 ? 1 : 0)) * TICKETS_PER_PAGE;
const ticketsToShow = pastTickets.slice(pageStartIdx, pageStartIdx + TICKETS_PER_PAGE);
// Past tickets section
for (let i = 0; i < ticketsToShow.length; i++) {
const p = ticketsToShow[i];
const drawDate = new Date(p.scheduledAt);
const statusInfo = getStatusInfo(p);
const globalIdx = pageStartIdx + i + 1;
message += `${globalIdx}. ${statusInfo.emoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} ${formatDate(drawDate)} ${statusInfo.text}\n`;
inlineKeyboard.push([{
text: `${globalIdx}. ${statusInfo.emoji} View #${p.id.substring(0, 8)}...`,
callback_data: `view_ticket_${p.id}`,
}]);
}
// Pagination buttons
const totalPastPages = Math.ceil(pastTickets.length / TICKETS_PER_PAGE);
const hasCurrentRound = currentRoundTickets.length > 0;
const effectivePage = hasCurrentRound ? page : page;
const maxPage = hasCurrentRound ? totalPastPages : totalPastPages - 1;
const navButtons: TelegramBot.InlineKeyboardButton[] = [];
if (page > 0) {
navButtons.push({
text: '⬅️ Previous',
callback_data: `tickets_page_${page - 1}`,
});
}
if (pageStartIdx + TICKETS_PER_PAGE < pastTickets.length) {
navButtons.push({
text: '➡️ Next',
callback_data: `tickets_page_${page + 1}`,
});
}
if (navButtons.length > 0) {
inlineKeyboard.push(navButtons);
}
// Add page info if paginated
if (pastTickets.length > TICKETS_PER_PAGE) {
const currentPageNum = page + 1;
const totalPages = Math.ceil(pastTickets.length / TICKETS_PER_PAGE) + (hasCurrentRound ? 1 : 0);
message += `\n_Page ${currentPageNum} of ${totalPages}_`;
}
message += `\n\nTap a ticket to view details.`;
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: inlineKeyboard },
});
} catch (error) {
logger.error('Error in handleTicketsCommand', { error, userId });
logger.error('Error in sendTicketsList', { error, userId });
await bot.sendMessage(chatId, messages.errors.fetchTicketsFailed, {
reply_markup: getMainMenuKeyboard(),
});
}
}
/**
* Get status emoji and text for a purchase
*/
function getStatusInfo(p: PurchaseInfo): { emoji: string; text: string } {
if (p.invoiceStatus === 'pending') {
return { emoji: '⏳', text: messages.tickets.statusPending };
} else if (p.invoiceStatus === 'expired') {
return { emoji: '❌', text: messages.tickets.statusExpired };
} else if (!p.hasDrawn) {
return { emoji: '🎟', text: messages.tickets.statusActive };
} else if (p.isWinner) {
return { emoji: '🏆', text: messages.tickets.statusWon };
} else {
return { emoji: '😔', text: messages.tickets.statusLost };
}
}
/**
* Handle viewing a specific ticket
*/
@@ -254,6 +387,7 @@ export async function handleStatusCheck(
export default {
handleTicketsCommand,
handleTicketsPage,
handleViewTicket,
handleStatusCheck,
};

View File

@@ -1,18 +1,22 @@
import TelegramBot from 'node-telegram-bot-api';
import config from './config';
import { botDatabase } from './services/database';
import { stateManager } from './services/state';
import { groupStateManager } from './services/groupState';
import { apiClient } from './services/api';
import { logger, logUserAction } from './services/logger';
import { notificationScheduler } from './services/notificationScheduler';
import {
handleStart,
handleAddressCommand,
handleLightningAddressInput,
handleLightningAddressCallback,
handleBuyCommand,
handleTicketAmountSelection,
handleCustomTicketAmount,
handlePurchaseConfirmation,
handleTicketsCommand,
handleTicketsPage,
handleViewTicket,
handleStatusCheck,
handleWinsCommand,
@@ -20,13 +24,16 @@ import {
handleMenuCommand,
handleCancel,
handleMenuCallback,
handleSettingsCommand,
handleSettingsCallback,
handleDisplayNameInput,
handleBotAddedToGroup,
handleBotRemovedFromGroup,
handleGroupSettings,
handleGroupSettingsCallback,
handleGroupRefresh,
} from './handlers';
import { getMainMenuKeyboard } from './utils/keyboards';
import { getMainMenuKeyboard, getReplyMarkupForChat } from './utils/keyboards';
import { messages } from './messages';
import { formatSats, formatDate, formatTimeUntil } from './utils/format';
@@ -86,12 +93,15 @@ bot.on('message', async (msg) => {
bot.onText(/\/start/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// In groups, just show a welcome message
// In groups, just show a welcome message (explicitly remove keyboard)
if (isGroupChat(msg)) {
await bot.sendMessage(
msg.chat.id,
`⚡ *Lightning Jackpot Bot*\n\nTo buy tickets and manage your account, message me directly!\n\nUse /jackpot to see current jackpot info.\nAdmins: Use /settings to configure the bot.`,
{ parse_mode: 'Markdown' }
`⚡ *Lightning Jackpot Bot*\n\nTo buy tickets and manage your account, message me directly!\n\nUse /jackpot to see current jackpot info.\nUse /lottohelp for commands.\nAdmins: Use /lottosettings to configure the bot.`,
{
parse_mode: 'Markdown',
reply_markup: getReplyMarkupForChat(msg.chat.id),
}
);
return;
}
@@ -99,8 +109,8 @@ bot.onText(/\/start/, async (msg) => {
await handleStart(bot, msg);
});
// Handle /buy command
bot.onText(/\/buy/, async (msg) => {
// Handle /buyticket command
bot.onText(/\/buyticket/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Check if in group
@@ -109,6 +119,7 @@ bot.onText(/\/buy/, async (msg) => {
if (settings && !settings.ticketPurchaseAllowed) {
await bot.sendMessage(msg.chat.id, messages.groups.purchasesDisabled, {
parse_mode: 'Markdown',
reply_markup: getReplyMarkupForChat(msg.chat.id),
});
return;
}
@@ -123,7 +134,9 @@ bot.onText(/\/tickets/, async (msg) => {
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '🧾 To view your tickets, message me directly!');
await bot.sendMessage(msg.chat.id, '🧾 To view your tickets, message me directly!', {
reply_markup: getReplyMarkupForChat(msg.chat.id),
});
return;
}
@@ -136,43 +149,49 @@ bot.onText(/\/wins/, async (msg) => {
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '🏆 To view your wins, message me directly!');
await bot.sendMessage(msg.chat.id, '🏆 To view your wins, message me directly!', {
reply_markup: getReplyMarkupForChat(msg.chat.id),
});
return;
}
await handleWinsCommand(bot, msg);
});
// Handle /address command
bot.onText(/\/address/, async (msg) => {
// Handle /lottoaddress command
bot.onText(/\/lottoaddress/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '⚡ To update your Lightning Address, message me directly!');
await bot.sendMessage(msg.chat.id, '⚡ To update your Lightning Address, message me directly!', {
reply_markup: getReplyMarkupForChat(msg.chat.id),
});
return;
}
await handleAddressCommand(bot, msg);
});
// Handle /menu command
bot.onText(/\/menu/, async (msg) => {
// Handle /lottomenu command
bot.onText(/\/lottomenu/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '📱 To access the full menu, message me directly!');
await bot.sendMessage(msg.chat.id, '📱 To access the full menu, message me directly!', {
reply_markup: getReplyMarkupForChat(msg.chat.id),
});
return;
}
await handleMenuCommand(bot, msg);
});
// Handle /help command
bot.onText(/\/help/, async (msg) => {
// Handle /lottohelp command
bot.onText(/\/lottohelp/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
await handleHelpCommand(bot, msg);
await handleHelpCommand(bot, msg, isGroupChat(msg));
});
// Handle /jackpot command (works in groups and DMs)
@@ -197,17 +216,20 @@ bot.onText(/\/jackpot/, async (msg) => {
⏰ *Draw at:* ${formatDate(drawTime)}
⏳ *Time left:* ${formatTimeUntil(drawTime)}
Use /buy to get your tickets! 🍀`;
Use /buyticket to get your tickets! 🍀`;
await bot.sendMessage(msg.chat.id, message, { parse_mode: 'Markdown' });
await bot.sendMessage(msg.chat.id, message, {
parse_mode: 'Markdown',
reply_markup: getReplyMarkupForChat(msg.chat.id),
});
} catch (error) {
logger.error('Error in /jackpot command', { error });
await bot.sendMessage(msg.chat.id, messages.errors.systemUnavailable);
}
});
// Handle /settings command (groups only, admin only)
bot.onText(/\/settings/, async (msg) => {
// Handle /lottosettings command (groups only, admin only)
bot.onText(/\/lottosettings/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
await handleGroupSettings(bot, msg);
});
@@ -222,6 +244,7 @@ bot.on('message', async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Ignore group messages for button handling
// Keyboards are explicitly removed in all group chat messages
if (isGroupChat(msg)) return;
const text = msg.text.trim();
@@ -231,6 +254,33 @@ bot.on('message', async (msg) => {
// Handle menu button presses
switch (text) {
case '🎰 Upcoming Jackpot':
try {
const jackpot = await apiClient.getNextJackpot();
if (!jackpot) {
await bot.sendMessage(msg.chat.id, messages.buy.noActiveJackpot, {
parse_mode: 'Markdown',
});
return;
}
const drawTime = new Date(jackpot.cycle.scheduled_at);
const jackpotMessage = `🎰 *Upcoming Jackpot*
💰 *Prize Pool:* ${formatSats(jackpot.cycle.pot_total_sats)} sats
🎟 *Ticket Price:* ${formatSats(jackpot.lottery.ticket_price_sats)} sats
⏰ *Draw at:* ${formatDate(drawTime)}
⏳ *Time left:* ${formatTimeUntil(drawTime)}
Use /buyticket to get your tickets! 🍀`;
await bot.sendMessage(msg.chat.id, jackpotMessage, { parse_mode: 'Markdown' });
} catch (error) {
logger.error('Error showing jackpot', { error });
await bot.sendMessage(msg.chat.id, messages.errors.systemUnavailable);
}
return;
case '🎟 Buy Tickets':
await handleBuyCommand(bot, msg);
return;
@@ -243,6 +293,9 @@ bot.on('message', async (msg) => {
case '⚡ Lightning Address':
await handleAddressCommand(bot, msg);
return;
case '⚙️ Settings':
await handleSettingsCommand(bot, msg);
return;
case ' Help':
await handleHelpCommand(bot, msg);
return;
@@ -264,6 +317,12 @@ bot.on('message', async (msg) => {
if (handled) return;
}
// Handle display name input
if (user.state === 'awaiting_display_name') {
await handleDisplayNameInput(bot, msg);
return;
}
// Handle custom ticket amount input
if (user.state === 'awaiting_ticket_amount') {
const handled = await handleCustomTicketAmount(bot, msg);
@@ -297,16 +356,55 @@ bot.on('callback_query', async (query) => {
logUserAction(query.from.id, 'Callback', { data });
try {
// Handle group settings toggles
// Handle group settings toggles (now includes group ID: group_toggle_enabled_-123456789)
if (data.startsWith('group_toggle_')) {
const action = data.replace('group_', '');
await handleGroupSettingsCallback(bot, query, action);
return;
}
// Handle group refresh
if (data === 'group_refresh') {
await handleGroupRefresh(bot, query);
// Handle group reminder time adjustment (reminder1_add_1_hours_-123456789, etc.)
if (data.match(/^group_reminder\d_(add|sub)_\d+_(minutes|hours|days)_-?\d+$/)) {
const action = data.replace('group_', '');
await handleGroupSettingsCallback(bot, query, action);
return;
}
// Handle group announcement delay selection (group_announce_delay_30_-123456789)
if (data.match(/^group_announce_delay_\d+_-?\d+$/)) {
const action = data.replace('group_', '');
await handleGroupSettingsCallback(bot, query, action);
return;
}
// Handle new jackpot announcement delay selection (group_newjackpot_delay_5_-123456789)
if (data.match(/^group_newjackpot_delay_\d+_-?\d+$/)) {
const action = data.replace('group_', '');
await handleGroupSettingsCallback(bot, query, action);
return;
}
// Handle group refresh (group_refresh_-123456789)
if (data.match(/^group_refresh_-?\d+$/)) {
const groupIdMatch = data.match(/^group_refresh_(-?\d+)$/);
if (groupIdMatch) {
const groupId = parseInt(groupIdMatch[1], 10);
await handleGroupRefresh(bot, query, groupId);
}
return;
}
// Handle user settings callbacks
if (data.startsWith('settings_')) {
const action = data.replace('settings_', '');
await handleSettingsCallback(bot, query, action);
return;
}
// Handle lightning address selection (21Tipbot/Bittip)
if (data.startsWith('ln_addr_')) {
const action = data.replace('ln_addr_', '');
await handleLightningAddressCallback(bot, query, action);
return;
}
@@ -342,6 +440,15 @@ bot.on('callback_query', async (query) => {
return;
}
// Handle tickets pagination
if (data.startsWith('tickets_page_')) {
const page = parseInt(data.replace('tickets_page_', ''), 10);
if (!isNaN(page)) {
await handleTicketsPage(bot, query, page);
}
return;
}
// Handle view ticket
if (data.startsWith('view_ticket_')) {
const purchaseId = data.replace('view_ticket_', '');
@@ -389,6 +496,7 @@ async function shutdown(): Promise<void> {
bot.stopPolling();
await stateManager.close();
await groupStateManager.close();
botDatabase.close();
logger.info('Shutdown complete');
process.exit(0);
}
@@ -399,28 +507,31 @@ process.on('SIGTERM', shutdown);
// Start bot
async function start(): Promise<void> {
try {
// Initialize state managers
// Initialize SQLite database
botDatabase.init();
// Initialize state managers (now use SQLite)
await stateManager.init();
await groupStateManager.init(config.redis.url);
await groupStateManager.init();
// Set bot commands for private chats
await bot.setMyCommands([
{ command: 'start', description: 'Start the bot' },
{ command: 'menu', description: 'Show main menu' },
{ command: 'buy', description: 'Buy lottery tickets' },
{ command: 'lottomenu', description: 'Show main menu' },
{ command: 'buyticket', description: 'Buy lottery tickets' },
{ command: 'tickets', description: 'View your tickets' },
{ command: 'wins', description: 'View your past wins' },
{ command: 'address', description: 'Update Lightning Address' },
{ command: 'lottoaddress', description: 'Update Lightning Address' },
{ command: 'jackpot', description: 'View current jackpot info' },
{ command: 'help', description: 'Help & information' },
{ command: 'lottohelp', description: 'Help & information' },
]);
// Set bot commands for groups (different scope)
await bot.setMyCommands(
[
{ command: 'jackpot', description: 'View current jackpot info' },
{ command: 'settings', description: 'Group settings (admin only)' },
{ command: 'help', description: 'Help & information' },
{ command: 'lottosettings', description: 'Group settings (admin only)' },
{ command: 'lottohelp', description: 'Help & information' },
],
{ scope: { type: 'all_group_chats' } }
);
@@ -431,10 +542,15 @@ async function start(): Promise<void> {
username: botInfo.username,
});
// Initialize and start notification scheduler
notificationScheduler.init(bot);
notificationScheduler.start();
logger.info('⚡ Lightning Jackpot Telegram Bot is running!');
logger.info(`📡 API URL: ${config.api.baseUrl}`);
logger.info(`🌐 Frontend URL: ${config.frontend.baseUrl}`);
logger.info('👥 Group support enabled');
logger.info('📢 Notification scheduler started');
} catch (error) {
logger.error('Failed to start bot', { error });
process.exit(1);

View File

@@ -8,7 +8,7 @@ export const messages = {
// ═══════════════════════════════════════════════════════════════════════════
errors: {
userNotIdentified: '❌ Could not identify user.',
startFirst: '❌ Please start the bot first with /start',
startFirst: '❌ Hold on! Before using this command, initiate a private chat with me (@LightningLottoBot).',
generic: '❌ An error occurred. Please try again.',
startAgain: '❌ An error occurred. Please try again with /start',
systemUnavailable: '❌ The lottery system is temporarily unavailable. Please try again soon.',
@@ -18,8 +18,19 @@ export const messages = {
ticketNotFound: '❌ Ticket not found.',
fetchTicketDetailsFailed: '❌ Failed to fetch ticket details.',
checkStatusFailed: '❌ Failed to check status',
noPendingPurchase: '❌ No pending purchase. Please start again with /buy',
noPendingPurchase: '❌ No pending purchase. Please start again with /buyticket',
setAddressFirst: '❌ Please set your Lightning Address first.',
maintenance: (message: string) => `🔧 *Maintenance Mode*
${message}
Please try again later. We'll be back soon! ⚡`,
maintenancePending: `⏳ *Maintenance Scheduled*
Maintenance will begin after the current draw completes.
This is your *last chance* to buy tickets for the current round! 🎟️`,
},
// ═══════════════════════════════════════════════════════════════════════════
@@ -42,6 +53,21 @@ You can buy Bitcoin Lightning lottery tickets, and if you win, your prize is pai
Please send your Lightning Address now:`,
needAddressWithOptions: (username?: string) => {
let msg = `Before you can play, I need your Lightning Address to send any winnings.\n\n`;
if (username) {
msg += `🤖 *Quick Setup* - Choose a tip bot below:\n\n`;
msg += `Or send me your Lightning Address to set a custom one.\n`;
msg += `*Example:* \`yourname@getalby.com\``;
} else {
msg += `*Example:* \`yourname@getalby.com\`\n\n`;
msg += `Please send your Lightning Address now:`;
}
return msg;
},
addressSet: (address: string) =>
`✅ Your payout address is set to: \`${address}\`
@@ -60,12 +86,40 @@ Use the menu below to get started! Good luck! 🍀`,
Send me your new Lightning Address to update it:`,
currentAddressWithOptions: (address: string, username?: string) => {
let msg = `⚡ *Your Current Payout Address:*\n\`${address}\`\n\n`;
if (username) {
msg += `🤖 *Quick Options* - Choose a tip bot below:\n\n`;
msg += `Or send me your Lightning Address to set a custom one.`;
} else {
msg += `Send me your new Lightning Address to update it:`;
}
return msg;
},
noAddressSet: `⚡ You don't have a Lightning Address set yet.
Send me your Lightning Address now:
*Example:* \`yourname@getalby.com\``,
noAddressSetWithOptions: (username?: string) => {
let msg = `⚡ You don't have a Lightning Address set yet.\n\n`;
if (username) {
msg += `🤖 *Quick Setup* - Choose a tip bot below:\n\n`;
msg += `Or send me your Lightning Address to set a custom one.\n`;
msg += `*Example:* \`yourname@getalby.com\``;
} else {
msg += `Send me your Lightning Address now:\n\n`;
msg += `*Example:* \`yourname@getalby.com\``;
}
return msg;
},
invalidFormat: `❌ That doesn't look like a valid Lightning Address.
*Format:* \`username@domain.com\`
@@ -73,6 +127,31 @@ Send me your Lightning Address now:
Please try again:`,
verifying: '🔍 Verifying your Lightning Address...',
verificationFailed: (address: string, error?: string) =>
`❌ *Could not verify Lightning Address*
Address: \`${address}\`
${error ? `Error: ${error}\n` : ''}
Please check the address and try again, or choose a different option:`,
verifyingService: (service: string, address: string) =>
`🔍 Verifying your ${service} address: \`${address}\`...`,
serviceNotSetup: (service: string, error?: string) =>
`❌ *${service} address not found*
It looks like you haven't set up ${service} yet, or your username doesn't match.
${error ? `Error: ${error}\n\n` : ''}Please set up ${service} first, or enter a different Lightning Address:`,
noUsername: `❌ You don't have a Telegram username set!
To use 21Tipbot or Bittip, you need a Telegram username.
Please set a username in Telegram settings, or enter a custom Lightning Address:`,
firstTimeSuccess: (address: string) =>
`✅ *Perfect!* I'll use \`${address}\` to send any winnings.
@@ -153,16 +232,14 @@ Confirm this purchase?`,
invoiceCaption: (
ticketCount: number,
totalAmount: string,
paymentRequest: string,
expiryMinutes: number
) =>
`🎟 *${ticketCount} ticket${ticketCount > 1 ? 's' : ''}*
💰 *Amount:* ${totalAmount} sats
⏳ Expires in ${expiryMinutes} minutes`,
\`${paymentRequest}\`
⏳ This invoice expires in ${expiryMinutes} minutes.
I'll notify you when payment is received!`,
invoiceString: (paymentRequest: string) =>
`\`${paymentRequest}\``,
paymentReceived: (ticketNumbers: string, drawTime: string) =>
`🎉 *Payment Received!*
@@ -180,13 +257,13 @@ Good luck! 🍀 I'll notify you after the draw!`,
No payment was received in time. No tickets were issued.
Use /buy to try again.`,
Use /buyticket to try again.`,
invoiceExpiredShort: `❌ *Invoice Expired*
This invoice has expired. No tickets were issued.
Use /buy to try again.`,
Use /buyticket to try again.`,
jackpotUnavailable: '❌ Jackpot is no longer available.',
},
@@ -201,13 +278,13 @@ Use /buy to try again.`,
You haven't purchased any tickets yet!
Use /buy to get started! 🎟`,
Use /buyticket to get started! 🎟`,
notFound: `🧾 *Your Tickets*
No ticket purchases found. Purchase history may have expired.
Use /buy to get new tickets! 🎟`,
Use /buyticket to get new tickets! 🎟`,
tapForDetails: `\nTap a ticket below for details:`,
@@ -266,13 +343,13 @@ ${statusSection}`,
You haven't purchased any tickets yet, so no wins to show!
Use /buy to get started! 🎟`,
Use /buyticket to get started! 🎟`,
noWinsYet: `🏆 *Your Wins*
You haven't won any jackpots yet. Keep playing!
Use /buy to get more tickets! 🎟🍀`,
Use /buyticket to get more tickets! 🎟🍀`,
header: (totalWinnings: string, paidWinnings: string) =>
`🏆 *Your Wins*
@@ -300,20 +377,47 @@ This is the Lightning Jackpot lottery bot! Buy tickets with Bitcoin Lightning, a
5⃣ If you win, sats are sent to your address instantly!
*Commands:*
• /buy — Buy lottery tickets
• /buyticket — Buy lottery tickets
• /tickets — View your tickets
• /wins — View your past wins
• /address — Update Lightning Address
• /menu — Show main menu
• /help — Show this help
• /lottoaddress — Update Lightning Address
• /lottomenu — Show main menu
• /lottohelp — Show this help
• /jackpot — View current jackpot info
*Settings (use ⚙️ Settings button):*
• Set your display name (shown if you win)
• Enable/disable draw reminders
• Enable/disable draw results notifications
• Enable/disable new jackpot alerts
*Tips:*
🎟 Each ticket is one chance to win
💰 Prize pool grows with each ticket sold
⚡ Winnings are paid instantly via Lightning
🔔 You'll be notified after every draw
🔔 You'll be notified after every draw you participate in
Good luck! 🍀`,
groupMessage: `⚡🎰 *Lightning Jackpot Bot - Group Help* 🎰⚡
*User Commands:*
• /jackpot — View current jackpot info
• /buyticket — Buy lottery tickets
• /lottohelp — Show this help
*Admin Commands:*
• /lottosettings — Configure bot settings for this group
*Admin Settings:*
👉 *Bot Enabled* — Enable/disable the bot in this group
👉 *Draw Announcements* — Announce draw winners in this group
👉 *Draw Reminders* — Send reminders before draws
👉 *Ticket Purchases* — Allow /buyticket command in group
💡 *Tip:* For privacy, ticket purchases in groups can be disabled. Users can always buy tickets by messaging the bot directly.
To buy tickets privately, message me directly!`,
},
// ═══════════════════════════════════════════════════════════════════════════
@@ -360,29 +464,118 @@ Good luck next round! 🍀`,
winnerName: string,
winningTicket: string,
prizeSats: string,
totalTickets: number
totalTickets: number,
winnerAddress?: string
) =>
`🎰 *JACKPOT DRAW COMPLETE!* 🎰
🏆 *Winner:* ${winnerName}
⚡ *Address:* \`${winnerAddress || 'N/A'}\`
🎟 *Winning Ticket:* #${winningTicket}
💰 *Prize:* ${prizeSats} sats
📊 *Total Tickets:* ${totalTickets}
Congratulations to the winner! ⚡
Use /buy to enter the next draw! 🍀`,
Use /buyticket to enter the next draw! 🍀`,
drawReminder: (potSats: string, drawTime: string, timeLeft: string) =>
`⏰ *Draw Reminder!*
drawCompleted: (potSats: number, hasWinner: boolean) =>
hasWinner
? `🎰 *JACKPOT DRAW COMPLETE!* 🎰
🎰 The next Lightning Jackpot draw is coming up!
💰 *Jackpot:* ${potSats.toLocaleString()} sats
🏆 A winner has been selected!
💰 *Current Prize Pool:* ${potSats} sats
🕐 *Draw Time:* ${drawTime}
⏳ *Time Left:* ${timeLeft}
Use /buyticket to enter the next round! 🍀`
: `🎰 *JACKPOT DRAW COMPLETE!* 🎰
Don't miss your chance to win! Use /buy to get your tickets! 🎟`,
No tickets were sold this round.
The jackpot rolls over to the next draw!
Use /buyticket to be the first to enter! 🍀`,
drawReminder: (value: number, unit: string, drawTime: Date, potSats: number) => {
const timeStr = unit === 'days'
? `${value} day${value > 1 ? 's' : ''}`
: unit === 'hours'
? `${value} hour${value > 1 ? 's' : ''}`
: `${value} minute${value > 1 ? 's' : ''}`;
const drawTimeStr = drawTime.toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
return `⏰ *Draw Reminder!*
🎰 The next Lightning Jackpot draw is in *${timeStr}*!
💰 *Current Prize Pool:* ${potSats.toLocaleString()} sats
🕐 *Draw Time:* ${drawTimeStr}
Don't miss your chance to win! Use /buyticket to get your tickets! 🎟`;
},
newJackpot: (lotteryName: string, ticketPrice: number, drawTime: Date) => {
const drawTimeStr = drawTime.toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
return `🎉 *NEW JACKPOT STARTED!* 🎉
⚡ *${lotteryName}*
A new lottery round has begun!
🎟 *Ticket Price:* ${ticketPrice} sats
🕐 *Draw Time:* ${drawTimeStr}
Be first to enter! Use /buyticket to buy tickets! 🍀`;
},
},
// ═══════════════════════════════════════════════════════════════════════════
// USER SETTINGS
// ═══════════════════════════════════════════════════════════════════════════
settings: {
overview: (displayName: string, notifications: { drawReminders: boolean; drawResults: boolean; newJackpotAlerts: boolean }) =>
`⚙️ *Your Settings*
👤 *Display Name:* ${displayName}
_(Shown when announcing winners. Defaults to your @username)_
*Notifications:*
${notifications.drawReminders ? '✅' : '❌'} Draw Reminders _(15 min before draws)_
${notifications.drawResults ? '✅' : '❌'} Draw Results _(when your tickets are drawn)_
${notifications.newJackpotAlerts ? '✅' : '❌'} New Jackpot Alerts _(when new rounds start)_
Tap buttons below to change settings:`,
enterDisplayName: `👤 *Set Display Name*
Enter your display name (max 20 characters).
This name will be shown if you win!
_Your Telegram @username is used by default._
_Send "Anon" to stay anonymous._`,
nameTooLong: '❌ Display name must be 20 characters or less. Please try again:',
nameUpdated: (name: string) =>
`✅ *Display Name Updated!*
Your display name is now: *${name}*
This will be shown when announcing winners.`,
},
// ═══════════════════════════════════════════════════════════════════════════
@@ -397,16 +590,16 @@ Hello *${groupName}*! I'm the Lightning Jackpot lottery bot.
I can announce lottery draws and remind you when jackpots are coming up!
*Group Admin Commands:*
• /settings — Configure bot settings for this group
• /lottosettings — Configure bot settings for this group
*User Commands:*
• /buy — Buy lottery tickets (in DM)
• /buyticket — Buy lottery tickets
• /jackpot — View current jackpot info
• /help — Get help
• /lottohelp — Get help
To buy tickets, message me directly @LightningLottoBot! 🎟`,
To buy tickets privately, message me directly! 🎟`,
privateChat: '❌ This command only works in groups. Use /menu to see available commands.',
privateChat: '❌ This command only works in groups. Use /lottomenu to see available commands.',
adminOnly: '⚠️ Only group administrators can change these settings.',
@@ -415,19 +608,65 @@ To buy tickets, message me directly @LightningLottoBot! 🎟`,
enabled: boolean;
drawAnnouncements: boolean;
reminders: boolean;
newJackpotAnnouncement?: boolean;
ticketPurchaseAllowed: boolean;
}) =>
`⚙️ *Group Settings*
reminder1Enabled?: boolean;
reminder1Time?: { value: number; unit: string };
reminder2Enabled?: boolean;
reminder2Time?: { value: number; unit: string };
reminder3Enabled?: boolean;
reminder3Time?: { value: number; unit: string };
announcementDelaySeconds?: number;
newJackpotDelayMinutes?: number;
}) => {
const announceDelay = settings.announcementDelaySeconds ?? 10;
const formatAnnounce = announceDelay === 0
? 'Immediately'
: 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 }) => {
if (!t) return '?';
if (t.unit === 'minutes') return `${t.value}m`;
if (t.unit === 'hours') return t.value === 1 ? '1h' : `${t.value}h`;
return t.value === 1 ? '1d' : `${t.value}d`;
};
// Format reminder times (3-tier system)
const r1 = settings.reminder1Enabled !== false;
const r2 = settings.reminder2Enabled === true;
const r3 = settings.reminder3Enabled === true;
const activeReminders: string[] = [];
if (r1) activeReminders.push(formatTime(settings.reminder1Time) || '1h');
if (r2) activeReminders.push(formatTime(settings.reminder2Time) || '1d');
if (r3) activeReminders.push(formatTime(settings.reminder3Time) || '6d');
const formatReminderList = activeReminders.length > 0
? activeReminders.join(', ')
: 'None';
const newJackpot = settings.newJackpotAnnouncement !== false;
return `⚙️ *Group Settings*
📍 *Group:* ${settings.groupTitle}
*Current Configuration:*
${settings.enabled ? '✅' : '❌'} Bot Enabled
${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements
${settings.reminders ? '✅' : '❌'} Draw Reminders
${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
Tap a button below to toggle settings:`,
_Tap buttons to toggle features or adjust times._
_This message will auto-delete in 2 minutes._`;
},
settingUpdated: (setting: string, enabled: boolean) =>
`✅ *${setting}* has been ${enabled ? 'enabled' : 'disabled'}.`,

View File

@@ -70,7 +70,7 @@ class ApiClient {
async buyTickets(
tickets: number,
lightningAddress: string,
telegramUserId: number
displayName: string = 'Anon'
): Promise<BuyTicketsResponse> {
try {
const response = await this.client.post<ApiResponse<BuyTicketsResponse>>(
@@ -78,7 +78,7 @@ class ApiClient {
{
tickets,
lightning_address: lightningAddress,
buyer_name: `TG:${telegramUserId}`,
buyer_name: displayName || 'Anon',
}
);
return response.data.data;
@@ -138,8 +138,54 @@ class ApiClient {
return false;
}
}
/**
* Check if system is in maintenance mode
*/
async checkMaintenanceStatus(): Promise<{ enabled: boolean; pending: boolean; message: string | null }> {
try {
const response = await this.client.get<ApiResponse<{ maintenance_mode: boolean; maintenance_pending: boolean; message: string | null }>>(
'/status/maintenance'
);
return {
enabled: response.data.data.maintenance_mode,
pending: response.data.data.maintenance_pending,
message: response.data.data.message,
};
} catch (error) {
// If endpoint doesn't exist or fails, assume not in maintenance
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 default apiClient;

View File

@@ -0,0 +1,689 @@
import Database from 'better-sqlite3';
import path from 'path';
import { logger } from './logger';
import { TelegramUser, NotificationPreferences, DEFAULT_NOTIFICATIONS } from '../types';
import { GroupSettings, DEFAULT_GROUP_SETTINGS, ReminderTime } from '../types/groups';
const DB_PATH = process.env.BOT_DATABASE_PATH || path.join(__dirname, '../../data/bot.db');
class BotDatabase {
private db: Database.Database | null = null;
/**
* Initialize the database
*/
init(): void {
try {
// Ensure data directory exists
const fs = require('fs');
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
this.db = new Database(DB_PATH);
this.db.pragma('journal_mode = WAL');
this.createTables();
logger.info('Bot database initialized', { path: DB_PATH });
} catch (error) {
logger.error('Failed to initialize bot database', { error });
throw error;
}
}
/**
* Create database tables
*/
private createTables(): void {
if (!this.db) return;
// Users table
this.db.exec(`
CREATE TABLE IF NOT EXISTS users (
telegram_id INTEGER PRIMARY KEY,
username TEXT,
first_name TEXT,
last_name TEXT,
display_name TEXT DEFAULT 'Anon',
lightning_address TEXT,
state TEXT DEFAULT 'idle',
state_data TEXT,
notif_draw_reminders INTEGER DEFAULT 1,
notif_draw_results INTEGER DEFAULT 1,
notif_new_jackpot_alerts INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// User purchases (tracking which purchases belong to which user)
this.db.exec(`
CREATE TABLE IF NOT EXISTS user_purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_id INTEGER NOT NULL,
purchase_id TEXT NOT NULL UNIQUE,
cycle_id TEXT,
ticket_count INTEGER,
amount_sats INTEGER,
lightning_address TEXT,
payment_request TEXT,
public_url TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (telegram_id) REFERENCES users(telegram_id)
)
`);
// Cycle participants (for notifications)
this.db.exec(`
CREATE TABLE IF NOT EXISTS cycle_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cycle_id TEXT NOT NULL,
telegram_id INTEGER NOT NULL,
purchase_id TEXT NOT NULL,
joined_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cycle_id, telegram_id, purchase_id),
FOREIGN KEY (telegram_id) REFERENCES users(telegram_id)
)
`);
// Groups table
this.db.exec(`
CREATE TABLE IF NOT EXISTS groups (
group_id INTEGER PRIMARY KEY,
group_title TEXT,
enabled INTEGER DEFAULT 1,
draw_announcements INTEGER DEFAULT 1,
reminders INTEGER DEFAULT 1,
new_jackpot_announcement INTEGER DEFAULT 1,
ticket_purchase_allowed INTEGER DEFAULT 0,
reminder1_enabled INTEGER DEFAULT 1,
reminder1_time TEXT DEFAULT '{"value":1,"unit":"hours"}',
reminder2_enabled INTEGER DEFAULT 0,
reminder2_time TEXT DEFAULT '{"value":1,"unit":"days"}',
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
)
`);
// Create indexes
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_user_purchases_telegram_id ON user_purchases(telegram_id);
CREATE INDEX IF NOT EXISTS idx_user_purchases_cycle_id ON user_purchases(cycle_id);
CREATE INDEX IF NOT EXISTS idx_cycle_participants_cycle_id ON cycle_participants(cycle_id);
CREATE INDEX IF NOT EXISTS idx_cycle_participants_telegram_id ON cycle_participants(telegram_id);
`);
// Run migrations for existing databases
this.runMigrations();
logger.debug('Database tables created/verified');
}
/**
* Run migrations for existing databases
*/
private runMigrations(): void {
if (!this.db) return;
// Check if new_jackpot_delay_minutes column exists in groups table
const groupColumns = this.db.prepare(`PRAGMA table_info(groups)`).all() as any[];
const hasNewJackpotDelay = groupColumns.some(col => col.name === 'new_jackpot_delay_minutes');
if (!hasNewJackpotDelay) {
logger.info('Running migration: adding new_jackpot_delay_minutes column to groups');
this.db.exec(`ALTER TABLE groups ADD COLUMN new_jackpot_delay_minutes INTEGER DEFAULT 5`);
}
logger.debug('Database migrations completed');
}
// ═══════════════════════════════════════════════════════════════════════════
// USER METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get user by Telegram ID
*/
getUser(telegramId: number): TelegramUser | null {
if (!this.db) return null;
const row = this.db.prepare(`
SELECT * FROM users WHERE telegram_id = ?
`).get(telegramId) as any;
if (!row) return null;
return this.rowToUser(row);
}
/**
* Create a new user
* Default display name is @username if available, otherwise 'Anon'
*/
createUser(
telegramId: number,
username?: string,
firstName?: string,
lastName?: string
): TelegramUser {
if (!this.db) throw new Error('Database not initialized');
const now = new Date().toISOString();
// Default display name: @username if available, otherwise 'Anon'
const defaultDisplayName = username ? `@${username}` : 'Anon';
this.db.prepare(`
INSERT INTO users (telegram_id, username, first_name, last_name, display_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(telegramId, username || null, firstName || null, lastName || null, defaultDisplayName, now, now);
logger.info('New user created', { telegramId, username, displayName: defaultDisplayName });
return this.getUser(telegramId)!;
}
/**
* Update user
*/
saveUser(user: TelegramUser): void {
if (!this.db) return;
const now = new Date().toISOString();
const stateData = user.stateData ? JSON.stringify(user.stateData) : null;
this.db.prepare(`
UPDATE users SET
username = ?,
first_name = ?,
last_name = ?,
display_name = ?,
lightning_address = ?,
state = ?,
state_data = ?,
notif_draw_reminders = ?,
notif_draw_results = ?,
notif_new_jackpot_alerts = ?,
updated_at = ?
WHERE telegram_id = ?
`).run(
user.username || null,
user.firstName || null,
user.lastName || null,
user.displayName || 'Anon',
user.lightningAddress || null,
user.state,
stateData,
user.notifications?.drawReminders ? 1 : 0,
user.notifications?.drawResults ? 1 : 0,
user.notifications?.newJackpotAlerts ? 1 : 0,
now,
user.telegramId
);
}
/**
* Update user state
*/
updateUserState(telegramId: number, state: string, stateData?: Record<string, any>): void {
if (!this.db) return;
const now = new Date().toISOString();
const data = stateData ? JSON.stringify(stateData) : null;
this.db.prepare(`
UPDATE users SET state = ?, state_data = ?, updated_at = ? WHERE telegram_id = ?
`).run(state, data, now, telegramId);
}
/**
* Update lightning address
*/
updateLightningAddress(telegramId: number, address: string): void {
if (!this.db) return;
const now = new Date().toISOString();
this.db.prepare(`
UPDATE users SET lightning_address = ?, state = 'idle', state_data = NULL, updated_at = ?
WHERE telegram_id = ?
`).run(address, now, telegramId);
}
/**
* Update display name
*/
updateDisplayName(telegramId: number, displayName: string): void {
if (!this.db) return;
const now = new Date().toISOString();
this.db.prepare(`
UPDATE users SET display_name = ?, state = 'idle', state_data = NULL, updated_at = ?
WHERE telegram_id = ?
`).run(displayName || 'Anon', now, telegramId);
}
/**
* Update notification preferences
*/
updateNotifications(telegramId: number, updates: Partial<NotificationPreferences>): TelegramUser | null {
if (!this.db) return null;
const user = this.getUser(telegramId);
if (!user) return null;
const now = new Date().toISOString();
const notifications = { ...user.notifications, ...updates };
this.db.prepare(`
UPDATE users SET
notif_draw_reminders = ?,
notif_draw_results = ?,
notif_new_jackpot_alerts = ?,
updated_at = ?
WHERE telegram_id = ?
`).run(
notifications.drawReminders ? 1 : 0,
notifications.drawResults ? 1 : 0,
notifications.newJackpotAlerts ? 1 : 0,
now,
telegramId
);
return this.getUser(telegramId);
}
/**
* Get users with specific notification enabled
*/
getUsersWithNotification(preference: keyof NotificationPreferences): TelegramUser[] {
if (!this.db) return [];
const column = preference === 'drawReminders' ? 'notif_draw_reminders'
: preference === 'drawResults' ? 'notif_draw_results'
: 'notif_new_jackpot_alerts';
const rows = this.db.prepare(`
SELECT * FROM users WHERE ${column} = 1
`).all() as any[];
return rows.map(row => this.rowToUser(row));
}
/**
* Convert database row to TelegramUser
*/
private rowToUser(row: any): TelegramUser {
return {
telegramId: row.telegram_id,
username: row.username || undefined,
firstName: row.first_name || undefined,
lastName: row.last_name || undefined,
displayName: row.display_name || 'Anon',
lightningAddress: row.lightning_address || undefined,
state: row.state || 'idle',
stateData: row.state_data ? JSON.parse(row.state_data) : undefined,
notifications: {
drawReminders: row.notif_draw_reminders === 1,
drawResults: row.notif_draw_results === 1,
newJackpotAlerts: row.notif_new_jackpot_alerts === 1,
},
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
};
}
// ═══════════════════════════════════════════════════════════════════════════
// PURCHASE METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Store a purchase
*/
storePurchase(
telegramId: number,
purchaseId: string,
data: {
cycleId: string;
ticketCount: number;
totalAmount: number;
lightningAddress: string;
paymentRequest: string;
publicUrl: string;
}
): void {
if (!this.db) return;
this.db.prepare(`
INSERT OR REPLACE INTO user_purchases
(telegram_id, purchase_id, cycle_id, ticket_count, amount_sats, lightning_address, payment_request, public_url, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')
`).run(
telegramId,
purchaseId,
data.cycleId,
data.ticketCount,
data.totalAmount,
data.lightningAddress,
data.paymentRequest,
data.publicUrl
);
}
/**
* Update purchase status
*/
updatePurchaseStatus(purchaseId: string, status: string): void {
if (!this.db) return;
this.db.prepare(`
UPDATE user_purchases SET status = ? WHERE purchase_id = ?
`).run(status, purchaseId);
}
/**
* Get user's recent purchase IDs
*/
getUserPurchaseIds(telegramId: number, limit: number = 10): string[] {
if (!this.db) return [];
const rows = this.db.prepare(`
SELECT purchase_id FROM user_purchases
WHERE telegram_id = ?
ORDER BY created_at DESC
LIMIT ?
`).all(telegramId, limit) as any[];
return rows.map(row => row.purchase_id);
}
/**
* Get purchase by ID
*/
getPurchase(purchaseId: string): any | null {
if (!this.db) return null;
return this.db.prepare(`
SELECT * FROM user_purchases WHERE purchase_id = ?
`).get(purchaseId);
}
// ═══════════════════════════════════════════════════════════════════════════
// CYCLE PARTICIPANT METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Add user as cycle participant
*/
addCycleParticipant(cycleId: string, telegramId: number, purchaseId: string): void {
if (!this.db) return;
try {
this.db.prepare(`
INSERT OR IGNORE INTO cycle_participants (cycle_id, telegram_id, purchase_id)
VALUES (?, ?, ?)
`).run(cycleId, telegramId, purchaseId);
} catch (error) {
// Ignore duplicate entries
}
}
/**
* Get cycle participants
*/
getCycleParticipants(cycleId: string): Array<{ telegramId: number; purchaseId: string }> {
if (!this.db) return [];
const rows = this.db.prepare(`
SELECT telegram_id, purchase_id FROM cycle_participants WHERE cycle_id = ?
`).all(cycleId) as any[];
return rows.map(row => ({
telegramId: row.telegram_id,
purchaseId: row.purchase_id,
}));
}
/**
* Check if user participated in cycle
*/
didUserParticipate(cycleId: string, telegramId: number): boolean {
if (!this.db) return false;
const row = this.db.prepare(`
SELECT 1 FROM cycle_participants WHERE cycle_id = ? AND telegram_id = ? LIMIT 1
`).get(cycleId, telegramId);
return !!row;
}
// ═══════════════════════════════════════════════════════════════════════════
// GROUP METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get group settings
*/
getGroup(groupId: number): GroupSettings | null {
if (!this.db) return null;
const row = this.db.prepare(`
SELECT * FROM groups WHERE group_id = ?
`).get(groupId) as any;
if (!row) return null;
return this.rowToGroup(row);
}
/**
* Register or update group
*/
registerGroup(groupId: number, groupTitle: string, addedBy: number): GroupSettings {
if (!this.db) throw new Error('Database not initialized');
const existing = this.getGroup(groupId);
if (existing) {
// Update title
this.db.prepare(`
UPDATE groups SET group_title = ?, updated_at = CURRENT_TIMESTAMP WHERE group_id = ?
`).run(groupTitle, groupId);
return this.getGroup(groupId)!;
}
// Insert new group
this.db.prepare(`
INSERT INTO groups (group_id, group_title, added_by)
VALUES (?, ?, ?)
`).run(groupId, groupTitle, addedBy);
logger.info('New group registered', { groupId, groupTitle, addedBy });
return this.getGroup(groupId)!;
}
/**
* Remove group
*/
removeGroup(groupId: number): void {
if (!this.db) return;
this.db.prepare(`DELETE FROM groups WHERE group_id = ?`).run(groupId);
logger.info('Group removed', { groupId });
}
/**
* Save group settings
*/
saveGroup(settings: GroupSettings): void {
if (!this.db) return;
this.db.prepare(`
UPDATE groups SET
group_title = ?,
enabled = ?,
draw_announcements = ?,
reminders = ?,
new_jackpot_announcement = ?,
ticket_purchase_allowed = ?,
reminder1_enabled = ?,
reminder1_time = ?,
reminder2_enabled = ?,
reminder2_time = ?,
reminder3_enabled = ?,
reminder3_time = ?,
announcement_delay_seconds = ?,
new_jackpot_delay_minutes = ?,
updated_at = CURRENT_TIMESTAMP
WHERE group_id = ?
`).run(
settings.groupTitle,
settings.enabled ? 1 : 0,
settings.drawAnnouncements ? 1 : 0,
settings.reminders ? 1 : 0,
settings.newJackpotAnnouncement ? 1 : 0,
settings.ticketPurchaseAllowed ? 1 : 0,
settings.reminder1Enabled ? 1 : 0,
JSON.stringify(settings.reminder1Time),
settings.reminder2Enabled ? 1 : 0,
JSON.stringify(settings.reminder2Time),
settings.reminder3Enabled ? 1 : 0,
JSON.stringify(settings.reminder3Time),
settings.announcementDelaySeconds,
settings.newJackpotDelayMinutes ?? 5,
settings.groupId
);
}
/**
* Update a boolean group setting
*/
updateGroupSetting(
groupId: number,
setting: string,
value: boolean
): GroupSettings | null {
const group = this.getGroup(groupId);
if (!group) return null;
(group as any)[setting] = value;
this.saveGroup(group);
return this.getGroup(groupId);
}
/**
* Update reminder time
*/
updateReminderTime(groupId: number, slot: 1 | 2 | 3, time: ReminderTime): GroupSettings | null {
const group = this.getGroup(groupId);
if (!group) return null;
switch (slot) {
case 1: group.reminder1Time = time; break;
case 2: group.reminder2Time = time; break;
case 3: group.reminder3Time = time; break;
}
this.saveGroup(group);
return this.getGroup(groupId);
}
/**
* Update announcement delay
*/
updateAnnouncementDelay(groupId: number, seconds: number): GroupSettings | null {
const group = this.getGroup(groupId);
if (!group) return null;
group.announcementDelaySeconds = seconds;
this.saveGroup(group);
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
*/
getGroupsWithFeature(feature: 'enabled' | 'drawAnnouncements' | 'reminders'): GroupSettings[] {
if (!this.db) return [];
const column = feature === 'enabled' ? 'enabled'
: feature === 'drawAnnouncements' ? 'draw_announcements'
: 'reminders';
const rows = this.db.prepare(`
SELECT * FROM groups WHERE enabled = 1 AND ${column} = 1
`).all() as any[];
return rows.map(row => this.rowToGroup(row));
}
/**
* Get all groups
*/
getAllGroups(): GroupSettings[] {
if (!this.db) return [];
const rows = this.db.prepare(`SELECT * FROM groups`).all() as any[];
return rows.map(row => this.rowToGroup(row));
}
/**
* Convert database row to GroupSettings
*/
private rowToGroup(row: any): GroupSettings {
return {
groupId: row.group_id,
groupTitle: row.group_title || 'Group',
enabled: row.enabled === 1,
drawAnnouncements: row.draw_announcements === 1,
reminders: row.reminders === 1,
newJackpotAnnouncement: row.new_jackpot_announcement === 1,
ticketPurchaseAllowed: row.ticket_purchase_allowed === 1,
reminder1Enabled: row.reminder1_enabled === 1,
reminder1Time: row.reminder1_time ? JSON.parse(row.reminder1_time) : { value: 1, unit: 'hours' },
reminder2Enabled: row.reminder2_enabled === 1,
reminder2Time: row.reminder2_time ? JSON.parse(row.reminder2_time) : { value: 1, unit: 'days' },
reminder3Enabled: row.reminder3_enabled === 1,
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),
};
}
/**
* Close database connection
*/
close(): void {
if (this.db) {
this.db.close();
logger.info('Bot database connection closed');
}
}
}
export const botDatabase = new BotDatabase();
export default botDatabase;

View File

@@ -1,224 +1,223 @@
import Redis from 'ioredis';
import config from '../config';
import { botDatabase } from './database';
import { logger } from './logger';
import { GroupSettings, DEFAULT_GROUP_SETTINGS } from '../types/groups';
const GROUP_PREFIX = 'tg_group:';
const GROUPS_LIST_KEY = 'tg_groups_list';
const STATE_TTL = 60 * 60 * 24 * 365; // 1 year
import { GroupSettings, ReminderTime, reminderTimeToMinutes } from '../types/groups';
class GroupStateManager {
private redis: Redis | null = null;
private memoryStore: Map<string, string> = new Map();
private useRedis: boolean = false;
async init(redisUrl: string | null): Promise<void> {
if (redisUrl) {
try {
this.redis = new Redis(redisUrl);
await this.redis.ping();
this.useRedis = true;
logger.info('Group state manager initialized with Redis');
} catch (error) {
logger.warn('Failed to connect to Redis for groups, using in-memory store');
this.redis = null;
this.useRedis = false;
}
}
}
private async get(key: string): Promise<string | null> {
if (this.useRedis && this.redis) {
return await this.redis.get(key);
}
return this.memoryStore.get(key) || null;
}
private async set(key: string, value: string, ttl?: number): Promise<void> {
if (this.useRedis && this.redis) {
if (ttl) {
await this.redis.setex(key, ttl, value);
} else {
await this.redis.set(key, value);
}
} else {
this.memoryStore.set(key, value);
}
}
private async del(key: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.del(key);
} else {
this.memoryStore.delete(key);
}
}
private async sadd(key: string, value: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.sadd(key, value);
} else {
const existing = this.memoryStore.get(key);
const set = existing ? new Set(JSON.parse(existing)) : new Set();
set.add(value);
this.memoryStore.set(key, JSON.stringify([...set]));
}
}
private async srem(key: string, value: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.srem(key, value);
} else {
const existing = this.memoryStore.get(key);
if (existing) {
const set = new Set(JSON.parse(existing));
set.delete(value);
this.memoryStore.set(key, JSON.stringify([...set]));
}
}
}
private async smembers(key: string): Promise<string[]> {
if (this.useRedis && this.redis) {
return await this.redis.smembers(key);
}
const existing = this.memoryStore.get(key);
return existing ? JSON.parse(existing) : [];
async init(): Promise<void> {
// Database is initialized separately
logger.info('Group state manager initialized (using SQLite database)');
}
/**
* Get group settings
*/
async getGroup(groupId: number): Promise<GroupSettings | null> {
const key = `${GROUP_PREFIX}${groupId}`;
const data = await this.get(key);
if (!data) return null;
try {
const settings = JSON.parse(data);
return {
...settings,
addedAt: new Date(settings.addedAt),
updatedAt: new Date(settings.updatedAt),
};
} catch (error) {
logger.error('Failed to parse group settings', { groupId, error });
return null;
}
return botDatabase.getGroup(groupId);
}
/**
* Create or update group settings
*/
async saveGroup(settings: GroupSettings): Promise<void> {
const key = `${GROUP_PREFIX}${settings.groupId}`;
settings.updatedAt = new Date();
await this.set(key, JSON.stringify(settings), STATE_TTL);
await this.sadd(GROUPS_LIST_KEY, settings.groupId.toString());
logger.debug('Group settings saved', { groupId: settings.groupId });
}
/**
* Register a new group when bot is added
* Register a new group
*/
async registerGroup(
groupId: number,
groupTitle: string,
addedBy: number
): Promise<GroupSettings> {
const existing = await this.getGroup(groupId);
if (existing) {
// Update title if changed
existing.groupTitle = groupTitle;
existing.updatedAt = new Date();
await this.saveGroup(existing);
return existing;
}
const settings: GroupSettings = {
groupId,
groupTitle,
...DEFAULT_GROUP_SETTINGS,
addedBy,
addedAt: new Date(),
updatedAt: new Date(),
};
await this.saveGroup(settings);
logger.info('New group registered', { groupId, groupTitle, addedBy });
return settings;
return botDatabase.registerGroup(groupId, groupTitle, addedBy);
}
/**
* Remove group when bot is removed
* Remove a group
*/
async removeGroup(groupId: number): Promise<void> {
const key = `${GROUP_PREFIX}${groupId}`;
await this.del(key);
await this.srem(GROUPS_LIST_KEY, groupId.toString());
logger.info('Group removed', { groupId });
botDatabase.removeGroup(groupId);
}
/**
* Update a specific setting
* Save group settings
*/
async saveGroup(settings: GroupSettings): Promise<void> {
botDatabase.saveGroup(settings);
logger.debug('Group settings saved', { groupId: settings.groupId });
}
/**
* Update a group setting
*/
async updateSetting(
groupId: number,
setting: keyof Pick<GroupSettings, 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed'>,
setting:
| 'enabled'
| 'drawAnnouncements'
| 'reminders'
| 'newJackpotAnnouncement'
| 'ticketPurchaseAllowed'
| 'reminder1Enabled'
| 'reminder2Enabled'
| 'reminder3Enabled',
value: boolean
): Promise<GroupSettings | null> {
const settings = await this.getGroup(groupId);
if (!settings) return null;
settings[setting] = value;
await this.saveGroup(settings);
return settings;
return botDatabase.updateGroupSetting(groupId, setting, value);
}
/**
* Get all groups with a specific feature enabled
* Update reminder time for a slot
*/
async updateReminderTime(
groupId: number,
slot: 1 | 2 | 3,
time: ReminderTime
): Promise<GroupSettings | null> {
return botDatabase.updateReminderTime(groupId, slot, time);
}
/**
* Update announcement delay
*/
async updateAnnouncementDelay(
groupId: number,
seconds: number
): Promise<GroupSettings | null> {
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
*/
async getGroupsWithFeature(
feature: 'enabled' | 'drawAnnouncements' | 'reminders'
feature: 'enabled' | 'drawAnnouncements' | 'reminders' | 'newJackpotAnnouncement'
): Promise<GroupSettings[]> {
const groupIds = await this.smembers(GROUPS_LIST_KEY);
const groups: GroupSettings[] = [];
for (const id of groupIds) {
const settings = await this.getGroup(parseInt(id, 10));
if (settings && settings.enabled && settings[feature]) {
groups.push(settings);
if (feature === 'newJackpotAnnouncement') {
const allGroups = await this.getAllGroups();
return allGroups.filter(g => g.enabled && g.newJackpotAnnouncement);
}
}
return groups;
return botDatabase.getGroupsWithFeature(feature as 'enabled' | 'drawAnnouncements' | 'reminders');
}
/**
* Get all registered groups
* Get all groups
*/
async getAllGroups(): Promise<GroupSettings[]> {
const groupIds = await this.smembers(GROUPS_LIST_KEY);
const groups: GroupSettings[] = [];
return botDatabase.getAllGroups();
}
for (const id of groupIds) {
const settings = await this.getGroup(parseInt(id, 10));
if (settings) {
groups.push(settings);
/**
* Get groups that need reminders for a specific draw time
*/
async getGroupsNeedingReminders(drawTime: Date): Promise<Array<{
settings: GroupSettings;
reminderSlot: 1 | 2 | 3;
}>> {
const allGroups = await this.getGroupsWithFeature('reminders');
const now = new Date();
const minutesUntilDraw = (drawTime.getTime() - now.getTime()) / (1000 * 60);
const results: Array<{ settings: GroupSettings; reminderSlot: 1 | 2 | 3 }> = [];
for (const group of allGroups) {
// Check each reminder slot
if (group.reminder1Enabled) {
const reminderMinutes = reminderTimeToMinutes(group.reminder1Time);
if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) {
results.push({ settings: group, reminderSlot: 1 });
}
}
return groups;
if (group.reminder2Enabled) {
const reminderMinutes = reminderTimeToMinutes(group.reminder2Time);
if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) {
results.push({ settings: group, reminderSlot: 2 });
}
}
if (group.reminder3Enabled) {
const reminderMinutes = reminderTimeToMinutes(group.reminder3Time);
if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) {
results.push({ settings: group, reminderSlot: 3 });
}
}
}
return results;
}
/**
* Add time to a reminder
*/
async addReminderTime(
groupId: number,
slot: 1 | 2 | 3,
amount: number,
unit: 'minutes' | 'hours' | 'days'
): Promise<GroupSettings | null> {
const group = await this.getGroup(groupId);
if (!group) return null;
const timeKey = `reminder${slot}Time` as 'reminder1Time' | 'reminder2Time' | 'reminder3Time';
const currentTime = group[timeKey];
// Convert everything to minutes, add, then convert back
let totalMinutes = reminderTimeToMinutes(currentTime);
switch (unit) {
case 'minutes': totalMinutes += amount; break;
case 'hours': totalMinutes += amount * 60; break;
case 'days': totalMinutes += amount * 24 * 60; break;
}
// Ensure minimum of 1 minute
totalMinutes = Math.max(1, totalMinutes);
// Convert back to best unit
const newTime = this.minutesToReminderTime(totalMinutes);
return this.updateReminderTime(groupId, slot, newTime);
}
/**
* Remove time from a reminder
*/
async removeReminderTime(
groupId: number,
slot: 1 | 2 | 3,
amount: number,
unit: 'minutes' | 'hours' | 'days'
): Promise<GroupSettings | null> {
return this.addReminderTime(groupId, slot, -amount, unit);
}
/**
* Convert total minutes to the best ReminderTime representation
*/
private minutesToReminderTime(totalMinutes: number): ReminderTime {
// Use days if evenly divisible and >= 1 day
if (totalMinutes >= 1440 && totalMinutes % 1440 === 0) {
return { value: totalMinutes / 1440, unit: 'days' };
}
// Use hours if evenly divisible and >= 1 hour
if (totalMinutes >= 60 && totalMinutes % 60 === 0) {
return { value: totalMinutes / 60, unit: 'hours' };
}
// Use minutes
return { value: totalMinutes, unit: 'minutes' };
}
/**
* Shutdown
*/
async close(): Promise<void> {
if (this.redis) {
await this.redis.quit();
}
// Database close is handled separately
logger.info('Group state manager closed');
}
}
export const groupStateManager = new GroupStateManager();
export default groupStateManager;

View File

@@ -80,3 +80,4 @@ export const logPaymentEvent = (
export default logger;

View File

@@ -0,0 +1,807 @@
import TelegramBot from 'node-telegram-bot-api';
import { groupStateManager } from './groupState';
import { stateManager } from './state';
import { apiClient } from './api';
import { logger } from './logger';
import { messages } from '../messages';
import { GroupSettings, reminderTimeToMinutes, formatReminderTime, ReminderTime, DEFAULT_GROUP_REMINDER_SLOTS } from '../types/groups';
import { TelegramUser } from '../types';
import { truncateLightningAddress } from '../utils/format';
interface CycleInfo {
id: string;
scheduled_at: string;
status: string;
pot_total_sats: number;
}
interface ScheduledReminder {
groupId?: number;
telegramId?: number;
cycleId: string;
reminderKey: string;
scheduledFor: Date;
timeout: NodeJS.Timeout;
}
class NotificationScheduler {
private bot: TelegramBot | null = null;
private pollInterval: NodeJS.Timeout | null = null;
private scheduledReminders: Map<string, ScheduledReminder> = new Map();
private lastCycleId: string | null = null;
private lastCycleStatus: string | null = null;
private isRunning = false;
private announcedCycles: Set<string> = new Set(); // Track announced cycles
/**
* Initialize the scheduler with the bot instance
*/
init(bot: TelegramBot): void {
this.bot = bot;
logger.info('Notification scheduler initialized');
}
/**
* Start the scheduler
*/
start(): void {
if (this.isRunning || !this.bot) {
return;
}
this.isRunning = true;
logger.info('Starting notification scheduler');
// Poll every 30 seconds
this.pollInterval = setInterval(() => this.poll(), 30 * 1000);
// Run immediately
this.poll();
}
/**
* Stop the scheduler
*/
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
// Clear all scheduled reminders
for (const reminder of this.scheduledReminders.values()) {
clearTimeout(reminder.timeout);
}
this.scheduledReminders.clear();
this.isRunning = false;
logger.info('Notification scheduler stopped');
}
/**
* Main poll loop
*/
private async poll(): Promise<void> {
try {
const jackpot = await apiClient.getNextJackpot();
if (!jackpot?.cycle) {
return;
}
const cycle = jackpot.cycle;
const lottery = jackpot.lottery;
// Check for draw completion (same cycle, status changed to completed)
if (this.lastCycleId === cycle.id &&
this.lastCycleStatus !== 'completed' &&
cycle.status === 'completed') {
await this.handleDrawCompleted(cycle);
}
// Check for new cycle (new jackpot started)
// IMPORTANT: When we detect a new cycle, the old one is completed
// Send draw completion for the old cycle BEFORE new cycle announcement
if (this.lastCycleId && this.lastCycleId !== cycle.id) {
// The previous cycle has completed - announce the draw result first
await this.handlePreviousCycleCompleted(this.lastCycleId);
// Then announce the new cycle
await this.handleNewCycle(cycle, lottery);
}
// Schedule reminders for current cycle
await this.scheduleGroupReminders(cycle);
await this.scheduleUserReminders(cycle);
this.lastCycleId = cycle.id;
this.lastCycleStatus = cycle.status;
} catch (error) {
logger.error('Error in notification scheduler poll', { error });
}
}
/**
* Handle previous cycle completion when we detect a new cycle
* Always sends group announcements, even if no participants tracked locally
*/
private async handlePreviousCycleCompleted(previousCycleId: string): Promise<void> {
if (!this.bot) return;
// Check if we've already announced this draw
if (this.announcedCycles.has(`draw:${previousCycleId}`)) {
return;
}
this.announcedCycles.add(`draw:${previousCycleId}`);
// Clear reminders for the old cycle
this.clearRemindersForCycle(previousCycleId);
logger.info('Processing previous cycle completion', { cycleId: previousCycleId });
// Fetch winner info from API (works even if no participants tracked locally)
let winnerDisplayName = 'Anon';
let winnerTicketNumber = '0000';
let winnerLightningAddress = '';
let potSats = 0;
let totalTickets = 0;
try {
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 });
}
// Get participants for DM notifications
const participants = await stateManager.getCycleParticipants(previousCycleId);
// Group participants by telegramId
const userPurchases = new Map<number, string[]>();
for (const participant of participants) {
const existing = userPurchases.get(participant.telegramId) || [];
existing.push(participant.purchaseId);
userPurchases.set(participant.telegramId, existing);
}
totalTickets = userPurchases.size;
// Find winner among tracked participants for DM notifications
let winnerTelegramId: number | null = null;
let prizeSats = potSats;
let payoutStatus = 'processing';
for (const [telegramId, purchaseIds] of userPurchases) {
for (const purchaseId of purchaseIds) {
try {
const status = await apiClient.getTicketStatus(purchaseId);
if (!status) continue;
if (status.cycle.pot_total_sats) {
potSats = status.cycle.pot_total_sats;
}
if (status.result.is_winner) {
const user = await stateManager.getUser(telegramId);
winnerTelegramId = telegramId;
if (user) {
winnerDisplayName = stateManager.getDisplayName(user);
}
winnerLightningAddress = status.purchase.lightning_address || winnerLightningAddress;
const winningTicket = status.tickets.find(t => t.is_winning_ticket);
if (winningTicket) {
winnerTicketNumber = winningTicket.serial_number.toString().padStart(4, '0');
}
prizeSats = status.result.payout?.amount_sats || potSats;
payoutStatus = status.result.payout?.status || 'processing';
break;
}
} catch (error) {
logger.error('Error checking purchase status', { purchaseId, error });
}
}
if (winnerTelegramId) break;
}
// Send DM notifications to tracked participants
const notifiedUsers = new Set<number>();
for (const [telegramId, purchaseIds] of userPurchases) {
if (notifiedUsers.has(telegramId)) continue;
notifiedUsers.add(telegramId);
try {
const user = await stateManager.getUser(telegramId);
if (!user || user.notifications?.drawResults === false) continue;
const isWinner = telegramId === winnerTelegramId;
if (isWinner) {
await this.bot.sendMessage(
telegramId,
messages.notifications.winner(
prizeSats.toLocaleString(),
winnerTicketNumber,
payoutStatus
),
{ parse_mode: 'Markdown' }
);
logger.info('Sent winner notification', { telegramId });
} else {
await this.bot.sendMessage(
telegramId,
messages.notifications.loser(
winnerTicketNumber,
potSats.toLocaleString()
),
{ parse_mode: 'Markdown' }
);
logger.debug('Sent draw result to participant', { telegramId });
}
} catch (error) {
logger.error('Failed to notify participant', { telegramId, error });
}
}
// ALWAYS send group announcements (regardless of tracked participants)
await this.sendGroupDrawAnnouncementsImmediate(
previousCycleId,
winnerDisplayName,
winnerTicketNumber,
potSats,
totalTickets,
winnerLightningAddress
);
}
/**
* Send draw announcements to groups immediately (no delay - for cycle transition)
*/
private async sendGroupDrawAnnouncementsImmediate(
cycleId: string,
winnerDisplayName: string,
winnerTicketNumber: string,
potSats: number,
totalParticipants: number,
winnerLightningAddress: string
): Promise<void> {
if (!this.bot) return;
const groups = await groupStateManager.getGroupsWithFeature('drawAnnouncements');
const truncatedAddress = truncateLightningAddress(winnerLightningAddress);
for (const group of groups) {
try {
const message = messages.notifications.drawAnnouncement(
winnerDisplayName,
`#${winnerTicketNumber}`,
potSats.toLocaleString(),
totalParticipants,
truncatedAddress
);
await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' });
logger.debug('Sent draw announcement to group', { groupId: group.groupId });
} catch (error) {
this.handleSendError(error, group.groupId);
}
}
logger.info('Draw announcements sent', {
cycleId,
groupCount: groups.length
});
}
/**
* Handle new cycle announcement
*/
private async handleNewCycle(
cycle: CycleInfo,
lottery: { name: string; ticket_price_sats: number }
): Promise<void> {
if (!this.bot) return;
// Check if we've already announced this cycle
if (this.announcedCycles.has(`new:${cycle.id}`)) {
return;
}
this.announcedCycles.add(`new:${cycle.id}`);
const drawTime = new Date(cycle.scheduled_at);
const bot = this.bot;
// Send to groups (with configurable delay)
const groups = await groupStateManager.getGroupsWithFeature('enabled');
for (const group of groups) {
if (group.newJackpotAnnouncement === false) continue;
const delayMs = (group.newJackpotDelayMinutes ?? 5) * 60 * 1000;
setTimeout(async () => {
try {
const message = messages.notifications.newJackpot(
lottery.name,
lottery.ticket_price_sats,
drawTime
);
await bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' });
logger.debug('Sent new jackpot announcement to group', {
groupId: group.groupId,
delayMinutes: group.newJackpotDelayMinutes ?? 5
});
} catch (error) {
this.handleSendError(error, group.groupId);
}
}, delayMs);
}
// Send to users with new jackpot alerts enabled (immediate)
const users = await stateManager.getUsersWithNotification('newJackpotAlerts');
for (const user of users) {
try {
const message = messages.notifications.newJackpot(
lottery.name,
lottery.ticket_price_sats,
drawTime
);
await this.bot.sendMessage(user.telegramId, message, { parse_mode: 'Markdown' });
logger.debug('Sent new jackpot alert to user', { telegramId: user.telegramId });
} catch (error) {
logger.error('Failed to send new jackpot alert', { telegramId: user.telegramId, error });
}
}
logger.info('New jackpot announcements scheduled/sent', { cycleId: cycle.id });
}
/**
* Handle draw completed - send notifications to participants and groups
* Always sends group announcements, even if no participants tracked locally
*/
private async handleDrawCompleted(cycle: CycleInfo): Promise<void> {
if (!this.bot) return;
// Check if we've already announced this draw
if (this.announcedCycles.has(`draw:${cycle.id}`)) {
return;
}
this.announcedCycles.add(`draw:${cycle.id}`);
// Clear reminders for this cycle
this.clearRemindersForCycle(cycle.id);
logger.info('Processing draw completion', {
cycleId: cycle.id,
potSats: cycle.pot_total_sats
});
// Fetch winner info from API (works even if no participants tracked locally)
let winnerDisplayName = 'Anon';
let winnerTicketNumber = '0000';
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 });
}
// Get participants for DM notifications
const participants = await stateManager.getCycleParticipants(cycle.id);
// Group participants by telegramId
const userPurchases = new Map<number, string[]>();
for (const participant of participants) {
const existing = userPurchases.get(participant.telegramId) || [];
existing.push(participant.purchaseId);
userPurchases.set(participant.telegramId, existing);
}
totalTickets = userPurchases.size;
// Find winner among tracked participants for DM notifications
let winnerTelegramId: number | null = null;
let prizeSats = potSats;
let payoutStatus = 'processing';
for (const [telegramId, purchaseIds] of userPurchases) {
for (const purchaseId of purchaseIds) {
try {
const status = await apiClient.getTicketStatus(purchaseId);
if (!status) continue;
if (status.result.is_winner) {
const user = await stateManager.getUser(telegramId);
winnerTelegramId = telegramId;
if (user) {
winnerDisplayName = stateManager.getDisplayName(user);
}
winnerLightningAddress = status.purchase.lightning_address || winnerLightningAddress;
const winningTicket = status.tickets.find(t => t.is_winning_ticket);
if (winningTicket) {
winnerTicketNumber = winningTicket.serial_number.toString().padStart(4, '0');
}
prizeSats = status.result.payout?.amount_sats || potSats;
payoutStatus = status.result.payout?.status || 'processing';
break;
}
} catch (error) {
logger.error('Error checking purchase status', { purchaseId, error });
}
}
if (winnerTelegramId) break;
}
// Send DM notifications to tracked participants
const notifiedUsers = new Set<number>();
for (const [telegramId] of userPurchases) {
if (notifiedUsers.has(telegramId)) continue;
notifiedUsers.add(telegramId);
try {
const user = await stateManager.getUser(telegramId);
if (!user || user.notifications?.drawResults === false) continue;
const isWinner = telegramId === winnerTelegramId;
if (isWinner) {
await this.bot.sendMessage(
telegramId,
messages.notifications.winner(
prizeSats.toLocaleString(),
winnerTicketNumber,
payoutStatus
),
{ parse_mode: 'Markdown' }
);
logger.info('Sent winner notification', { telegramId });
} else {
await this.bot.sendMessage(
telegramId,
messages.notifications.loser(
winnerTicketNumber,
potSats.toLocaleString()
),
{ parse_mode: 'Markdown' }
);
logger.debug('Sent draw result to participant', { telegramId });
}
} catch (error) {
logger.error('Failed to notify participant', { telegramId, error });
}
}
// ALWAYS send group announcements (regardless of tracked participants)
await this.sendGroupDrawAnnouncements(cycle, winnerDisplayName, winnerTicketNumber, totalTickets, winnerLightningAddress);
}
/**
* Send draw announcements to groups
*/
private async sendGroupDrawAnnouncements(
cycle: CycleInfo,
winnerDisplayName: string,
winnerTicketNumber: string,
totalParticipants: number,
winnerLightningAddress: string
): Promise<void> {
if (!this.bot) return;
const groups = await groupStateManager.getGroupsWithFeature('drawAnnouncements');
const truncatedAddress = truncateLightningAddress(winnerLightningAddress);
for (const group of groups) {
const delay = (group.announcementDelaySeconds || 0) * 1000;
setTimeout(async () => {
try {
const message = messages.notifications.drawAnnouncement(
winnerDisplayName,
`#${winnerTicketNumber}`,
cycle.pot_total_sats.toLocaleString(),
totalParticipants,
truncatedAddress
);
if (this.bot) {
await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' });
logger.debug('Sent draw announcement to group', { groupId: group.groupId });
}
} catch (error) {
this.handleSendError(error, group.groupId);
}
}, delay);
}
logger.info('Draw announcements scheduled', {
cycleId: cycle.id,
groupCount: groups.length
});
}
/**
* Schedule reminders for groups (3-tier system with custom times)
*/
private async scheduleGroupReminders(cycle: CycleInfo): Promise<void> {
if (!this.bot || cycle.status === 'completed' || cycle.status === 'cancelled') {
return;
}
const drawTime = new Date(cycle.scheduled_at);
const now = new Date();
const groups = await groupStateManager.getGroupsWithFeature('reminders');
for (const group of groups) {
// Build list of enabled reminders from 3-tier system with custom times
const enabledReminders: { slot: number; time: ReminderTime }[] = [];
// Check each of the 3 reminder slots with their custom times
if (group.reminder1Enabled !== false) {
const time = group.reminder1Time || DEFAULT_GROUP_REMINDER_SLOTS[0];
enabledReminders.push({ slot: 1, time });
}
if (group.reminder2Enabled === true) {
const time = group.reminder2Time || DEFAULT_GROUP_REMINDER_SLOTS[1];
enabledReminders.push({ slot: 2, time });
}
if (group.reminder3Enabled === true) {
const time = group.reminder3Time || DEFAULT_GROUP_REMINDER_SLOTS[2];
enabledReminders.push({ slot: 3, time });
}
if (enabledReminders.length === 0) {
continue;
}
for (const { slot, time: reminderTime } of enabledReminders) {
// Use slot-only key to prevent duplicate reminders when settings change
const uniqueKey = `group:${group.groupId}:${cycle.id}:slot${slot}`;
// Check if we already have a reminder for this slot
const existingReminder = this.scheduledReminders.get(uniqueKey);
if (existingReminder) {
// If settings changed (different time), cancel old and reschedule
const newMinutesBefore = reminderTimeToMinutes(reminderTime);
const newReminderDate = new Date(drawTime.getTime() - newMinutesBefore * 60 * 1000);
// Only reschedule if the time actually changed
if (existingReminder.scheduledFor.getTime() !== newReminderDate.getTime()) {
clearTimeout(existingReminder.timeout);
this.scheduledReminders.delete(uniqueKey);
logger.debug('Cleared old reminder due to settings change', {
groupId: group.groupId,
slot,
oldTime: existingReminder.scheduledFor.toISOString(),
newTime: newReminderDate.toISOString(),
});
} else {
continue; // Same time, skip
}
}
const minutesBefore = reminderTimeToMinutes(reminderTime);
const reminderDate = new Date(drawTime.getTime() - minutesBefore * 60 * 1000);
if (reminderDate <= now) {
continue;
}
const delay = reminderDate.getTime() - now.getTime();
const timeout = setTimeout(async () => {
await this.sendGroupReminder(group, cycle, reminderTime, drawTime);
this.scheduledReminders.delete(uniqueKey);
}, delay);
this.scheduledReminders.set(uniqueKey, {
groupId: group.groupId,
cycleId: cycle.id,
reminderKey: `slot${slot}_${formatReminderTime(reminderTime)}`,
scheduledFor: reminderDate,
timeout,
});
logger.debug('Scheduled group reminder', {
groupId: group.groupId,
cycleId: cycle.id,
slot,
time: formatReminderTime(reminderTime),
scheduledFor: reminderDate.toISOString(),
});
}
}
}
/**
* Schedule reminders for individual users with drawReminders enabled
*/
private async scheduleUserReminders(cycle: CycleInfo): Promise<void> {
if (!this.bot || cycle.status === 'completed' || cycle.status === 'cancelled') {
return;
}
const drawTime = new Date(cycle.scheduled_at);
const now = new Date();
// Get users with draw reminders enabled
const users = await stateManager.getUsersWithNotification('drawReminders');
// Default reminder: 15 minutes before
const defaultReminder: ReminderTime = { value: 15, unit: 'minutes' };
const reminderKey = formatReminderTime(defaultReminder);
for (const user of users) {
const uniqueKey = `user:${user.telegramId}:${cycle.id}:${reminderKey}`;
if (this.scheduledReminders.has(uniqueKey)) {
continue;
}
const minutesBefore = reminderTimeToMinutes(defaultReminder);
const reminderDate = new Date(drawTime.getTime() - minutesBefore * 60 * 1000);
if (reminderDate <= now) {
continue;
}
const delay = reminderDate.getTime() - now.getTime();
const timeout = setTimeout(async () => {
await this.sendUserReminder(user, cycle, defaultReminder, drawTime);
this.scheduledReminders.delete(uniqueKey);
}, delay);
this.scheduledReminders.set(uniqueKey, {
telegramId: user.telegramId,
cycleId: cycle.id,
reminderKey,
scheduledFor: reminderDate,
timeout,
});
}
}
/**
* Send a reminder to a group
*/
private async sendGroupReminder(
group: GroupSettings,
cycle: CycleInfo,
reminderTime: ReminderTime,
drawTime: Date
): Promise<void> {
if (!this.bot) return;
try {
// Fetch fresh cycle data to get current pot amount
const freshJackpot = await apiClient.getNextJackpot();
const currentPotSats = freshJackpot?.cycle?.id === cycle.id
? freshJackpot.cycle.pot_total_sats
: cycle.pot_total_sats;
const message = messages.notifications.drawReminder(
reminderTime.value,
reminderTime.unit,
drawTime,
currentPotSats
);
await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' });
logger.info('Sent draw reminder to group', {
groupId: group.groupId,
reminderKey: formatReminderTime(reminderTime),
potSats: currentPotSats
});
} catch (error) {
this.handleSendError(error, group.groupId);
}
}
/**
* Send a reminder to a user
*/
private async sendUserReminder(
user: TelegramUser,
cycle: CycleInfo,
reminderTime: ReminderTime,
drawTime: Date
): Promise<void> {
if (!this.bot) return;
try {
// Fetch fresh cycle data to get current pot amount
const freshJackpot = await apiClient.getNextJackpot();
const currentPotSats = freshJackpot?.cycle?.id === cycle.id
? freshJackpot.cycle.pot_total_sats
: cycle.pot_total_sats;
const message = messages.notifications.drawReminder(
reminderTime.value,
reminderTime.unit,
drawTime,
currentPotSats
);
await this.bot.sendMessage(user.telegramId, message, { parse_mode: 'Markdown' });
logger.info('Sent draw reminder to user', { telegramId: user.telegramId, potSats: currentPotSats });
} catch (error) {
logger.error('Failed to send reminder to user', { telegramId: user.telegramId, error });
}
}
/**
* Handle send errors (remove group if bot was kicked)
*/
private handleSendError(error: any, groupId: number): void {
logger.error('Failed to send message to group', { groupId, error });
if (error?.response?.statusCode === 403) {
groupStateManager.removeGroup(groupId);
}
}
/**
* Clear all scheduled reminders for a cycle
*/
private clearRemindersForCycle(cycleId: string): void {
for (const [key, reminder] of this.scheduledReminders.entries()) {
if (reminder.cycleId === cycleId) {
clearTimeout(reminder.timeout);
this.scheduledReminders.delete(key);
}
}
}
/**
* Get status info for debugging
*/
getStatus(): object {
return {
isRunning: this.isRunning,
lastCycleId: this.lastCycleId,
lastCycleStatus: this.lastCycleStatus,
scheduledReminders: this.scheduledReminders.size,
announcedCycles: this.announcedCycles.size,
};
}
}
export const notificationScheduler = new NotificationScheduler();
export default notificationScheduler;

View File

@@ -25,3 +25,4 @@ export async function generateQRCode(data: string): Promise<Buffer> {
export default { generateQRCode };

View File

@@ -1,130 +1,31 @@
import Redis from 'ioredis';
import config from '../config';
import { botDatabase } from './database';
import { logger } from './logger';
import {
TelegramUser,
UserState,
AwaitingPaymentData,
NotificationPreferences,
DEFAULT_NOTIFICATIONS,
} from '../types';
const STATE_PREFIX = 'tg_user:';
const PURCHASE_PREFIX = 'tg_purchase:';
const USER_PURCHASES_PREFIX = 'tg_user_purchases:';
const STATE_TTL = 60 * 60 * 24 * 30; // 30 days
class StateManager {
private redis: Redis | null = null;
private memoryStore: Map<string, string> = new Map();
private useRedis: boolean = false;
async init(): Promise<void> {
if (config.redis.url) {
try {
this.redis = new Redis(config.redis.url);
this.redis.on('error', (error) => {
logger.error('Redis connection error', { error: error.message });
});
this.redis.on('connect', () => {
logger.info('Connected to Redis');
});
// Test connection
await this.redis.ping();
this.useRedis = true;
logger.info('State manager initialized with Redis');
} catch (error) {
logger.warn('Failed to connect to Redis, falling back to in-memory store', {
error: (error as Error).message,
});
this.redis = null;
this.useRedis = false;
}
} else {
logger.info('State manager initialized with in-memory store');
this.useRedis = false;
}
}
private async get(key: string): Promise<string | null> {
if (this.useRedis && this.redis) {
return await this.redis.get(key);
}
return this.memoryStore.get(key) || null;
}
private async set(key: string, value: string, ttl?: number): Promise<void> {
if (this.useRedis && this.redis) {
if (ttl) {
await this.redis.setex(key, ttl, value);
} else {
await this.redis.set(key, value);
}
} else {
this.memoryStore.set(key, value);
}
}
private async del(key: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.del(key);
} else {
this.memoryStore.delete(key);
}
}
private async lpush(key: string, value: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.lpush(key, value);
await this.redis.ltrim(key, 0, 99); // Keep last 100 purchases
} else {
const existing = this.memoryStore.get(key);
const list = existing ? JSON.parse(existing) : [];
list.unshift(value);
if (list.length > 100) list.pop();
this.memoryStore.set(key, JSON.stringify(list));
}
}
private async lrange(key: string, start: number, stop: number): Promise<string[]> {
if (this.useRedis && this.redis) {
return await this.redis.lrange(key, start, stop);
}
const existing = this.memoryStore.get(key);
if (!existing) return [];
const list = JSON.parse(existing);
return list.slice(start, stop + 1);
// Database is initialized separately
logger.info('State manager initialized (using SQLite database)');
}
/**
* Get or create user
* Get user by Telegram ID
*/
async getUser(telegramId: number): Promise<TelegramUser | null> {
const key = `${STATE_PREFIX}${telegramId}`;
const data = await this.get(key);
if (!data) return null;
try {
const user = JSON.parse(data);
return {
...user,
createdAt: new Date(user.createdAt),
updatedAt: new Date(user.updatedAt),
};
} catch (error) {
logger.error('Failed to parse user data', { telegramId, error });
return null;
}
return botDatabase.getUser(telegramId);
}
/**
* Create or update user
* Save user
*/
async saveUser(user: TelegramUser): Promise<void> {
const key = `${STATE_PREFIX}${user.telegramId}`;
user.updatedAt = new Date();
await this.set(key, JSON.stringify(user), STATE_TTL);
botDatabase.saveUser(user);
logger.debug('User saved', { telegramId: user.telegramId, state: user.state });
}
@@ -137,18 +38,7 @@ class StateManager {
firstName?: string,
lastName?: string
): Promise<TelegramUser> {
const user: TelegramUser = {
telegramId,
username,
firstName,
lastName,
state: 'awaiting_lightning_address',
createdAt: new Date(),
updatedAt: new Date(),
};
await this.saveUser(user);
logger.info('New user created', { telegramId, username });
return user;
return botDatabase.createUser(telegramId, username, firstName, lastName);
}
/**
@@ -159,14 +49,7 @@ class StateManager {
state: UserState,
stateData?: Record<string, any>
): Promise<void> {
const user = await this.getUser(telegramId);
if (!user) {
logger.warn('Attempted to update state for non-existent user', { telegramId });
return;
}
user.state = state;
user.stateData = stateData;
await this.saveUser(user);
botDatabase.updateUserState(telegramId, state, stateData);
}
/**
@@ -176,15 +59,25 @@ class StateManager {
telegramId: number,
lightningAddress: string
): Promise<void> {
const user = await this.getUser(telegramId);
if (!user) {
logger.warn('Attempted to update address for non-existent user', { telegramId });
return;
botDatabase.updateLightningAddress(telegramId, lightningAddress);
}
user.lightningAddress = lightningAddress;
user.state = 'idle';
user.stateData = undefined;
await this.saveUser(user);
/**
* Update user display name
*/
async updateDisplayName(telegramId: number, displayName: string): Promise<void> {
botDatabase.updateDisplayName(telegramId, displayName);
logger.info('Display name updated', { telegramId, displayName });
}
/**
* Update user notification preferences
*/
async updateNotifications(
telegramId: number,
updates: Partial<NotificationPreferences>
): Promise<TelegramUser | null> {
return botDatabase.updateNotifications(telegramId, updates);
}
/**
@@ -195,33 +88,36 @@ class StateManager {
purchaseId: string,
data: AwaitingPaymentData
): Promise<void> {
// Store purchase data
const purchaseKey = `${PURCHASE_PREFIX}${purchaseId}`;
await this.set(purchaseKey, JSON.stringify({
telegramId,
...data,
createdAt: new Date().toISOString(),
}), STATE_TTL);
// Add to user's purchase list
const userPurchasesKey = `${USER_PURCHASES_PREFIX}${telegramId}`;
await this.lpush(userPurchasesKey, purchaseId);
botDatabase.storePurchase(telegramId, purchaseId, {
cycleId: data.cycleId,
ticketCount: data.ticketCount,
totalAmount: data.totalAmount,
lightningAddress: data.paymentRequest ? '' : '', // Not storing sensitive data
paymentRequest: data.paymentRequest,
publicUrl: data.publicUrl,
});
}
/**
* Get purchase data
*/
async getPurchase(purchaseId: string): Promise<(AwaitingPaymentData & { telegramId: number }) | null> {
const key = `${PURCHASE_PREFIX}${purchaseId}`;
const data = await this.get(key);
if (!data) return null;
const purchase = botDatabase.getPurchase(purchaseId);
if (!purchase) return null;
try {
return JSON.parse(data);
} catch (error) {
logger.error('Failed to parse purchase data', { purchaseId, error });
return null;
}
return {
telegramId: purchase.telegram_id,
cycleId: purchase.cycle_id,
ticketCount: purchase.ticket_count,
scheduledAt: '', // Not stored locally
ticketPrice: 0, // Not stored locally
totalAmount: purchase.amount_sats,
lotteryName: '', // Not stored locally
purchaseId: purchase.purchase_id,
paymentRequest: purchase.payment_request,
publicUrl: purchase.public_url,
pollStartTime: 0,
};
}
/**
@@ -231,32 +127,70 @@ class StateManager {
telegramId: number,
limit: number = 10
): Promise<string[]> {
const key = `${USER_PURCHASES_PREFIX}${telegramId}`;
return await this.lrange(key, 0, limit - 1);
return botDatabase.getUserPurchaseIds(telegramId, limit);
}
/**
* Clear user state data (keeping lightning address)
*/
async clearUserStateData(telegramId: number): Promise<void> {
const user = await this.getUser(telegramId);
if (!user) return;
user.state = 'idle';
user.stateData = undefined;
await this.saveUser(user);
botDatabase.updateUserState(telegramId, 'idle', undefined);
}
/**
* Add user as participant to a cycle
*/
async addCycleParticipant(cycleId: string, telegramId: number, purchaseId: string): Promise<void> {
botDatabase.addCycleParticipant(cycleId, telegramId, purchaseId);
logger.debug('Added cycle participant', { cycleId, telegramId });
}
/**
* Get all participants for a cycle
*/
async getCycleParticipants(cycleId: string): Promise<Array<{ telegramId: number; purchaseId: string }>> {
return botDatabase.getCycleParticipants(cycleId);
}
/**
* Check if user participated in a cycle
*/
async didUserParticipate(cycleId: string, telegramId: number): Promise<boolean> {
return botDatabase.didUserParticipate(cycleId, telegramId);
}
/**
* Get all users with specific notification preference enabled
*/
async getUsersWithNotification(
preference: keyof NotificationPreferences
): Promise<TelegramUser[]> {
return botDatabase.getUsersWithNotification(preference);
}
/**
* Get user's display name (for announcements)
* Priority: displayName > @username > 'Anon'
*/
getDisplayName(user: TelegramUser): string {
if (user.displayName && user.displayName !== 'Anon') {
return user.displayName;
}
// Fall back to @username if available
if (user.username) {
return `@${user.username}`;
}
return 'Anon';
}
/**
* Shutdown
*/
async close(): Promise<void> {
if (this.redis) {
await this.redis.quit();
logger.info('Redis connection closed');
}
// Database close is handled separately
logger.info('State manager closed');
}
}
export const stateManager = new StateManager();
export default stateManager;

View File

@@ -1,3 +1,11 @@
/**
* Reminder time with unit
*/
export interface ReminderTime {
value: number;
unit: 'minutes' | 'hours' | 'days';
}
/**
* Group settings for lottery features
*/
@@ -7,12 +15,81 @@ export interface GroupSettings {
enabled: boolean;
drawAnnouncements: boolean;
reminders: boolean;
newJackpotAnnouncement: boolean; // Announce when a new jackpot starts
ticketPurchaseAllowed: boolean;
// Reminder slots (3 tiers - each with customizable time)
reminder1Enabled: boolean;
reminder1Time: ReminderTime; // Default: 1 hour before
reminder2Enabled: boolean;
reminder2Time: ReminderTime; // Default: 1 day before
reminder3Enabled: boolean;
reminder3Time: ReminderTime; // Default: 6 days before
// 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;
}
/**
* Default reminder slots for groups (3 tiers)
* 1st: 1 Hour before draw
* 2nd: 1 Day before draw
* 3rd: 6 Days before draw
*/
export const DEFAULT_GROUP_REMINDER_SLOTS: ReminderTime[] = [
{ value: 1, unit: 'hours' }, // 1st reminder: 1 hour before
{ value: 1, unit: 'days' }, // 2nd reminder: 1 day before
{ value: 6, unit: 'days' }, // 3rd reminder: 6 days before
];
/**
* Available reminder time presets (for custom selection)
*/
export const REMINDER_PRESETS: ReminderTime[] = [
{ value: 5, unit: 'minutes' },
{ value: 15, unit: 'minutes' },
{ value: 30, unit: 'minutes' },
{ value: 1, unit: 'hours' },
{ value: 6, unit: 'hours' },
{ value: 12, unit: 'hours' },
{ value: 1, unit: 'days' },
{ value: 3, unit: 'days' },
{ value: 6, unit: 'days' },
];
/**
* Convert reminder time to minutes
*/
export function reminderTimeToMinutes(rt: ReminderTime): number {
switch (rt.unit) {
case 'minutes': return rt.value;
case 'hours': return rt.value * 60;
case 'days': return rt.value * 60 * 24;
}
}
/**
* Format reminder time for display
*/
export function formatReminderTime(rt: ReminderTime): string {
if (rt.unit === 'minutes') return `${rt.value}m`;
if (rt.unit === 'hours') return rt.value === 1 ? '1h' : `${rt.value}h`;
return rt.value === 1 ? '1d' : `${rt.value}d`;
}
/**
* Available announcement delay options (seconds after draw)
*/
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
*/
@@ -20,6 +97,17 @@ export const DEFAULT_GROUP_SETTINGS: Omit<GroupSettings, 'groupId' | 'groupTitle
enabled: true,
drawAnnouncements: true,
reminders: true,
newJackpotAnnouncement: true,
ticketPurchaseAllowed: false, // Disabled by default for privacy - users should buy in DM
reminder1Enabled: true,
reminder1Time: { value: 1, unit: 'hours' }, // Default: 1 hour before
reminder2Enabled: false,
reminder2Time: { value: 1, unit: 'days' }, // Default: 1 day before
reminder3Enabled: false,
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
};

View File

@@ -4,7 +4,15 @@ export type UserState =
| 'awaiting_lightning_address'
| 'awaiting_ticket_amount'
| 'awaiting_invoice_payment'
| 'updating_address';
| 'updating_address'
| 'awaiting_display_name';
// User notification preferences
export interface NotificationPreferences {
drawReminders: boolean;
drawResults: boolean;
newJackpotAlerts: boolean;
}
// Telegram user data stored in state
export interface TelegramUser {
@@ -12,13 +20,22 @@ export interface TelegramUser {
username?: string;
firstName?: string;
lastName?: string;
displayName?: string; // Custom display name for announcements (default: "Anon")
lightningAddress?: string;
state: UserState;
stateData?: Record<string, any>;
notifications: NotificationPreferences;
createdAt: Date;
updatedAt: Date;
}
// Default notification preferences
export const DEFAULT_NOTIFICATIONS: NotificationPreferences = {
drawReminders: true,
drawResults: true,
newJackpotAlerts: true,
};
// API Response Types
export interface ApiResponse<T> {
version: string;
@@ -131,6 +148,9 @@ export interface AwaitingPaymentData extends PendingPurchaseData {
paymentRequest: string;
publicUrl: string;
pollStartTime: number;
headerMessageId?: number;
qrMessageId?: number;
invoiceMessageId?: number;
}
// Re-export group types

View File

@@ -46,7 +46,7 @@ export function formatTimeUntil(date: Date | string): string {
}
/**
* Validate Lightning Address format
* Validate Lightning Address format (basic regex check)
*/
export function isValidLightningAddress(address: string): boolean {
// Basic format: something@something.something
@@ -54,6 +54,62 @@ export function isValidLightningAddress(address: string): boolean {
return regex.test(address);
}
/**
* LNURL-pay response structure
*/
interface LnurlPayResponse {
status?: string;
reason?: string;
callback?: string;
minSendable?: number;
maxSendable?: number;
tag?: string;
}
/**
* Verify Lightning Address is actually valid by checking LNURL endpoint
*/
export async function verifyLightningAddress(address: string): Promise<{ valid: boolean; error?: string }> {
// First check format
if (!isValidLightningAddress(address)) {
return { valid: false, error: 'Invalid format' };
}
try {
const [username, domain] = address.split('@');
const url = `https://${domain}/.well-known/lnurlp/${username}`;
const response = await fetch(url, {
method: 'GET',
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000), // 10 second timeout
});
if (!response.ok) {
return { valid: false, error: `Address not found (${response.status})` };
}
const data = (await response.json()) as LnurlPayResponse;
// Check for LNURL-pay response
if (data.status === 'ERROR') {
return { valid: false, error: data.reason || 'Invalid address' };
}
// Valid LNURL-pay response should have callback and minSendable
if (data.callback && data.minSendable !== undefined) {
return { valid: true };
}
return { valid: false, error: 'Invalid LNURL response' };
} catch (error: any) {
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
return { valid: false, error: 'Verification timed out' };
}
return { valid: false, error: 'Could not verify address' };
}
}
/**
* Escape markdown special characters for Telegram MarkdownV2
*/
@@ -69,3 +125,20 @@ export function truncate(str: string, maxLength: number): string {
return str.substring(0, maxLength - 3) + '...';
}
/**
* Truncate lightning address for privacy
* "username@blink.sv" -> "us******@blink.sv"
*/
export function truncateLightningAddress(address: string): string {
if (!address || !address.includes('@')) return address;
const [username, domain] = address.split('@');
// Show first 2 chars of username, then asterisks
const visibleChars = Math.min(2, username.length);
const truncatedUsername = username.substring(0, visibleChars) + '******';
return `${truncatedUsername}@${domain}`;
}

View File

@@ -1,7 +1,32 @@
import TelegramBot, {
InlineKeyboardMarkup,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
} from 'node-telegram-bot-api';
import { NotificationPreferences } from '../types';
/**
* Check if chat ID is a group chat (negative IDs are groups/supergroups)
*/
function isGroupChat(chatId: number): boolean {
return chatId < 0;
}
/**
* Get reply markup for a chat - removes keyboard in group chats, shows keyboard in private chats
* This ensures keyboards are completely disabled in group chats
*/
export function getReplyMarkupForChat(
chatId: number,
keyboard?: ReplyKeyboardMarkup
): ReplyKeyboardMarkup | ReplyKeyboardRemove | undefined {
if (isGroupChat(chatId)) {
// Explicitly remove keyboard in group chats
return { remove_keyboard: true };
}
// Return the provided keyboard for private chats, or undefined if none provided
return keyboard;
}
/**
* Main menu reply keyboard
@@ -9,9 +34,10 @@ import TelegramBot, {
export function getMainMenuKeyboard(): ReplyKeyboardMarkup {
return {
keyboard: [
[{ text: '🎰 Upcoming Jackpot' }],
[{ text: '🎟 Buy Tickets' }, { text: '🧾 My Tickets' }],
[{ text: '🏆 My Wins' }, { text: '⚡ Lightning Address' }],
[{ text: ' Help' }],
[{ text: '⚙️ Settings' }, { text: ' Help' }],
],
resize_keyboard: true,
one_time_keyboard: false,
@@ -68,15 +94,13 @@ function isValidTelegramUrl(url: string): boolean {
}
/**
* View ticket status button
* View ticket status button (after payment confirmed)
*/
export function getViewTicketKeyboard(
purchaseId: string,
publicUrl?: string
): InlineKeyboardMarkup {
const buttons: TelegramBot.InlineKeyboardButton[][] = [
[{ text: '🔄 Check Status', callback_data: `status_${purchaseId}` }],
];
const buttons: TelegramBot.InlineKeyboardButton[][] = [];
// Only add URL button if it's a valid Telegram URL (HTTPS, not localhost)
if (publicUrl && isValidTelegramUrl(publicUrl)) {
@@ -143,3 +167,63 @@ export function getCancelKeyboard(): InlineKeyboardMarkup {
};
}
/**
* Lightning address selection keyboard (for registration)
*/
export function getLightningAddressKeyboard(username?: string): InlineKeyboardMarkup {
const buttons: InlineKeyboardButton[][] = [];
if (username) {
// Add quick options for tipbots
buttons.push([{
text: `⚡ Use 21Tipbot (${username}@twentyone.tips)`,
callback_data: 'ln_addr_21tipbot',
}]);
buttons.push([{
text: `⚡ Use Bittip (${username}@btip.nl)`,
callback_data: 'ln_addr_bittip',
}]);
}
buttons.push([{ text: '❌ Cancel', callback_data: 'cancel' }]);
return { inline_keyboard: buttons };
}
type InlineKeyboardButton = TelegramBot.InlineKeyboardButton;
/**
* User settings keyboard
*/
export function getSettingsKeyboard(
displayName: string,
notifications: NotificationPreferences
): InlineKeyboardMarkup {
const onOff = (val: boolean) => val ? '✅' : '❌';
return {
inline_keyboard: [
[{
text: `👤 Display Name: ${displayName}`,
callback_data: 'settings_change_name',
}],
[{
text: `${onOff(notifications.drawReminders)} Draw Reminders`,
callback_data: 'settings_toggle_notif_drawReminders',
}],
[{
text: `${onOff(notifications.drawResults)} Draw Results`,
callback_data: 'settings_toggle_notif_drawResults',
}],
[{
text: `${onOff(notifications.newJackpotAlerts)} New Jackpot Alerts`,
callback_data: 'settings_toggle_notif_newJackpotAlerts',
}],
[{
text: '🏠 Back to Menu',
callback_data: 'settings_back_menu',
}],
],
};
}

View File

@@ -19,3 +19,4 @@
"exclude": ["node_modules", "dist"]
}