Compare commits

...

13 Commits

Author SHA1 Message Date
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
47 changed files with 4929 additions and 866 deletions

2
.gitignore vendored
View File

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

View File

@@ -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 { JackpotCycle, TicketPurchase, Ticket, Payout } from '../types';
import { AuthRequest } from '../middleware/auth'; 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 => { const toIsoString = (value: any): string => {
if (!value) { if (!value) {
return new Date().toISOString(); return new Date().toISOString();
@@ -65,17 +127,29 @@ export async function getNextJackpot(req: Request, res: Response) {
const lottery = lotteryResult.rows[0]; const lottery = lotteryResult.rows[0];
// Get next cycle // Get next cycle - first try to find one that hasn't drawn yet
const cycleResult = await db.query<JackpotCycle>( let cycleResult = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles `SELECT * FROM jackpot_cycles
WHERE lottery_id = $1 WHERE lottery_id = $1
AND status IN ('scheduled', 'sales_open') AND status IN ('scheduled', 'sales_open', 'drawing')
AND scheduled_at > NOW()
ORDER BY scheduled_at ASC ORDER BY scheduled_at ASC
LIMIT 1`, LIMIT 1`,
[lottery.id] [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) { if (cycleResult.rows.length === 0) {
return res.status(503).json({ return res.status(503).json({
version: '1.0', version: '1.0',
@@ -128,6 +202,16 @@ export async function getNextJackpot(req: Request, res: Response) {
*/ */
export async function buyTickets(req: AuthRequest, res: Response) { export async function buyTickets(req: AuthRequest, res: Response) {
try { 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 { tickets, lightning_address, nostr_pubkey, name, buyer_name } = req.body;
const userId = req.user?.id || null; const userId = req.user?.id || null;
const authNostrPubkey = req.user?.nostr_pubkey || null; const authNostrPubkey = req.user?.nostr_pubkey || null;
@@ -419,6 +503,23 @@ interface PastWinRow {
pot_after_fee_sats: number | null; pot_after_fee_sats: number | null;
buyer_name: string | null; buyer_name: string | null;
serial_number: number | 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.scheduled_at,
jc.pot_total_sats, jc.pot_total_sats,
jc.pot_after_fee_sats, jc.pot_after_fee_sats,
jc.winning_lightning_address,
tp.buyer_name, tp.buyer_name,
t.serial_number t.serial_number
FROM jackpot_cycles jc FROM jackpot_cycles jc
@@ -460,6 +562,7 @@ export async function getPastWins(req: Request, res: Response) {
? parseInt(row.pot_after_fee_sats.toString()) ? parseInt(row.pot_after_fee_sats.toString())
: null, : null,
winner_name: row.buyer_name || 'Anon', winner_name: row.buyer_name || 'Anon',
winner_address: truncateLightningAddress(row.winning_lightning_address),
winning_ticket_serial: row.serial_number winning_ticket_serial: row.serial_number
? parseInt(row.serial_number.toString()) ? parseInt(row.serial_number.toString())
: null, : null,

View File

@@ -158,6 +158,17 @@ class DatabaseWrapper {
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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 indexes
CREATE INDEX IF NOT EXISTS idx_cycles_status_time ON jackpot_cycles(status, scheduled_at); 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); CREATE INDEX IF NOT EXISTS idx_ticketpurchase_paymenthash ON ticket_purchases(lnbits_payment_hash);

View File

@@ -3,7 +3,9 @@ import {
listCycles, listCycles,
runDrawManually, runDrawManually,
retryPayout, retryPayout,
listPayouts listPayouts,
getMaintenanceStatus,
setMaintenanceMode
} from '../controllers/admin'; } from '../controllers/admin';
import { verifyAdmin } from '../middleware/auth'; import { verifyAdmin } from '../middleware/auth';
@@ -126,5 +128,78 @@ router.get('/payouts', listPayouts);
*/ */
router.post('/payouts/:id/retry', retryPayout); 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; export default router;

View File

@@ -3,7 +3,8 @@ import {
getNextJackpot, getNextJackpot,
buyTickets, buyTickets,
getTicketStatus, getTicketStatus,
getPastWins getPastWins,
getPublicMaintenanceStatus
} from '../controllers/public'; } from '../controllers/public';
import { buyRateLimiter, ticketStatusRateLimiter } from '../middleware/rateLimit'; import { buyRateLimiter, ticketStatusRateLimiter } from '../middleware/rateLimit';
import { optionalAuth } from '../middleware/auth'; import { optionalAuth } from '../middleware/auth';
@@ -187,5 +188,32 @@ router.get('/jackpot/past-wins', getPastWins);
*/ */
router.get('/tickets/:id', ticketStatusRateLimiter, getTicketStatus); 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; export default router;

View File

@@ -242,6 +242,10 @@ async function checkAndExecuteDraws(): Promise<void> {
for (const cycle of result.rows) { for (const cycle of result.rows) {
console.log(`Executing draw for cycle ${cycle.id} (${cycle.cycle_type})`); console.log(`Executing draw for cycle ${cycle.id} (${cycle.cycle_type})`);
await executeDraw(cycle.id); await executeDraw(cycle.id);
// Cancel unpaid invoices for this cycle after draw
await cancelUnpaidPurchases(cycle.id);
// Check if maintenance was pending and activate it
await activatePendingMaintenance();
} }
} catch (error) { } catch (error) {
@@ -249,6 +253,57 @@ 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 * Update cycles to 'sales_open' status when appropriate
*/ */

View File

@@ -17,6 +17,8 @@ export default function BuyPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [jackpot, setJackpot] = useState<any>(null); const [jackpot, setJackpot] = useState<any>(null);
const [maintenanceMode, setMaintenanceMode] = useState(false);
const [maintenanceMessage, setMaintenanceMessage] = useState<string | null>(null);
// Form state // Form state
const [lightningAddress, setLightningAddress] = useState(''); const [lightningAddress, setLightningAddress] = useState('');
@@ -125,6 +127,11 @@ export default function BuyPage() {
const loadJackpot = async () => { const loadJackpot = async () => {
try { try {
// Check maintenance status first
const maintenanceStatus = await api.getMaintenanceStatus();
setMaintenanceMode(maintenanceStatus.maintenance_mode);
setMaintenanceMessage(maintenanceStatus.message);
const response = await api.getNextJackpot(); const response = await api.getNextJackpot();
if (response.data) { if (response.data) {
setJackpot(response.data); setJackpot(response.data);
@@ -221,16 +228,40 @@ export default function BuyPage() {
const ticketPriceSats = jackpot.lottery.ticket_price_sats; const ticketPriceSats = jackpot.lottery.ticket_price_sats;
const totalCost = ticketPriceSats * tickets; const totalCost = ticketPriceSats * tickets;
// Show maintenance message if in maintenance mode
if (maintenanceMode) {
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>
<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 ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto px-1">
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white"> <h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-6 sm:mb-8 text-center text-white">
{STRINGS.buy.title} {STRINGS.buy.title}
</h1> </h1>
{!invoice ? ( {!invoice ? (
/* Purchase Form */ /* Purchase Form */
<div className="bg-gray-900 rounded-xl p-8 border border-gray-800"> <div className="bg-gray-900 rounded-xl p-5 sm:p-8 border border-gray-800">
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-5 sm:space-y-6">
{/* Lightning Address */} {/* Lightning Address */}
<div> <div>
<label className="block text-gray-300 mb-2 font-medium"> <label className="block text-gray-300 mb-2 font-medium">
@@ -321,7 +352,7 @@ export default function BuyPage() {
<button <button
type="submit" type="submit"
disabled={loading} 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} {loading ? 'Creating Invoice...' : STRINGS.buy.createInvoice}
</button> </button>

View File

@@ -10,8 +10,17 @@
body { body {
color: rgb(var(--foreground-rgb)); color: rgb(var(--foreground-rgb));
background: rgb(var(--background-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 { @layer utilities {
.text-balance { .text-balance {
text-wrap: balance; text-wrap: balance;
@@ -20,6 +29,39 @@ body {
.animate-fade-in { .animate-fade-in {
animation: fade-in 0.5s ease-out forwards; 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 { @keyframes fade-in {
@@ -33,21 +75,68 @@ body {
} }
} }
/* Custom scrollbar */ /* Custom scrollbar (desktop) */
::-webkit-scrollbar { @media (hover: hover) and (pointer: fine) {
width: 8px; ::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #0b0b0b;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
} }
::-webkit-scrollbar-track { /* Mobile-specific styles */
background: #0b0b0b; @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;
}
} }
::-webkit-scrollbar-thumb { /* Prevent iOS bounce/pull-to-refresh on certain elements */
background: #333; .no-bounce {
border-radius: 4px; overscroll-behavior: none;
} }
::-webkit-scrollbar-thumb:hover { /* Focus visible only for keyboard navigation */
background: #555; @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 { Inter } from 'next/font/google';
import './globals.css'; import './globals.css';
import { Providers } from './providers'; import { Providers } from './providers';
@@ -7,9 +7,25 @@ import { Footer } from '@/components/Footer';
const inter = Inter({ subsets: ['latin'] }); const inter = Inter({ subsets: ['latin'] });
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 5,
userScalable: true,
themeColor: '#0b0b0b',
};
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Lightning Lottery - Win Bitcoin', title: 'Lightning Lottery - Win Bitcoin',
description: 'Bitcoin Lightning Network powered lottery with instant payouts', 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({ export default function RootLayout({

View File

@@ -33,6 +33,20 @@ export default function HomePage() {
const [isRecentWin, setIsRecentWin] = useState(false); const [isRecentWin, setIsRecentWin] = useState(false);
const [awaitingNextCycle, setAwaitingNextCycle] = useState(false); const [awaitingNextCycle, setAwaitingNextCycle] = useState(false);
const [pendingWinner, setPendingWinner] = useState<RecentWinner | null>(null); 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 () => { const loadJackpot = useCallback(async () => {
try { try {
@@ -76,9 +90,10 @@ export default function HomePage() {
}, [drawJustCompleted]); }, [drawJustCompleted]);
useEffect(() => { useEffect(() => {
loadMaintenanceStatus();
loadJackpot(); loadJackpot();
loadRecentWinner(); loadRecentWinner();
}, [loadJackpot, loadRecentWinner]); }, [loadMaintenanceStatus, loadJackpot, loadRecentWinner]);
// Detect when draw time passes and trigger draw animation (only if tickets were sold) // Detect when draw time passes and trigger draw animation (only if tickets were sold)
useEffect(() => { useEffect(() => {
@@ -245,21 +260,51 @@ export default function HomePage() {
/> />
)} )}
{/* 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 */} {/* Hero Section */}
<div className="text-center mb-12"> <div className="text-center mb-8 sm:mb-12">
<h1 className="text-4xl md:text-6xl font-bold mb-4 text-white"> <h1 className="text-3xl sm:text-4xl md:text-6xl font-bold mb-3 sm:mb-4 text-white">
{STRINGS.app.title} {STRINGS.app.title}
</h1> </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> </div>
{/* Recent Winner Banner - Only shown for 60 seconds after draw */} {/* Recent Winner Banner - Only shown for 60 seconds after draw */}
{showWinnerBanner && ( {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 */} {/* Close button */}
<button <button
onClick={handleDismissWinnerBanner} 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" aria-label="Dismiss"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -267,16 +312,16 @@ export default function HomePage() {
</svg> </svg>
</button> </button>
<div className="text-center"> <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 🏆 Latest Winner
</div> </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'} {recentWinner.winner_name || 'Anon'}
</div> </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 Won {recentWinner.pot_after_fee_sats.toLocaleString()} sats
</div> </div>
<div className="text-gray-400 text-sm"> <div className="text-gray-400 text-xs sm:text-sm">
Ticket #{recentWinner.winning_ticket_serial.toLocaleString()} Ticket #{recentWinner.winning_ticket_serial.toLocaleString()}
</div> </div>
</div> </div>
@@ -284,22 +329,22 @@ export default function HomePage() {
)} )}
{/* Current Jackpot Card */} {/* Current Jackpot Card */}
<div className="bg-gray-900 rounded-2xl p-8 md:p-12 mb-8 border border-gray-800"> <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-2xl font-semibold text-center mb-6 text-gray-300"> <h2 className="text-xl sm:text-2xl font-semibold text-center mb-4 sm:mb-6 text-gray-300">
{STRINGS.home.currentJackpot} {STRINGS.home.currentJackpot}
</h2> </h2>
{/* Pot Display */} {/* Pot Display */}
<div className="mb-8"> <div className="mb-6 sm:mb-8">
<JackpotPotDisplay potTotalSats={jackpot.cycle.pot_total_sats} /> <JackpotPotDisplay potTotalSats={jackpot.cycle.pot_total_sats} />
</div> </div>
{/* Countdown */} {/* Countdown */}
<div className="mb-8"> <div className="mb-6 sm:mb-8">
<div className="text-center text-gray-400 mb-4"> <div className="text-center text-gray-400 mb-3 sm:mb-4 text-sm sm:text-base">
{STRINGS.home.drawIn} {STRINGS.home.drawIn}
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center overflow-x-auto scrollbar-hide">
<JackpotCountdown <JackpotCountdown
scheduledAt={jackpot.cycle.scheduled_at} scheduledAt={jackpot.cycle.scheduled_at}
drawCompleted={awaitingNextCycle || drawJustCompleted} drawCompleted={awaitingNextCycle || drawJustCompleted}
@@ -308,16 +353,16 @@ export default function HomePage() {
</div> </div>
{/* Ticket Price */} {/* 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 Ticket Price: {jackpot.lottery.ticket_price_sats.toLocaleString()} sats
</div> </div>
{/* Buy Button - Hide when waiting for next round */} {/* Buy Button - Hide when waiting for next round */}
{!isWaitingForNextRound && ( {!isWaitingForNextRound && !maintenanceMode && (
<div className="flex justify-center"> <div className="flex justify-center">
<Link <Link
href="/buy" 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} {STRINGS.home.buyTickets}
</Link> </Link>
@@ -326,8 +371,8 @@ export default function HomePage() {
</div> </div>
{/* Check Ticket Section */} {/* Check Ticket Section */}
<div className="bg-gray-900 rounded-2xl p-8 border border-gray-800"> <div className="bg-gray-900 rounded-xl sm:rounded-2xl p-5 sm:p-8 border border-gray-800">
<h3 className="text-xl font-semibold text-center mb-4 text-gray-300"> <h3 className="text-lg sm:text-xl font-semibold text-center mb-4 text-gray-300">
{STRINGS.home.checkTicket} {STRINGS.home.checkTicket}
</h3> </h3>
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
@@ -336,12 +381,12 @@ export default function HomePage() {
value={ticketId} value={ticketId}
onChange={(e) => setTicketId(e.target.value)} onChange={(e) => setTicketId(e.target.value)}
placeholder={STRINGS.home.ticketIdPlaceholder} 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()} onKeyPress={(e) => e.key === 'Enter' && handleCheckTicket()}
/> />
<button <button
onClick={handleCheckTicket} 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 Check Status
</button> </button>
@@ -349,25 +394,25 @@ export default function HomePage() {
</div> </div>
{/* Info Section */} {/* Info Section */}
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-6"> <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-6 rounded-lg border border-gray-800"> <div className="bg-gray-900 p-5 sm:p-6 rounded-lg border border-gray-800">
<div className="text-4xl mb-3"></div> <div className="text-3xl sm:text-4xl mb-2 sm:mb-3"></div>
<h4 className="text-lg font-semibold mb-2 text-white">Instant</h4> <h4 className="text-base sm:text-lg font-semibold mb-1 sm:mb-2 text-white">Instant</h4>
<p className="text-gray-400 text-sm"> <p className="text-gray-400 text-xs sm:text-sm">
Lightning-fast ticket purchases and payouts Lightning-fast ticket purchases and payouts
</p> </p>
</div> </div>
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800"> <div className="bg-gray-900 p-5 sm:p-6 rounded-lg border border-gray-800">
<div className="text-4xl mb-3">🔒</div> <div className="text-3xl sm:text-4xl mb-2 sm:mb-3">🔒</div>
<h4 className="text-lg font-semibold mb-2 text-white">Secure</h4> <h4 className="text-base sm:text-lg font-semibold mb-1 sm:mb-2 text-white">Secure</h4>
<p className="text-gray-400 text-sm"> <p className="text-gray-400 text-xs sm:text-sm">
Cryptographically secure random number generation Cryptographically secure random number generation
</p> </p>
</div> </div>
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800"> <div className="bg-gray-900 p-5 sm:p-6 rounded-lg border border-gray-800">
<div className="text-4xl mb-3">🎯</div> <div className="text-3xl sm:text-4xl mb-2 sm:mb-3">🎯</div>
<h4 className="text-lg font-semibold mb-2 text-white">Fair</h4> <h4 className="text-base sm:text-lg font-semibold mb-1 sm:mb-2 text-white">Fair</h4>
<p className="text-gray-400 text-sm"> <p className="text-gray-400 text-xs sm:text-sm">
Transparent draws with verifiable results Transparent draws with verifiable results
</p> </p>
</div> </div>

View File

@@ -13,6 +13,7 @@ interface PastWin {
pot_total_sats: number; pot_total_sats: number;
pot_after_fee_sats: number | null; pot_after_fee_sats: number | null;
winner_name: string; winner_name: string;
winner_address: string | null;
winning_ticket_serial: number | null; winning_ticket_serial: number | null;
} }
@@ -87,13 +88,19 @@ export default function PastWinsPage() {
</div> </div>
</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>
<div className="text-gray-400 mb-1">{STRINGS.pastWins.winner}</div> <div className="text-gray-400 mb-1">{STRINGS.pastWins.winner}</div>
<div className="text-white font-semibold"> <div className="text-white font-semibold">
{win.winner_name || 'Anon'} {win.winner_name || 'Anon'}
</div> </div>
</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>
<div className="text-gray-400 mb-1">{STRINGS.pastWins.ticket}</div> <div className="text-gray-400 mb-1">{STRINGS.pastWins.ticket}</div>
<div className="text-white"> <div className="text-white">

View File

@@ -96,30 +96,30 @@ export default function TicketStatusPage() {
const { purchase, tickets, cycle, result } = data; const { purchase, tickets, cycle, result } = data;
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto px-1">
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white"> <h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-6 sm:mb-8 text-center text-white">
{STRINGS.ticket.title} {STRINGS.ticket.title}
</h1> </h1>
{/* Save This Link */} {/* 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-6 mb-6"> <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-4"> <div className="flex items-start gap-3 sm:gap-4">
<div className="text-3xl">🔖</div> <div className="text-2xl sm:text-3xl">🔖</div>
<div className="flex-1"> <div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-white mb-2">Save This Link!</h3> <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-sm mb-3"> <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. 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> </p>
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col gap-2">
<div className="flex-1 bg-gray-800/80 rounded-lg px-3 py-2 font-mono text-sm text-gray-300 break-all"> <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}`} {ticketUrl || `/tickets/${ticketId}`}
</div> </div>
<button <button
onClick={copyLink} onClick={copyLink}
className={`px-4 py-2 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${ 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 copied
? 'bg-green-600 text-white' ? 'bg-green-600 text-white'
: 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-white'
}`} }`}
> >
{copied ? ( {copied ? (
@@ -144,11 +144,11 @@ export default function TicketStatusPage() {
</div> </div>
{/* Purchase Info */} {/* Purchase Info */}
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800"> <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-4 text-sm"> <div className="grid grid-cols-2 gap-3 sm:gap-4 text-xs sm:text-sm">
<div> <div>
<span className="text-gray-400">Purchase ID:</span> <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>
<div> <div>
<span className="text-gray-400">Status:</span> <span className="text-gray-400">Status:</span>
@@ -167,15 +167,15 @@ export default function TicketStatusPage() {
{/* Payment Status */} {/* Payment Status */}
{purchase.invoice_status === 'pending' && ( {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} {STRINGS.ticket.waiting}
</div> </div>
)} )}
{/* Tickets */} {/* Tickets */}
{purchase.ticket_issue_status === 'issued' && ( {purchase.ticket_issue_status === 'issued' && (
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800"> <div className="bg-gray-900 rounded-xl p-4 sm:p-6 mb-5 sm:mb-6 border border-gray-800">
<h2 className="text-xl font-semibold mb-4 text-gray-300"> <h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 text-gray-300">
{STRINGS.ticket.ticketNumbers} {STRINGS.ticket.ticketNumbers}
</h2> </h2>
<TicketList tickets={tickets} /> <TicketList tickets={tickets} />
@@ -183,28 +183,30 @@ export default function TicketStatusPage() {
)} )}
{/* Draw Info */} {/* Draw Info */}
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800"> <div className="bg-gray-900 rounded-xl p-4 sm:p-6 mb-5 sm:mb-6 border border-gray-800">
<h2 className="text-xl font-semibold mb-4 text-gray-300"> <h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 text-gray-300">
Draw Information Draw Information
</h2> </h2>
<div className="space-y-4"> <div className="space-y-3 sm:space-y-4">
<div> <div>
<span className="text-gray-400">Draw Time:</span> <span className="text-gray-400 text-sm">Draw Time:</span>
<div className="text-white">{formatDateTime(cycle.scheduled_at)}</div> <div className="text-white text-sm sm:text-base">{formatDateTime(cycle.scheduled_at)}</div>
</div> </div>
<div> <div>
<span className="text-gray-400">Current Pot:</span> <span className="text-gray-400 text-sm">Current Pot:</span>
<div className="text-2xl font-bold text-bitcoin-orange"> <div className="text-xl sm:text-2xl font-bold text-bitcoin-orange">
{cycle.pot_total_sats.toLocaleString()} sats {cycle.pot_total_sats.toLocaleString()} sats
</div> </div>
</div> </div>
{cycle.status !== 'completed' && ( {cycle.status !== 'completed' && (
<div> <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>
<JackpotCountdown scheduledAt={cycle.scheduled_at} /> <div className="overflow-x-auto scrollbar-hide">
<JackpotCountdown scheduledAt={cycle.scheduled_at} />
</div>
</div> </div>
)} )}
</div> </div>
@@ -212,14 +214,14 @@ export default function TicketStatusPage() {
{/* Results */} {/* Results */}
{result.has_drawn && ( {result.has_drawn && (
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800"> <div className="bg-gray-900 rounded-xl p-4 sm:p-6 border border-gray-800">
<h2 className="text-xl font-semibold mb-4 text-gray-300"> <h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 text-gray-300">
Draw Results Draw Results
</h2> </h2>
{result.is_winner ? ( {result.is_winner ? (
<div> <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} 🎉 {STRINGS.ticket.congratulations}
</div> </div>
{result.payout && ( {result.payout && (
@@ -231,10 +233,10 @@ export default function TicketStatusPage() {
</div> </div>
) : ( ) : (
<div> <div>
<div className="bg-gray-800 px-6 py-4 rounded-lg mb-4 text-center"> <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">{STRINGS.ticket.betterLuck}</div> <div className="text-gray-400 mb-2 text-sm sm:text-base">{STRINGS.ticket.betterLuck}</div>
{cycle.winning_ticket_id && ( {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> {STRINGS.ticket.winningTicket}: <span className="font-bold text-bitcoin-orange">#{cycle.winning_ticket_id.substring(0, 8)}</span>
</div> </div>
)} )}

View File

@@ -147,17 +147,17 @@ export function DrawAnimation({
</div> </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 */} {/* Spinning Phase */}
{(phase === 'spinning' || phase === 'revealing') && ( {(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... 🎰 Drawing Winner...
</div> </div>
<div className="bg-gray-900 rounded-2xl p-8 border-2 border-yellow-500/50 shadow-2xl shadow-yellow-500/20"> <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-sm mb-2">Ticket Number</div> <div className="text-gray-400 text-xs sm:text-sm mb-2">Ticket Number</div>
<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' : '' phase === 'spinning' ? 'animate-number-spin' : ''
}`} }`}
> >
@@ -170,26 +170,26 @@ export function DrawAnimation({
{/* Winner Phase */} {/* Winner Phase */}
{phase === 'winner' && hasWinner && ( {phase === 'winner' && hasWinner && (
<div className="animate-winner-reveal"> <div className="animate-winner-reveal">
<div className="text-4xl mb-4">🎉🏆🎉</div> <div className="text-3xl sm:text-4xl mb-3 sm:mb-4">🎉🏆🎉</div>
<div className="text-3xl md:text-4xl font-bold text-yellow-400 mb-6"> <div className="text-2xl sm:text-3xl md:text-4xl font-bold text-yellow-400 mb-4 sm:mb-6">
We Have a Winner! We Have a Winner!
</div> </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="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-sm mb-1">Winner</div> <div className="text-gray-300 text-xs sm:text-sm mb-1">Winner</div>
<div className="text-3xl md:text-4xl font-bold text-white mb-4"> <div className="text-2xl sm:text-3xl md:text-4xl font-bold text-white mb-3 sm:mb-4 break-all">
{winnerName || 'Anon'} {winnerName || 'Anon'}
</div> </div>
<div className="text-gray-300 text-sm mb-1">Winning Ticket</div> <div className="text-gray-300 text-xs sm:text-sm mb-1">Winning Ticket</div>
<div className="text-2xl font-mono text-bitcoin-orange mb-4"> <div className="text-xl sm:text-2xl font-mono text-bitcoin-orange mb-3 sm:mb-4">
#{winningTicket!.toLocaleString()} #{winningTicket!.toLocaleString()}
</div> </div>
<div className="text-gray-300 text-sm mb-1">Prize</div> <div className="text-gray-300 text-xs sm:text-sm mb-1">Prize</div>
<div className="text-4xl md:text-5xl font-bold text-green-400"> <div className="text-3xl sm:text-4xl md:text-5xl font-bold text-green-400">
{potAmount!.toLocaleString()} sats {potAmount!.toLocaleString()} sats
</div> </div>
</div> </div>
<div className="mt-6 text-gray-400 text-sm animate-pulse"> <div className="mt-4 sm:mt-6 text-gray-400 text-xs sm:text-sm animate-pulse">
Click anywhere to continue Tap anywhere to continue
</div> </div>
</div> </div>
)} )}
@@ -197,20 +197,20 @@ export function DrawAnimation({
{/* No Winner Phase (no tickets sold) */} {/* No Winner Phase (no tickets sold) */}
{phase === 'no-winner' && ( {phase === 'no-winner' && (
<div className="animate-winner-reveal"> <div className="animate-winner-reveal">
<div className="text-4xl mb-4">😔</div> <div className="text-3xl sm:text-4xl mb-3 sm:mb-4">😔</div>
<div className="text-2xl md:text-3xl font-bold text-gray-400 mb-6"> <div className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-400 mb-4 sm:mb-6">
No Tickets This Round No Tickets This Round
</div> </div>
<div className="bg-gray-900 rounded-2xl p-8 border-2 border-gray-600 shadow-2xl"> <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-lg mb-4"> <div className="text-gray-300 text-base sm:text-lg mb-3 sm:mb-4">
No tickets were sold for this draw. No tickets were sold for this draw.
</div> </div>
<div className="text-bitcoin-orange text-xl font-semibold"> <div className="text-bitcoin-orange text-lg sm:text-xl font-semibold">
Next draw starting soon! Next draw starting soon!
</div> </div>
</div> </div>
<div className="mt-6 text-gray-400 text-sm animate-pulse"> <div className="mt-4 sm:mt-6 text-gray-400 text-xs sm:text-sm animate-pulse">
Click anywhere to continue Tap anywhere to continue
</div> </div>
</div> </div>
)} )}

View File

@@ -2,22 +2,22 @@ import Link from 'next/link';
export function Footer() { export function Footer() {
return ( 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="container mx-auto px-4">
<div className="flex flex-col md:flex-row justify-between items-center"> <div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="text-gray-400 text-sm mb-4 md:mb-0"> <div className="text-gray-400 text-xs sm:text-sm text-center md:text-left">
© 2025 Lightning Lottery. Powered by Bitcoin Lightning Network. © 2025 Lightning Lottery. Powered by Bitcoin Lightning Network.
</div> </div>
<div className="flex space-x-6"> <div className="flex space-x-6">
<Link <Link
href="/about" 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 About
</Link> </Link>
<Link <Link
href="/past-wins" 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 Past Winners
</Link> </Link>

View File

@@ -31,34 +31,34 @@ export function JackpotCountdown({ scheduledAt, drawCompleted = false }: Jackpot
} }
return ( 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 && ( {countdown.days > 0 && (
<div className="flex flex-col items-center"> <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} {countdown.days}
</div> </div>
<div className="text-sm text-gray-400">days</div> <div className="text-xs sm:text-sm text-gray-400">days</div>
</div> </div>
)} )}
<div className="flex flex-col items-center"> <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')} {countdown.hours.toString().padStart(2, '0')}
</div> </div>
<div className="text-sm text-gray-400">hours</div> <div className="text-xs sm:text-sm text-gray-400">hours</div>
</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="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')} {countdown.minutes.toString().padStart(2, '0')}
</div> </div>
<div className="text-sm text-gray-400">minutes</div> <div className="text-xs sm:text-sm text-gray-400">min</div>
</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="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')} {countdown.seconds.toString().padStart(2, '0')}
</div> </div>
<div className="text-sm text-gray-400">seconds</div> <div className="text-xs sm:text-sm text-gray-400">sec</div>
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -27,7 +27,7 @@ export function LightningInvoiceCard({
}; };
return ( 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 */} {/* QR Code Container */}
<div className="flex justify-center mb-4 relative"> <div className="flex justify-center mb-4 relative">
<div <div
@@ -37,9 +37,10 @@ export function LightningInvoiceCard({
> >
<QRCodeSVG <QRCodeSVG
value={paymentRequest.toUpperCase()} value={paymentRequest.toUpperCase()}
size={260} size={typeof window !== 'undefined' && window.innerWidth < 400 ? 200 : 260}
level="M" level="M"
includeMargin={true} includeMargin={true}
className="w-full max-w-[260px] h-auto"
/> />
</div> </div>
@@ -87,19 +88,19 @@ export function LightningInvoiceCard({
showPaidAnimation ? 'opacity-100' : 'opacity-0 h-0 overflow-hidden' 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> </div>
{/* Amount */} {/* Amount */}
<div className="text-center mb-4"> <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 {amountSats.toLocaleString()} sats
</div> </div>
</div> </div>
{/* Invoice */} {/* Invoice */}
<div className="mb-4"> <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} {paymentRequest}
</div> </div>
</div> </div>
@@ -108,10 +109,10 @@ export function LightningInvoiceCard({
<button <button
onClick={handleCopy} onClick={handleCopy}
disabled={showPaidAnimation} 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 showPaidAnimation
? 'bg-green-500 text-white cursor-default' ? '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'} {showPaidAnimation ? '✓ Paid' : copied ? '✓ Copied!' : '📋 Copy Invoice'}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useAppSelector } from '@/store/hooks'; import { useAppSelector } from '@/store/hooks';
import { NostrLoginButton } from './NostrLoginButton'; import { NostrLoginButton } from './NostrLoginButton';
import { shortNpub, hexToNpub } from '@/lib/nostr'; import { shortNpub, hexToNpub } from '@/lib/nostr';
@@ -9,9 +9,87 @@ import STRINGS from '@/constants/strings';
export function TopBar() { export function TopBar() {
const user = useAppSelector((state) => state.user); 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 ( 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="container mx-auto px-4">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
{/* Logo */} {/* Logo */}
@@ -22,41 +100,106 @@ export function TopBar() {
</span> </span>
</Link> </Link>
{/* Navigation */} {/* Desktop Navigation */}
<div className="flex items-center space-x-6"> <div className="hidden md:flex items-center space-x-6">
<Link <NavLinks />
href="/" </div>
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>
{user.authenticated ? ( {/* Mobile Menu Button */}
<Link <button
href="/dashboard" type="button"
className="text-gray-300 hover:text-white transition-colors" 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}
>
{mobileMenuOpen ? (
// X icon
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
Dashboard <path
</Link> 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> </div>
</div> </div>
</nav> </nav>
); );
} }

View File

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

View File

@@ -157,6 +157,16 @@ class ApiClient {
return this.request(`/jackpot/past-wins?${params.toString()}`); 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 // Auth endpoints
async nostrAuth(nostrPubkey: string, signedMessage: string, nonce: string) { async nostrAuth(nostrPubkey: string, signedMessage: string, nonce: string) {
return this.request('/auth/nostr', { return this.request('/auth/nostr', {

View File

@@ -14,6 +14,12 @@ logs/
*.log *.log
npm-debug.log* npm-debug.log*
# Database
data/
*.db
*.db-wal
*.db-shm
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/

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 URL (for generating ticket links)
FRONTEND_BASE_URL=http://localhost:3001 FRONTEND_BASE_URL=http://localhost:3001
# Redis Configuration (optional - falls back to in-memory if not set) # SQLite Database Path (optional - defaults to ./data/bot.db)
REDIS_URL=redis://localhost:6379 # BOT_DATABASE_PATH=./data/bot.db
# Bot Configuration # Bot Configuration
MAX_TICKETS_PER_PURCHASE=100 MAX_TICKETS_PER_PURCHASE=100

View File

@@ -10,13 +10,14 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.2", "axios": "^1.6.2",
"better-sqlite3": "^9.4.3",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"ioredis": "^5.3.2",
"node-telegram-bot-api": "^0.64.0", "node-telegram-bot-api": "^0.64.0",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^20.10.4", "@types/node": "^20.10.4",
"@types/node-telegram-bot-api": "^0.64.2", "@types/node-telegram-bot-api": "^0.64.2",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
@@ -136,12 +137,6 @@
"kuler": "^2.0.0" "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": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -208,6 +203,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/caseless": {
"version": "0.12.5", "version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
@@ -535,6 +540,26 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/bcrypt-pbkdf": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@@ -544,6 +569,17 @@
"tweetnacl": "^0.14.3" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -557,6 +593,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/bl": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz",
@@ -597,6 +642,30 @@
"node": ">=8" "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": { "node_modules/call-bind": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -684,6 +753,12 @@
"fsevents": "~2.3.2" "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": { "node_modules/cliui": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@@ -695,15 +770,6 @@
"wrap-ansi": "^6.2.0" "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": { "node_modules/color": {
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
@@ -849,6 +915,7 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -871,6 +938,30 @@
"node": ">=0.10.0" "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": { "node_modules/define-data-property": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -914,13 +1005,13 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/denque": { "node_modules/detect-libc": {
"version": "2.1.0", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=0.10" "node": ">=8"
} }
}, },
"node_modules/diff": { "node_modules/diff": {
@@ -1144,6 +1235,15 @@
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
"license": "MIT" "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": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -1188,6 +1288,12 @@
"node": ">=0.10.0" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1282,6 +1388,12 @@
"node": ">= 0.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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1416,6 +1528,12 @@
"assert-plus": "^1.0.0" "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": { "node_modules/glob-parent": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -1584,6 +1702,26 @@
"node": ">=0.10" "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": { "node_modules/ignore-by-default": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "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==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -1611,30 +1755,6 @@
"node": ">= 0.4" "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": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -2106,18 +2226,6 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "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": { "node_modules/logform": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
@@ -2184,6 +2292,18 @@
"node": ">= 0.6" "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": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2197,12 +2317,45 @@
"node": "*" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/node-telegram-bot-api": {
"version": "0.64.0", "version": "0.64.0",
"resolved": "https://registry.npmjs.org/node-telegram-bot-api/-/node-telegram-bot-api-0.64.0.tgz", "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": ">= 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": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -2527,6 +2716,21 @@
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT" "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": { "node_modules/readable-stream": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -2561,27 +2765,6 @@
"node": ">=8.10.0" "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": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -2878,7 +3061,6 @@
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -3011,6 +3193,51 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/simple-update-notifier": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -3058,12 +3285,6 @@
"node": "*" "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": { "node_modules/stealthy-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz",
@@ -3183,6 +3404,15 @@
"node": ">=8" "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": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -3196,6 +3426,69 @@
"node": ">=4" "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": { "node_modules/text-hex": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import TelegramBot from 'node-telegram-bot-api'; import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state'; import { stateManager } from '../services/state';
import { logger, logUserAction } from '../services/logger'; import { logger, logUserAction } from '../services/logger';
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards'; import { getMainMenuKeyboard, getLightningAddressKeyboard, getCancelKeyboard } from '../utils/keyboards';
import { isValidLightningAddress } from '../utils/format'; import { isValidLightningAddress, verifyLightningAddress } from '../utils/format';
import { messages } from '../messages'; import { messages } from '../messages';
/** /**
@@ -14,6 +14,7 @@ export async function handleAddressCommand(
): Promise<void> { ): Promise<void> {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const userId = msg.from?.id; const userId = msg.from?.id;
const username = msg.from?.username;
if (!userId) { if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified); await bot.sendMessage(chatId, messages.errors.userNotIdentified);
@@ -31,12 +32,12 @@ export async function handleAddressCommand(
} }
const message = user.lightningAddress const message = user.lightningAddress
? messages.address.currentAddress(user.lightningAddress) ? messages.address.currentAddressWithOptions(user.lightningAddress, username)
: messages.address.noAddressSet; : messages.address.noAddressSetWithOptions(username);
await bot.sendMessage(chatId, message, { await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(), reply_markup: getLightningAddressKeyboard(username),
}); });
await stateManager.updateUserState(userId, 'updating_address'); await stateManager.updateUserState(userId, 'updating_address');
@@ -55,6 +56,7 @@ export async function handleLightningAddressInput(
): Promise<boolean> { ): Promise<boolean> {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const userId = msg.from?.id; const userId = msg.from?.id;
const username = msg.from?.username;
const text = msg.text?.trim(); const text = msg.text?.trim();
if (!userId || !text) return false; if (!userId || !text) return false;
@@ -73,7 +75,19 @@ export async function handleLightningAddressInput(
if (!isValidLightningAddress(text)) { if (!isValidLightningAddress(text)) {
await bot.sendMessage(chatId, messages.address.invalidFormat, { await bot.sendMessage(chatId, messages.address.invalidFormat, {
parse_mode: 'Markdown', 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; return true;
} }
@@ -81,7 +95,7 @@ export async function handleLightningAddressInput(
// Save the lightning address // Save the lightning address
await stateManager.updateLightningAddress(userId, text); await stateManager.updateLightningAddress(userId, text);
logUserAction(userId, 'Lightning address updated'); logUserAction(userId, 'Lightning address updated', { address: text });
const responseMessage = user.state === 'awaiting_lightning_address' const responseMessage = user.state === 'awaiting_lightning_address'
? messages.address.firstTimeSuccess(text) ? 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 { export default {
handleAddressCommand, handleAddressCommand,
handleLightningAddressInput, handleLightningAddressInput,
handleLightningAddressCallback,
}; };

View File

@@ -15,6 +15,13 @@ import { formatSats, formatDate, formatTimeUntil } from '../utils/format';
import { PendingPurchaseData, AwaitingPaymentData } from '../types'; import { PendingPurchaseData, AwaitingPaymentData } from '../types';
import { messages } from '../messages'; 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 * Handle /buy command or "Buy Tickets" button
*/ */
@@ -33,6 +40,26 @@ export async function handleBuyCommand(
logUserAction(userId, 'Initiated ticket purchase'); logUserAction(userId, 'Initiated ticket purchase');
try { 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); const user = await stateManager.getUser(userId);
if (!user) { if (!user) {
@@ -273,11 +300,11 @@ export async function handlePurchaseConfirmation(
logUserAction(userId, 'Confirmed purchase', { tickets: pendingData.ticketCount }); logUserAction(userId, 'Confirmed purchase', { tickets: pendingData.ticketCount });
// Create invoice // Create invoice with user's display name (defaults to @username)
const purchaseResult = await apiClient.buyTickets( const purchaseResult = await apiClient.buyTickets(
pendingData.ticketCount, pendingData.ticketCount,
user.lightningAddress, user.lightningAddress,
userId stateManager.getDisplayName(user)
); );
logPaymentEvent(userId, purchaseResult.ticket_purchase_id, 'created', { logPaymentEvent(userId, purchaseResult.ticket_purchase_id, 'created', {
@@ -295,21 +322,23 @@ export async function handlePurchaseConfirmation(
parse_mode: 'Markdown', parse_mode: 'Markdown',
}); });
// Send QR code // Send QR code with caption
await bot.sendPhoto(chatId, qrBuffer, { const qrMessage = await bot.sendPhoto(chatId, qrBuffer, {
caption: messages.buy.invoiceCaption( caption: messages.buy.invoiceCaption(
pendingData.ticketCount, pendingData.ticketCount,
formatSats(pendingData.totalAmount), formatSats(pendingData.totalAmount),
purchaseResult.invoice.payment_request,
config.bot.invoiceExpiryMinutes config.bot.invoiceExpiryMinutes
), ),
parse_mode: 'Markdown', 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 // Store purchase and start polling
const paymentData: AwaitingPaymentData = { const paymentData: AwaitingPaymentData = {
...pendingData, ...pendingData,
@@ -317,17 +346,21 @@ export async function handlePurchaseConfirmation(
paymentRequest: purchaseResult.invoice.payment_request, paymentRequest: purchaseResult.invoice.payment_request,
publicUrl: purchaseResult.public_url, publicUrl: purchaseResult.public_url,
pollStartTime: Date.now(), pollStartTime: Date.now(),
headerMessageId: messageId,
invoiceMessageId: invoiceMessage.message_id,
qrMessageId: qrMessage.message_id,
}; };
await stateManager.storePurchase(userId, purchaseResult.ticket_purchase_id, paymentData); await stateManager.storePurchase(userId, purchaseResult.ticket_purchase_id, paymentData);
await stateManager.updateUserState(userId, 'awaiting_invoice_payment', paymentData); await stateManager.updateUserState(userId, 'awaiting_invoice_payment', paymentData);
// Start payment polling // Start payment polling - pass all message IDs to delete on completion
pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id); pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id, messageId, qrMessage.message_id, invoiceMessage.message_id);
} catch (error) { } catch (error) {
logger.error('Error in handlePurchaseConfirmation', { error, userId }); logger.error('Error in handlePurchaseConfirmation', { error, userId });
await bot.sendMessage(chatId, messages.errors.invoiceCreationFailed, { await bot.sendMessage(chatId, messages.errors.invoiceCreationFailed, {
reply_markup: getMainMenuKeyboard(), // Only show reply keyboard in private chats, not in groups
...(isGroupChat(chatId) ? {} : { reply_markup: getMainMenuKeyboard() }),
}); });
await stateManager.clearUserStateData(userId); await stateManager.clearUserStateData(userId);
} }
@@ -340,7 +373,10 @@ async function pollPaymentStatus(
bot: TelegramBot, bot: TelegramBot,
chatId: number, chatId: number,
userId: number, userId: number,
purchaseId: string purchaseId: string,
headerMessageId?: number,
qrMessageId?: number,
invoiceMessageId?: number
): Promise<void> { ): Promise<void> {
const pollInterval = config.bot.paymentPollIntervalMs; const pollInterval = config.bot.paymentPollIntervalMs;
const timeout = config.bot.paymentPollTimeoutMs; const timeout = config.bot.paymentPollTimeoutMs;
@@ -348,14 +384,45 @@ async function pollPaymentStatus(
logPaymentEvent(userId, purchaseId, 'polling'); 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> => { const checkPayment = async (): Promise<void> => {
try { try {
// Check if we've timed out // Check if we've timed out
if (Date.now() - startTime > timeout) { if (Date.now() - startTime > timeout) {
logPaymentEvent(userId, purchaseId, 'expired'); logPaymentEvent(userId, purchaseId, 'expired');
// Delete the invoice messages
await deleteInvoiceMessages();
await bot.sendMessage(chatId, messages.buy.invoiceExpired, { await bot.sendMessage(chatId, messages.buy.invoiceExpired, {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(), // Only show reply keyboard in private chats, not in groups
...(isGroupChat(chatId) ? {} : { reply_markup: getMainMenuKeyboard() }),
}); });
await stateManager.clearUserStateData(userId); await stateManager.clearUserStateData(userId);
return; return;
@@ -374,6 +441,12 @@ async function pollPaymentStatus(
tickets: status.tickets.length, 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! // Payment received!
const ticketNumbers = status.tickets const ticketNumbers = status.tickets
.map((t) => `#${t.serial_number.toString().padStart(4, '0')}`) .map((t) => `#${t.serial_number.toString().padStart(4, '0')}`)
@@ -397,9 +470,14 @@ async function pollPaymentStatus(
if (status.purchase.invoice_status === 'expired') { if (status.purchase.invoice_status === 'expired') {
logPaymentEvent(userId, purchaseId, 'expired'); logPaymentEvent(userId, purchaseId, 'expired');
// Delete the invoice messages
await deleteInvoiceMessages();
await bot.sendMessage(chatId, messages.buy.invoiceExpiredShort, { await bot.sendMessage(chatId, messages.buy.invoiceExpiredShort, {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(), // Only show reply keyboard in private chats, not in groups
...(isGroupChat(chatId) ? {} : { reply_markup: getMainMenuKeyboard() }),
}); });
await stateManager.clearUserStateData(userId); await stateManager.clearUserStateData(userId);
return; return;

View File

@@ -2,6 +2,20 @@ import TelegramBot from 'node-telegram-bot-api';
import { groupStateManager } from '../services/groupState'; import { groupStateManager } from '../services/groupState';
import { logger, logUserAction } from '../services/logger'; import { logger, logUserAction } from '../services/logger';
import { messages } from '../messages'; import { messages } from '../messages';
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 * Check if a user is an admin in a group
@@ -63,6 +77,7 @@ export async function handleBotRemovedFromGroup(
/** /**
* Handle /settings command (group admin only) * Handle /settings command (group admin only)
* Opens settings in private DM, not in the group
*/ */
export async function handleGroupSettings( export async function handleGroupSettings(
bot: TelegramBot, bot: TelegramBot,
@@ -106,36 +121,108 @@ export async function handleGroupSettings(
return; return;
} }
await bot.sendMessage( // Send settings to user's private DM
chatId, try {
messages.groups.settingsOverview(currentSettings), const sentMessage = await bot.sendMessage(
{ userId,
parse_mode: 'Markdown', messages.groups.settingsOverview(currentSettings),
reply_markup: getGroupSettingsKeyboard(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!`
);
} 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.`
);
}
} catch (error) { } catch (error) {
logger.error('Error in handleGroupSettings', { error, chatId }); logger.error('Error in handleGroupSettings', { error, chatId });
await bot.sendMessage(chatId, messages.errors.generic); await bot.sendMessage(chatId, messages.errors.generic);
} }
} }
/**
* 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 * Handle group settings toggle callback
* Settings can now be managed from DMs, so group ID is included in callback data
*/ */
export async function handleGroupSettingsCallback( export async function handleGroupSettingsCallback(
bot: TelegramBot, bot: TelegramBot,
query: TelegramBot.CallbackQuery, query: TelegramBot.CallbackQuery,
action: string action: string
): Promise<void> { ): Promise<void> {
const chatId = query.message?.chat.id; const dmChatId = query.message?.chat.id;
const userId = query.from.id; const userId = query.from.id;
const messageId = query.message?.message_id; const messageId = query.message?.message_id;
if (!chatId || !messageId) return; if (!dmChatId || !messageId) return;
// Check if user is admin // Parse the action to extract the group ID
const isAdmin = await isGroupAdmin(bot, chatId, userId); 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) { if (!isAdmin) {
await bot.answerCallbackQuery(query.id, { await bot.answerCallbackQuery(query.id, {
text: messages.groups.adminOnly, text: messages.groups.adminOnly,
@@ -144,125 +231,339 @@ export async function handleGroupSettingsCallback(
return; return;
} }
// Refresh auto-delete timer on any interaction (in the DM)
scheduleSettingsMessageDeletion(bot, dmChatId, messageId);
try { try {
let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed'; const currentSettings = await groupStateManager.getGroup(groupId);
switch (action) {
case 'toggle_enabled':
setting = 'enabled';
break;
case 'toggle_announcements':
setting = 'drawAnnouncements';
break;
case 'toggle_reminders':
setting = 'reminders';
break;
case 'toggle_purchases':
setting = 'ticketPurchaseAllowed';
break;
default:
await bot.answerCallbackQuery(query.id);
return;
}
const currentSettings = await groupStateManager.getGroup(chatId);
if (!currentSettings) { if (!currentSettings) {
await bot.answerCallbackQuery(query.id, { text: 'Group not found' }); await bot.answerCallbackQuery(query.id, { text: 'Group not found' });
return; return;
} }
const newValue = !currentSettings[setting]; let updatedSettings: GroupSettings | null = null;
const updatedSettings = await groupStateManager.updateSetting(chatId, setting, newValue);
// 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;
case 'toggle_announcements':
setting = 'drawAnnouncements';
break;
case 'toggle_reminders':
setting = 'reminders';
break;
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 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'}`,
});
}
}
// 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) { if (!updatedSettings) {
await bot.answerCallbackQuery(query.id, { text: 'Failed to update' }); await bot.answerCallbackQuery(query.id, { text: 'Failed to update' });
return; return;
} }
logUserAction(userId, 'Updated group setting', { // Update the message with new settings (in the DM)
groupId: chatId,
setting,
newValue,
});
await bot.answerCallbackQuery(query.id, {
text: `${setting} ${newValue ? 'enabled' : 'disabled'}`,
});
// Update the message with new settings
await bot.editMessageText( await bot.editMessageText(
messages.groups.settingsOverview(updatedSettings), messages.groups.settingsOverview(updatedSettings),
{ {
chat_id: chatId, chat_id: dmChatId,
message_id: messageId, message_id: messageId,
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getGroupSettingsKeyboard(updatedSettings), reply_markup: getGroupSettingsKeyboard(updatedSettings),
} }
); );
} catch (error) { } 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' }); 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 * Generate inline keyboard for group settings
* groupId is included in callback data so settings can be managed from DMs
*/ */
function getGroupSettingsKeyboard(settings: { function getGroupSettingsKeyboard(settings: GroupSettings): TelegramBot.InlineKeyboardMarkup {
enabled: boolean; const onOff = (val: boolean | undefined) => val !== false ? '✅' : '❌';
drawAnnouncements: boolean; const selected = (current: number, option: number) => current === option ? '●' : '○';
reminders: boolean; const gid = settings.groupId; // Include group ID in all callbacks
ticketPurchaseAllowed: boolean;
}): TelegramBot.InlineKeyboardMarkup {
const onOff = (val: boolean) => val ? '✅' : '❌';
return { const keyboard: TelegramBot.InlineKeyboardButton[][] = [
inline_keyboard: [ [{
[{ text: `${onOff(settings.enabled)} Bot Enabled`,
text: `${onOff(settings.enabled)} Bot Enabled`, callback_data: `group_toggle_enabled_${gid}`,
callback_data: 'group_toggle_enabled', }],
}], [{
[{ text: `${onOff(settings.newJackpotAnnouncement)} New Jackpot Announcement`,
text: `${onOff(settings.drawAnnouncements)} Draw Announcements`, callback_data: `group_toggle_newjackpot_${gid}`,
callback_data: 'group_toggle_announcements', }],
}], ];
[{
text: `${onOff(settings.reminders)} Draw Reminders`, // Add new jackpot delay options if enabled
callback_data: 'group_toggle_reminders', if (settings.newJackpotAnnouncement !== false) {
}], keyboard.push(
[{ NEW_JACKPOT_DELAY_OPTIONS.map(minutes => ({
text: `${onOff(settings.ticketPurchaseAllowed)} Allow Ticket Purchases`, text: `${selected(settings.newJackpotDelayMinutes ?? 5, minutes)} ${formatNewJackpotDelay(minutes)}`,
callback_data: 'group_toggle_purchases', callback_data: `group_newjackpot_delay_${minutes}_${gid}`,
}], }))
[{ );
text: '🔄 Refresh', }
callback_data: 'group_refresh',
}], 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_${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_${gid}`,
}],
[{
text: '🔄 Refresh',
callback_data: `group_refresh_${gid}`,
}]
);
return { inline_keyboard: keyboard };
} }
/** /**
* Handle refresh callback * Handle refresh callback
* groupId is extracted from callback data since settings are managed in DMs
*/ */
export async function handleGroupRefresh( export async function handleGroupRefresh(
bot: TelegramBot, bot: TelegramBot,
query: TelegramBot.CallbackQuery query: TelegramBot.CallbackQuery,
groupId: number
): Promise<void> { ): Promise<void> {
const chatId = query.message?.chat.id; const dmChatId = query.message?.chat.id;
const messageId = query.message?.message_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!' }); await bot.answerCallbackQuery(query.id, { text: 'Refreshed!' });
const settings = await groupStateManager.getGroup(chatId); const settings = await groupStateManager.getGroup(groupId);
if (!settings) return; if (!settings) return;
await bot.editMessageText( await bot.editMessageText(
messages.groups.settingsOverview(settings), messages.groups.settingsOverview(settings),
{ {
chat_id: chatId, chat_id: dmChatId,
message_id: messageId, message_id: messageId,
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getGroupSettingsKeyboard(settings), reply_markup: getGroupSettingsKeyboard(settings),

View File

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

View File

@@ -2,6 +2,7 @@ export { handleStart } from './start';
export { export {
handleAddressCommand, handleAddressCommand,
handleLightningAddressInput, handleLightningAddressInput,
handleLightningAddressCallback,
} from './address'; } from './address';
export { export {
handleBuyCommand, handleBuyCommand,
@@ -11,6 +12,7 @@ export {
} from './buy'; } from './buy';
export { export {
handleTicketsCommand, handleTicketsCommand,
handleTicketsPage,
handleViewTicket, handleViewTicket,
handleStatusCheck, handleStatusCheck,
} from './tickets'; } from './tickets';
@@ -21,6 +23,11 @@ export {
handleCancel, handleCancel,
handleMenuCallback, handleMenuCallback,
} from './menu'; } from './menu';
export {
handleSettingsCommand,
handleSettingsCallback,
handleDisplayNameInput,
} from './settings';
export { export {
handleBotAddedToGroup, handleBotAddedToGroup,
handleBotRemovedFromGroup, 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 TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state'; import { stateManager } from '../services/state';
import { logger, logUserAction } from '../services/logger'; import { logger, logUserAction } from '../services/logger';
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards'; import { getMainMenuKeyboard, getLightningAddressKeyboard } from '../utils/keyboards';
import { messages } from '../messages'; import { messages } from '../messages';
/** /**
@@ -10,6 +10,7 @@ import { messages } from '../messages';
export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): Promise<void> { export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): Promise<void> {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const userId = msg.from?.id; const userId = msg.from?.id;
const username = msg.from?.username;
if (!userId) { if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified); 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', { logUserAction(userId, 'Started bot', {
username: msg.from?.username, username: username,
firstName: msg.from?.first_name, firstName: msg.from?.first_name,
}); });
@@ -29,7 +30,7 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P
// Create new user // Create new user
user = await stateManager.createUser( user = await stateManager.createUser(
userId, userId,
msg.from?.username, username,
msg.from?.first_name, msg.from?.first_name,
msg.from?.last_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 // Check if lightning address is set
if (!user.lightningAddress) { if (!user.lightningAddress) {
await bot.sendMessage(chatId, messages.start.needAddress, { await bot.sendMessage(chatId, messages.start.needAddressWithOptions(username), {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(), reply_markup: getLightningAddressKeyboard(username),
}); });
await stateManager.updateUserState(userId, 'awaiting_lightning_address'); 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 { formatSats, formatDate } from '../utils/format';
import { messages } from '../messages'; 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 * Handle /tickets command or "My Tickets" button
*/ */
export async function handleTicketsCommand( export async function handleTicketsCommand(
bot: TelegramBot, bot: TelegramBot,
msg: TelegramBot.Message msg: TelegramBot.Message,
page: number = 0
): Promise<void> { ): Promise<void> {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const userId = msg.from?.id; const userId = msg.from?.id;
@@ -22,8 +35,46 @@ export async function handleTicketsCommand(
return; 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 { try {
const user = await stateManager.getUser(userId); const user = await stateManager.getUser(userId);
@@ -32,8 +83,12 @@ export async function handleTicketsCommand(
return; return;
} }
// Get user's purchase IDs from state // Get current cycle to identify current round tickets
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 10); 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) { if (purchaseIds.length === 0) {
await bot.sendMessage(chatId, messages.tickets.empty, { await bot.sendMessage(chatId, messages.tickets.empty, {
@@ -44,21 +99,21 @@ export async function handleTicketsCommand(
} }
// Fetch status for each purchase // Fetch status for each purchase
const purchases: Array<{ const allPurchases: PurchaseInfo[] = [];
id: string;
ticketCount: number;
scheduledAt: string;
invoiceStatus: string;
isWinner: boolean;
hasDrawn: boolean;
}> = [];
for (const purchaseId of purchaseIds) { for (const purchaseId of purchaseIds) {
try { try {
const status = await apiClient.getTicketStatus(purchaseId); const status = await apiClient.getTicketStatus(purchaseId);
if (status) { 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, id: status.purchase.id,
cycleId: status.purchase.cycle_id,
ticketCount: status.purchase.number_of_tickets, ticketCount: status.purchase.number_of_tickets,
scheduledAt: status.cycle.scheduled_at, scheduledAt: status.cycle.scheduled_at,
invoiceStatus: status.purchase.invoice_status, 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, { await bot.sendMessage(chatId, messages.tickets.notFound, {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(), reply_markup: getMainMenuKeyboard(),
@@ -80,56 +135,134 @@ export async function handleTicketsCommand(
return; return;
} }
// Format purchases list // Separate current round and past tickets
let message = messages.tickets.header; 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++) { // Sort past tickets by date (newest first)
const p = purchases[i]; pastTickets.sort((a, b) =>
const drawDate = new Date(p.scheduledAt); new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime()
);
let statusEmoji: string; // Build message
let statusText: string; let message = '';
const inlineKeyboard: TelegramBot.InlineKeyboardButton[][] = [];
if (p.invoiceStatus === 'pending') { // Current round section (always shown on page 0)
statusEmoji = '⏳'; if (page === 0 && currentRoundTickets.length > 0) {
statusText = messages.tickets.statusPending; message += `🎯 *Current Round*\n\n`;
} else if (p.invoiceStatus === 'expired') {
statusEmoji = '❌'; for (const p of currentRoundTickets) {
statusText = messages.tickets.statusExpired; const drawDate = new Date(p.scheduledAt);
} else if (!p.hasDrawn) { const statusInfo = getStatusInfo(p);
statusEmoji = '🎟'; message += `${statusInfo.emoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} Draw: ${formatDate(drawDate)}\n`;
statusText = messages.tickets.statusActive;
} else if (p.isWinner) { inlineKeyboard.push([{
statusEmoji = '🏆'; text: `🎟 View Current Tickets #${p.id.substring(0, 8)}...`,
statusText = messages.tickets.statusWon; callback_data: `view_ticket_${p.id}`,
} else { }]);
statusEmoji = '😔';
statusText = messages.tickets.statusLost;
} }
message += `${i + 1}. ${statusEmoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} ${formatDate(drawDate)} ${statusText}\n`; 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`;
} }
message += messages.tickets.tapForDetails; // 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);
// Create inline buttons for each purchase // Past tickets section
const inlineKeyboard = purchases.map((p, i) => [{ for (let i = 0; i < ticketsToShow.length; i++) {
text: `${i + 1}. View Ticket #${p.id.substring(0, 8)}...`, const p = ticketsToShow[i];
callback_data: `view_ticket_${p.id}`, 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, { await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: { inline_keyboard: inlineKeyboard }, reply_markup: { inline_keyboard: inlineKeyboard },
}); });
} catch (error) { } catch (error) {
logger.error('Error in handleTicketsCommand', { error, userId }); logger.error('Error in sendTicketsList', { error, userId });
await bot.sendMessage(chatId, messages.errors.fetchTicketsFailed, { await bot.sendMessage(chatId, messages.errors.fetchTicketsFailed, {
reply_markup: getMainMenuKeyboard(), 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 * Handle viewing a specific ticket
*/ */
@@ -254,6 +387,7 @@ export async function handleStatusCheck(
export default { export default {
handleTicketsCommand, handleTicketsCommand,
handleTicketsPage,
handleViewTicket, handleViewTicket,
handleStatusCheck, handleStatusCheck,
}; };

View File

@@ -1,18 +1,22 @@
import TelegramBot from 'node-telegram-bot-api'; import TelegramBot from 'node-telegram-bot-api';
import config from './config'; import config from './config';
import { botDatabase } from './services/database';
import { stateManager } from './services/state'; import { stateManager } from './services/state';
import { groupStateManager } from './services/groupState'; import { groupStateManager } from './services/groupState';
import { apiClient } from './services/api'; import { apiClient } from './services/api';
import { logger, logUserAction } from './services/logger'; import { logger, logUserAction } from './services/logger';
import { notificationScheduler } from './services/notificationScheduler';
import { import {
handleStart, handleStart,
handleAddressCommand, handleAddressCommand,
handleLightningAddressInput, handleLightningAddressInput,
handleLightningAddressCallback,
handleBuyCommand, handleBuyCommand,
handleTicketAmountSelection, handleTicketAmountSelection,
handleCustomTicketAmount, handleCustomTicketAmount,
handlePurchaseConfirmation, handlePurchaseConfirmation,
handleTicketsCommand, handleTicketsCommand,
handleTicketsPage,
handleViewTicket, handleViewTicket,
handleStatusCheck, handleStatusCheck,
handleWinsCommand, handleWinsCommand,
@@ -20,6 +24,9 @@ import {
handleMenuCommand, handleMenuCommand,
handleCancel, handleCancel,
handleMenuCallback, handleMenuCallback,
handleSettingsCommand,
handleSettingsCallback,
handleDisplayNameInput,
handleBotAddedToGroup, handleBotAddedToGroup,
handleBotRemovedFromGroup, handleBotRemovedFromGroup,
handleGroupSettings, handleGroupSettings,
@@ -90,7 +97,7 @@ bot.onText(/\/start/, async (msg) => {
if (isGroupChat(msg)) { if (isGroupChat(msg)) {
await bot.sendMessage( await bot.sendMessage(
msg.chat.id, 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.`, `⚡ *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' } { parse_mode: 'Markdown' }
); );
return; return;
@@ -99,8 +106,8 @@ bot.onText(/\/start/, async (msg) => {
await handleStart(bot, msg); await handleStart(bot, msg);
}); });
// Handle /buy command // Handle /buyticket command
bot.onText(/\/buy/, async (msg) => { bot.onText(/\/buyticket/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return; if (!shouldProcessMessage(msg.message_id)) return;
// Check if in group // Check if in group
@@ -143,8 +150,8 @@ bot.onText(/\/wins/, async (msg) => {
await handleWinsCommand(bot, msg); await handleWinsCommand(bot, msg);
}); });
// Handle /address command // Handle /lottoaddress command
bot.onText(/\/address/, async (msg) => { bot.onText(/\/lottoaddress/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return; if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat // Only in private chat
@@ -156,8 +163,8 @@ bot.onText(/\/address/, async (msg) => {
await handleAddressCommand(bot, msg); await handleAddressCommand(bot, msg);
}); });
// Handle /menu command // Handle /lottomenu command
bot.onText(/\/menu/, async (msg) => { bot.onText(/\/lottomenu/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return; if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat // Only in private chat
@@ -169,10 +176,10 @@ bot.onText(/\/menu/, async (msg) => {
await handleMenuCommand(bot, msg); await handleMenuCommand(bot, msg);
}); });
// Handle /help command // Handle /lottohelp command
bot.onText(/\/help/, async (msg) => { bot.onText(/\/lottohelp/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return; if (!shouldProcessMessage(msg.message_id)) return;
await handleHelpCommand(bot, msg); await handleHelpCommand(bot, msg, isGroupChat(msg));
}); });
// Handle /jackpot command (works in groups and DMs) // Handle /jackpot command (works in groups and DMs)
@@ -197,7 +204,7 @@ bot.onText(/\/jackpot/, async (msg) => {
⏰ *Draw at:* ${formatDate(drawTime)} ⏰ *Draw at:* ${formatDate(drawTime)}
⏳ *Time left:* ${formatTimeUntil(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' });
} catch (error) { } catch (error) {
@@ -206,8 +213,8 @@ Use /buy to get your tickets! 🍀`;
} }
}); });
// Handle /settings command (groups only, admin only) // Handle /lottosettings command (groups only, admin only)
bot.onText(/\/settings/, async (msg) => { bot.onText(/\/lottosettings/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return; if (!shouldProcessMessage(msg.message_id)) return;
await handleGroupSettings(bot, msg); await handleGroupSettings(bot, msg);
}); });
@@ -231,6 +238,33 @@ bot.on('message', async (msg) => {
// Handle menu button presses // Handle menu button presses
switch (text) { 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': case '🎟 Buy Tickets':
await handleBuyCommand(bot, msg); await handleBuyCommand(bot, msg);
return; return;
@@ -243,6 +277,9 @@ bot.on('message', async (msg) => {
case '⚡ Lightning Address': case '⚡ Lightning Address':
await handleAddressCommand(bot, msg); await handleAddressCommand(bot, msg);
return; return;
case '⚙️ Settings':
await handleSettingsCommand(bot, msg);
return;
case ' Help': case ' Help':
await handleHelpCommand(bot, msg); await handleHelpCommand(bot, msg);
return; return;
@@ -264,6 +301,12 @@ bot.on('message', async (msg) => {
if (handled) return; if (handled) return;
} }
// Handle display name input
if (user.state === 'awaiting_display_name') {
await handleDisplayNameInput(bot, msg);
return;
}
// Handle custom ticket amount input // Handle custom ticket amount input
if (user.state === 'awaiting_ticket_amount') { if (user.state === 'awaiting_ticket_amount') {
const handled = await handleCustomTicketAmount(bot, msg); const handled = await handleCustomTicketAmount(bot, msg);
@@ -297,16 +340,55 @@ bot.on('callback_query', async (query) => {
logUserAction(query.from.id, 'Callback', { data }); logUserAction(query.from.id, 'Callback', { data });
try { try {
// Handle group settings toggles // Handle group settings toggles (now includes group ID: group_toggle_enabled_-123456789)
if (data.startsWith('group_toggle_')) { if (data.startsWith('group_toggle_')) {
const action = data.replace('group_', ''); const action = data.replace('group_', '');
await handleGroupSettingsCallback(bot, query, action); await handleGroupSettingsCallback(bot, query, action);
return; return;
} }
// Handle group refresh // Handle group reminder time adjustment (reminder1_add_1_hours_-123456789, etc.)
if (data === 'group_refresh') { if (data.match(/^group_reminder\d_(add|sub)_\d+_(minutes|hours|days)_-?\d+$/)) {
await handleGroupRefresh(bot, query); 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; return;
} }
@@ -342,6 +424,15 @@ bot.on('callback_query', async (query) => {
return; 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 // Handle view ticket
if (data.startsWith('view_ticket_')) { if (data.startsWith('view_ticket_')) {
const purchaseId = data.replace('view_ticket_', ''); const purchaseId = data.replace('view_ticket_', '');
@@ -389,6 +480,7 @@ async function shutdown(): Promise<void> {
bot.stopPolling(); bot.stopPolling();
await stateManager.close(); await stateManager.close();
await groupStateManager.close(); await groupStateManager.close();
botDatabase.close();
logger.info('Shutdown complete'); logger.info('Shutdown complete');
process.exit(0); process.exit(0);
} }
@@ -399,28 +491,31 @@ process.on('SIGTERM', shutdown);
// Start bot // Start bot
async function start(): Promise<void> { async function start(): Promise<void> {
try { try {
// Initialize state managers // Initialize SQLite database
botDatabase.init();
// Initialize state managers (now use SQLite)
await stateManager.init(); await stateManager.init();
await groupStateManager.init(config.redis.url); await groupStateManager.init();
// Set bot commands for private chats // Set bot commands for private chats
await bot.setMyCommands([ await bot.setMyCommands([
{ command: 'start', description: 'Start the bot' }, { command: 'start', description: 'Start the bot' },
{ command: 'menu', description: 'Show main menu' }, { command: 'lottomenu', description: 'Show main menu' },
{ command: 'buy', description: 'Buy lottery tickets' }, { command: 'buyticket', description: 'Buy lottery tickets' },
{ command: 'tickets', description: 'View your tickets' }, { command: 'tickets', description: 'View your tickets' },
{ command: 'wins', description: 'View your past wins' }, { 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: 'jackpot', description: 'View current jackpot info' },
{ command: 'help', description: 'Help & information' }, { command: 'lottohelp', description: 'Help & information' },
]); ]);
// Set bot commands for groups (different scope) // Set bot commands for groups (different scope)
await bot.setMyCommands( await bot.setMyCommands(
[ [
{ command: 'jackpot', description: 'View current jackpot info' }, { command: 'jackpot', description: 'View current jackpot info' },
{ command: 'settings', description: 'Group settings (admin only)' }, { command: 'lottosettings', description: 'Group settings (admin only)' },
{ command: 'help', description: 'Help & information' }, { command: 'lottohelp', description: 'Help & information' },
], ],
{ scope: { type: 'all_group_chats' } } { scope: { type: 'all_group_chats' } }
); );
@@ -431,10 +526,15 @@ async function start(): Promise<void> {
username: botInfo.username, username: botInfo.username,
}); });
// Initialize and start notification scheduler
notificationScheduler.init(bot);
notificationScheduler.start();
logger.info('⚡ Lightning Jackpot Telegram Bot is running!'); logger.info('⚡ Lightning Jackpot Telegram Bot is running!');
logger.info(`📡 API URL: ${config.api.baseUrl}`); logger.info(`📡 API URL: ${config.api.baseUrl}`);
logger.info(`🌐 Frontend URL: ${config.frontend.baseUrl}`); logger.info(`🌐 Frontend URL: ${config.frontend.baseUrl}`);
logger.info('👥 Group support enabled'); logger.info('👥 Group support enabled');
logger.info('📢 Notification scheduler started');
} catch (error) { } catch (error) {
logger.error('Failed to start bot', { error }); logger.error('Failed to start bot', { error });
process.exit(1); process.exit(1);

View File

@@ -8,7 +8,7 @@ export const messages = {
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
errors: { errors: {
userNotIdentified: '❌ Could not identify user.', 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.', generic: '❌ An error occurred. Please try again.',
startAgain: '❌ An error occurred. Please try again with /start', startAgain: '❌ An error occurred. Please try again with /start',
systemUnavailable: '❌ The lottery system is temporarily unavailable. Please try again soon.', systemUnavailable: '❌ The lottery system is temporarily unavailable. Please try again soon.',
@@ -18,8 +18,19 @@ export const messages = {
ticketNotFound: '❌ Ticket not found.', ticketNotFound: '❌ Ticket not found.',
fetchTicketDetailsFailed: '❌ Failed to fetch ticket details.', fetchTicketDetailsFailed: '❌ Failed to fetch ticket details.',
checkStatusFailed: '❌ Failed to check status', 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.', 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:`, 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) => addressSet: (address: string) =>
`✅ Your payout address is set to: \`${address}\` `✅ 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:`, 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. noAddressSet: `⚡ You don't have a Lightning Address set yet.
Send me your Lightning Address now: Send me your Lightning Address now:
*Example:* \`yourname@getalby.com\``, *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. invalidFormat: `❌ That doesn't look like a valid Lightning Address.
*Format:* \`username@domain.com\` *Format:* \`username@domain.com\`
@@ -73,6 +127,31 @@ Send me your Lightning Address now:
Please try again:`, 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) => firstTimeSuccess: (address: string) =>
`✅ *Perfect!* I'll use \`${address}\` to send any winnings. `✅ *Perfect!* I'll use \`${address}\` to send any winnings.
@@ -153,16 +232,14 @@ Confirm this purchase?`,
invoiceCaption: ( invoiceCaption: (
ticketCount: number, ticketCount: number,
totalAmount: string, totalAmount: string,
paymentRequest: string,
expiryMinutes: number expiryMinutes: number
) => ) =>
`🎟 *${ticketCount} ticket${ticketCount > 1 ? 's' : ''}* `🎟 *${ticketCount} ticket${ticketCount > 1 ? 's' : ''}*
💰 *Amount:* ${totalAmount} sats 💰 *Amount:* ${totalAmount} sats
⏳ Expires in ${expiryMinutes} minutes`,
\`${paymentRequest}\` invoiceString: (paymentRequest: string) =>
`\`${paymentRequest}\``,
⏳ This invoice expires in ${expiryMinutes} minutes.
I'll notify you when payment is received!`,
paymentReceived: (ticketNumbers: string, drawTime: string) => paymentReceived: (ticketNumbers: string, drawTime: string) =>
`🎉 *Payment Received!* `🎉 *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. No payment was received in time. No tickets were issued.
Use /buy to try again.`, Use /buyticket to try again.`,
invoiceExpiredShort: `❌ *Invoice Expired* invoiceExpiredShort: `❌ *Invoice Expired*
This invoice has expired. No tickets were issued. This invoice has expired. No tickets were issued.
Use /buy to try again.`, Use /buyticket to try again.`,
jackpotUnavailable: '❌ Jackpot is no longer available.', jackpotUnavailable: '❌ Jackpot is no longer available.',
}, },
@@ -201,13 +278,13 @@ Use /buy to try again.`,
You haven't purchased any tickets yet! You haven't purchased any tickets yet!
Use /buy to get started! 🎟`, Use /buyticket to get started! 🎟`,
notFound: `🧾 *Your Tickets* notFound: `🧾 *Your Tickets*
No ticket purchases found. Purchase history may have expired. 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:`, tapForDetails: `\nTap a ticket below for details:`,
@@ -266,13 +343,13 @@ ${statusSection}`,
You haven't purchased any tickets yet, so no wins to show! 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* noWinsYet: `🏆 *Your Wins*
You haven't won any jackpots yet. Keep playing! 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) => header: (totalWinnings: string, paidWinnings: string) =>
`🏆 *Your Wins* `🏆 *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! 5⃣ If you win, sats are sent to your address instantly!
*Commands:* *Commands:*
• /buy — Buy lottery tickets • /buyticket — Buy lottery tickets
• /tickets — View your tickets • /tickets — View your tickets
• /wins — View your past wins • /wins — View your past wins
• /address — Update Lightning Address • /lottoaddress — Update Lightning Address
• /menu — Show main menu • /lottomenu — Show main menu
• /help — Show this help • /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:* *Tips:*
🎟 Each ticket is one chance to win 🎟 Each ticket is one chance to win
💰 Prize pool grows with each ticket sold 💰 Prize pool grows with each ticket sold
⚡ Winnings are paid instantly via Lightning ⚡ 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! 🍀`, 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, winnerName: string,
winningTicket: string, winningTicket: string,
prizeSats: string, prizeSats: string,
totalTickets: number totalTickets: number,
winnerAddress?: string
) => ) =>
`🎰 *JACKPOT DRAW COMPLETE!* 🎰 `🎰 *JACKPOT DRAW COMPLETE!* 🎰
🏆 *Winner:* ${winnerName} 🏆 *Winner:* ${winnerName}
⚡ *Address:* \`${winnerAddress || 'N/A'}\`
🎟 *Winning Ticket:* #${winningTicket} 🎟 *Winning Ticket:* #${winningTicket}
💰 *Prize:* ${prizeSats} sats 💰 *Prize:* ${prizeSats} sats
📊 *Total Tickets:* ${totalTickets} 📊 *Total Tickets:* ${totalTickets}
Congratulations to the winner! ⚡ 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) => drawCompleted: (potSats: number, hasWinner: boolean) =>
`⏰ *Draw Reminder!* 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 Use /buyticket to enter the next round! 🍀`
🕐 *Draw Time:* ${drawTime} : `🎰 *JACKPOT DRAW COMPLETE!* 🎰
⏳ *Time Left:* ${timeLeft}
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! I can announce lottery draws and remind you when jackpots are coming up!
*Group Admin Commands:* *Group Admin Commands:*
• /settings — Configure bot settings for this group • /lottosettings — Configure bot settings for this group
*User Commands:* *User Commands:*
• /buy — Buy lottery tickets (in DM) • /buyticket — Buy lottery tickets
• /jackpot — View current jackpot info • /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.', adminOnly: '⚠️ Only group administrators can change these settings.',
@@ -415,19 +608,65 @@ To buy tickets, message me directly @LightningLottoBot! 🎟`,
enabled: boolean; enabled: boolean;
drawAnnouncements: boolean; drawAnnouncements: boolean;
reminders: boolean; reminders: boolean;
newJackpotAnnouncement?: boolean;
ticketPurchaseAllowed: boolean; ticketPurchaseAllowed: boolean;
}) => reminder1Enabled?: boolean;
`⚙️ *Group Settings* 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} 📍 *Group:* ${settings.groupTitle}
*Current Configuration:* *Current Configuration:*
${settings.enabled ? '✅' : '❌'} Bot Enabled ${settings.enabled ? '✅' : '❌'} Bot Enabled
${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements ${newJackpot ? '✅' : '❌'} New Jackpot Announcements ${newJackpot ? `_(${formatNewJackpotDelay})_` : ''}
${settings.reminders ? '✅' : '❌'} Draw Reminders ${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements ${settings.drawAnnouncements ? `_(${formatAnnounce})_` : ''}
${settings.reminders ? '✅' : '❌'} Draw Reminders ${settings.reminders ? `_(${formatReminderList})_` : ''}
${settings.ticketPurchaseAllowed ? '✅' : '❌'} Ticket Purchases in Group ${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) => settingUpdated: (setting: string, enabled: boolean) =>
`✅ *${setting}* has been ${enabled ? 'enabled' : 'disabled'}.`, `✅ *${setting}* has been ${enabled ? 'enabled' : 'disabled'}.`,

View File

@@ -70,7 +70,7 @@ class ApiClient {
async buyTickets( async buyTickets(
tickets: number, tickets: number,
lightningAddress: string, lightningAddress: string,
telegramUserId: number displayName: string = 'Anon'
): Promise<BuyTicketsResponse> { ): Promise<BuyTicketsResponse> {
try { try {
const response = await this.client.post<ApiResponse<BuyTicketsResponse>>( const response = await this.client.post<ApiResponse<BuyTicketsResponse>>(
@@ -78,7 +78,7 @@ class ApiClient {
{ {
tickets, tickets,
lightning_address: lightningAddress, lightning_address: lightningAddress,
buyer_name: `TG:${telegramUserId}`, buyer_name: displayName || 'Anon',
} }
); );
return response.data.data; return response.data.data;
@@ -138,6 +138,51 @@ class ApiClient {
return false; 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 const apiClient = new 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,225 +1,223 @@
import Redis from 'ioredis'; import { botDatabase } from './database';
import config from '../config';
import { logger } from './logger'; import { logger } from './logger';
import { GroupSettings, DEFAULT_GROUP_SETTINGS } from '../types/groups'; import { GroupSettings, ReminderTime, reminderTimeToMinutes } from '../types/groups';
const GROUP_PREFIX = 'tg_group:';
const GROUPS_LIST_KEY = 'tg_groups_list';
const STATE_TTL = 60 * 60 * 24 * 365; // 1 year
class GroupStateManager { class GroupStateManager {
private redis: Redis | null = null; async init(): Promise<void> {
private memoryStore: Map<string, string> = new Map(); // Database is initialized separately
private useRedis: boolean = false; logger.info('Group state manager initialized (using SQLite database)');
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) : [];
} }
/** /**
* Get group settings * Get group settings
*/ */
async getGroup(groupId: number): Promise<GroupSettings | null> { async getGroup(groupId: number): Promise<GroupSettings | null> {
const key = `${GROUP_PREFIX}${groupId}`; return botDatabase.getGroup(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;
}
} }
/** /**
* Create or update group settings * Register a new group
*/
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
*/ */
async registerGroup( async registerGroup(
groupId: number, groupId: number,
groupTitle: string, groupTitle: string,
addedBy: number addedBy: number
): Promise<GroupSettings> { ): Promise<GroupSettings> {
const existing = await this.getGroup(groupId); return botDatabase.registerGroup(groupId, groupTitle, addedBy);
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;
} }
/** /**
* Remove group when bot is removed * Remove a group
*/ */
async removeGroup(groupId: number): Promise<void> { async removeGroup(groupId: number): Promise<void> {
const key = `${GROUP_PREFIX}${groupId}`; botDatabase.removeGroup(groupId);
await this.del(key);
await this.srem(GROUPS_LIST_KEY, groupId.toString());
logger.info('Group removed', { 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( async updateSetting(
groupId: number, groupId: number,
setting: keyof Pick<GroupSettings, 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed'>, setting:
| 'enabled'
| 'drawAnnouncements'
| 'reminders'
| 'newJackpotAnnouncement'
| 'ticketPurchaseAllowed'
| 'reminder1Enabled'
| 'reminder2Enabled'
| 'reminder3Enabled',
value: boolean value: boolean
): Promise<GroupSettings | null> { ): Promise<GroupSettings | null> {
const settings = await this.getGroup(groupId); return botDatabase.updateGroupSetting(groupId, setting, value);
if (!settings) return null;
settings[setting] = value;
await this.saveGroup(settings);
return settings;
} }
/** /**
* 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( async getGroupsWithFeature(
feature: 'enabled' | 'drawAnnouncements' | 'reminders' feature: 'enabled' | 'drawAnnouncements' | 'reminders' | 'newJackpotAnnouncement'
): Promise<GroupSettings[]> { ): Promise<GroupSettings[]> {
const groupIds = await this.smembers(GROUPS_LIST_KEY); if (feature === 'newJackpotAnnouncement') {
const groups: GroupSettings[] = []; const allGroups = await this.getAllGroups();
return allGroups.filter(g => g.enabled && g.newJackpotAnnouncement);
for (const id of groupIds) {
const settings = await this.getGroup(parseInt(id, 10));
if (settings && settings.enabled && settings[feature]) {
groups.push(settings);
}
} }
return botDatabase.getGroupsWithFeature(feature as 'enabled' | 'drawAnnouncements' | 'reminders');
return groups;
} }
/** /**
* Get all registered groups * Get all groups
*/ */
async getAllGroups(): Promise<GroupSettings[]> { async getAllGroups(): Promise<GroupSettings[]> {
const groupIds = await this.smembers(GROUPS_LIST_KEY); return botDatabase.getAllGroups();
const groups: GroupSettings[] = []; }
for (const id of groupIds) { /**
const settings = await this.getGroup(parseInt(id, 10)); * Get groups that need reminders for a specific draw time
if (settings) { */
groups.push(settings); 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 });
}
}
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 groups; return results;
} }
async close(): Promise<void> { /**
if (this.redis) { * Add time to a reminder
await this.redis.quit(); */
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> {
// Database close is handled separately
logger.info('Group state manager closed');
} }
} }
export const groupStateManager = new GroupStateManager(); export const groupStateManager = new GroupStateManager();
export default groupStateManager; export default groupStateManager;

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

@@ -1,130 +1,31 @@
import Redis from 'ioredis'; import { botDatabase } from './database';
import config from '../config';
import { logger } from './logger'; import { logger } from './logger';
import { import {
TelegramUser, TelegramUser,
UserState, UserState,
AwaitingPaymentData, AwaitingPaymentData,
NotificationPreferences,
DEFAULT_NOTIFICATIONS,
} from '../types'; } 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 { class StateManager {
private redis: Redis | null = null;
private memoryStore: Map<string, string> = new Map();
private useRedis: boolean = false;
async init(): Promise<void> { async init(): Promise<void> {
if (config.redis.url) { // Database is initialized separately
try { logger.info('State manager initialized (using SQLite database)');
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);
} }
/** /**
* Get or create user * Get user by Telegram ID
*/ */
async getUser(telegramId: number): Promise<TelegramUser | null> { async getUser(telegramId: number): Promise<TelegramUser | null> {
const key = `${STATE_PREFIX}${telegramId}`; return botDatabase.getUser(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;
}
} }
/** /**
* Create or update user * Save user
*/ */
async saveUser(user: TelegramUser): Promise<void> { async saveUser(user: TelegramUser): Promise<void> {
const key = `${STATE_PREFIX}${user.telegramId}`; botDatabase.saveUser(user);
user.updatedAt = new Date();
await this.set(key, JSON.stringify(user), STATE_TTL);
logger.debug('User saved', { telegramId: user.telegramId, state: user.state }); logger.debug('User saved', { telegramId: user.telegramId, state: user.state });
} }
@@ -137,18 +38,7 @@ class StateManager {
firstName?: string, firstName?: string,
lastName?: string lastName?: string
): Promise<TelegramUser> { ): Promise<TelegramUser> {
const user: TelegramUser = { return botDatabase.createUser(telegramId, username, firstName, lastName);
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;
} }
/** /**
@@ -159,14 +49,7 @@ class StateManager {
state: UserState, state: UserState,
stateData?: Record<string, any> stateData?: Record<string, any>
): Promise<void> { ): Promise<void> {
const user = await this.getUser(telegramId); botDatabase.updateUserState(telegramId, state, stateData);
if (!user) {
logger.warn('Attempted to update state for non-existent user', { telegramId });
return;
}
user.state = state;
user.stateData = stateData;
await this.saveUser(user);
} }
/** /**
@@ -176,15 +59,25 @@ class StateManager {
telegramId: number, telegramId: number,
lightningAddress: string lightningAddress: string
): Promise<void> { ): Promise<void> {
const user = await this.getUser(telegramId); botDatabase.updateLightningAddress(telegramId, lightningAddress);
if (!user) { }
logger.warn('Attempted to update address for non-existent user', { telegramId });
return; /**
} * Update user display name
user.lightningAddress = lightningAddress; */
user.state = 'idle'; async updateDisplayName(telegramId: number, displayName: string): Promise<void> {
user.stateData = undefined; botDatabase.updateDisplayName(telegramId, displayName);
await this.saveUser(user); 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, purchaseId: string,
data: AwaitingPaymentData data: AwaitingPaymentData
): Promise<void> { ): Promise<void> {
// Store purchase data botDatabase.storePurchase(telegramId, purchaseId, {
const purchaseKey = `${PURCHASE_PREFIX}${purchaseId}`; cycleId: data.cycleId,
await this.set(purchaseKey, JSON.stringify({ ticketCount: data.ticketCount,
telegramId, totalAmount: data.totalAmount,
...data, lightningAddress: data.paymentRequest ? '' : '', // Not storing sensitive data
createdAt: new Date().toISOString(), paymentRequest: data.paymentRequest,
}), STATE_TTL); publicUrl: data.publicUrl,
});
// Add to user's purchase list
const userPurchasesKey = `${USER_PURCHASES_PREFIX}${telegramId}`;
await this.lpush(userPurchasesKey, purchaseId);
} }
/** /**
* Get purchase data * Get purchase data
*/ */
async getPurchase(purchaseId: string): Promise<(AwaitingPaymentData & { telegramId: number }) | null> { async getPurchase(purchaseId: string): Promise<(AwaitingPaymentData & { telegramId: number }) | null> {
const key = `${PURCHASE_PREFIX}${purchaseId}`; const purchase = botDatabase.getPurchase(purchaseId);
const data = await this.get(key); if (!purchase) return null;
if (!data) return null;
try { return {
return JSON.parse(data); telegramId: purchase.telegram_id,
} catch (error) { cycleId: purchase.cycle_id,
logger.error('Failed to parse purchase data', { purchaseId, error }); ticketCount: purchase.ticket_count,
return null; 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,33 +127,70 @@ class StateManager {
telegramId: number, telegramId: number,
limit: number = 10 limit: number = 10
): Promise<string[]> { ): Promise<string[]> {
const key = `${USER_PURCHASES_PREFIX}${telegramId}`; return botDatabase.getUserPurchaseIds(telegramId, limit);
return await this.lrange(key, 0, limit - 1);
} }
/** /**
* Clear user state data (keeping lightning address) * Clear user state data (keeping lightning address)
*/ */
async clearUserStateData(telegramId: number): Promise<void> { async clearUserStateData(telegramId: number): Promise<void> {
const user = await this.getUser(telegramId); botDatabase.updateUserState(telegramId, 'idle', undefined);
if (!user) return; }
user.state = 'idle';
user.stateData = undefined; /**
await this.saveUser(user); * 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 * Shutdown
*/ */
async close(): Promise<void> { async close(): Promise<void> {
if (this.redis) { // Database close is handled separately
await this.redis.quit(); logger.info('State manager closed');
logger.info('Redis connection closed');
}
} }
} }
export const stateManager = new StateManager(); export const stateManager = new StateManager();
export default 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 * Group settings for lottery features
*/ */
@@ -7,12 +15,81 @@ export interface GroupSettings {
enabled: boolean; enabled: boolean;
drawAnnouncements: boolean; drawAnnouncements: boolean;
reminders: boolean; reminders: boolean;
newJackpotAnnouncement: boolean; // Announce when a new jackpot starts
ticketPurchaseAllowed: boolean; 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; addedBy: number;
addedAt: Date; addedAt: Date;
updatedAt: 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 * Default group settings
*/ */
@@ -20,7 +97,17 @@ export const DEFAULT_GROUP_SETTINGS: Omit<GroupSettings, 'groupId' | 'groupTitle
enabled: true, enabled: true,
drawAnnouncements: true, drawAnnouncements: true,
reminders: true, reminders: true,
newJackpotAnnouncement: true,
ticketPurchaseAllowed: false, // Disabled by default for privacy - users should buy in DM 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_lightning_address'
| 'awaiting_ticket_amount' | 'awaiting_ticket_amount'
| 'awaiting_invoice_payment' | '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 // Telegram user data stored in state
export interface TelegramUser { export interface TelegramUser {
@@ -12,13 +20,22 @@ export interface TelegramUser {
username?: string; username?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
displayName?: string; // Custom display name for announcements (default: "Anon")
lightningAddress?: string; lightningAddress?: string;
state: UserState; state: UserState;
stateData?: Record<string, any>; stateData?: Record<string, any>;
notifications: NotificationPreferences;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
// Default notification preferences
export const DEFAULT_NOTIFICATIONS: NotificationPreferences = {
drawReminders: true,
drawResults: true,
newJackpotAlerts: true,
};
// API Response Types // API Response Types
export interface ApiResponse<T> { export interface ApiResponse<T> {
version: string; version: string;
@@ -131,6 +148,9 @@ export interface AwaitingPaymentData extends PendingPurchaseData {
paymentRequest: string; paymentRequest: string;
publicUrl: string; publicUrl: string;
pollStartTime: number; pollStartTime: number;
headerMessageId?: number;
qrMessageId?: number;
invoiceMessageId?: number;
} }
// Re-export group types // 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 { export function isValidLightningAddress(address: string): boolean {
// Basic format: something@something.something // Basic format: something@something.something
@@ -54,6 +54,62 @@ export function isValidLightningAddress(address: string): boolean {
return regex.test(address); 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 * Escape markdown special characters for Telegram MarkdownV2
*/ */
@@ -69,4 +125,20 @@ export function truncate(str: string, maxLength: number): string {
return str.substring(0, maxLength - 3) + '...'; 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

@@ -2,6 +2,7 @@ import TelegramBot, {
InlineKeyboardMarkup, InlineKeyboardMarkup,
ReplyKeyboardMarkup, ReplyKeyboardMarkup,
} from 'node-telegram-bot-api'; } from 'node-telegram-bot-api';
import { NotificationPreferences } from '../types';
/** /**
* Main menu reply keyboard * Main menu reply keyboard
@@ -9,9 +10,10 @@ import TelegramBot, {
export function getMainMenuKeyboard(): ReplyKeyboardMarkup { export function getMainMenuKeyboard(): ReplyKeyboardMarkup {
return { return {
keyboard: [ keyboard: [
[{ text: '🎰 Upcoming Jackpot' }],
[{ text: '🎟 Buy Tickets' }, { text: '🧾 My Tickets' }], [{ text: '🎟 Buy Tickets' }, { text: '🧾 My Tickets' }],
[{ text: '🏆 My Wins' }, { text: '⚡ Lightning Address' }], [{ text: '🏆 My Wins' }, { text: '⚡ Lightning Address' }],
[{ text: ' Help' }], [{ text: '⚙️ Settings' }, { text: ' Help' }],
], ],
resize_keyboard: true, resize_keyboard: true,
one_time_keyboard: false, one_time_keyboard: false,
@@ -68,15 +70,13 @@ function isValidTelegramUrl(url: string): boolean {
} }
/** /**
* View ticket status button * View ticket status button (after payment confirmed)
*/ */
export function getViewTicketKeyboard( export function getViewTicketKeyboard(
purchaseId: string, purchaseId: string,
publicUrl?: string publicUrl?: string
): InlineKeyboardMarkup { ): InlineKeyboardMarkup {
const buttons: TelegramBot.InlineKeyboardButton[][] = [ const buttons: TelegramBot.InlineKeyboardButton[][] = [];
[{ text: '🔄 Check Status', callback_data: `status_${purchaseId}` }],
];
// Only add URL button if it's a valid Telegram URL (HTTPS, not localhost) // Only add URL button if it's a valid Telegram URL (HTTPS, not localhost)
if (publicUrl && isValidTelegramUrl(publicUrl)) { if (publicUrl && isValidTelegramUrl(publicUrl)) {
@@ -143,3 +143,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',
}],
],
};
}