Compare commits
13 Commits
2fea2dc836
...
backup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83298dc4ca | ||
|
|
1dce27ea42 | ||
|
|
3bc067f691 | ||
|
|
00f09236a3 | ||
|
|
959268e7c1 | ||
|
|
fb378d56c5 | ||
|
|
fcd180b7a4 | ||
|
|
404fdf2610 | ||
|
|
0eb8a6c580 | ||
|
|
d1ede9ee8d | ||
|
|
86e2e0a321 | ||
|
|
13fd2b8989 | ||
|
|
dd6b26c524 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -88,3 +88,5 @@ ehthumbs.db
|
||||
*.key
|
||||
secrets/
|
||||
|
||||
# Setup/deployment configs (contains server-specific settings)
|
||||
setup/
|
||||
|
||||
162
README.md
162
README.md
@@ -1,4 +1,4 @@
|
||||
# ⚡ Lightning Lottery
|
||||
# ⚡ Lightning Lotto
|
||||
|
||||
A complete Bitcoin Lightning Network powered lottery system with instant payouts to Lightning Addresses.
|
||||
|
||||
@@ -10,24 +10,27 @@ A complete Bitcoin Lightning Network powered lottery system with instant payouts
|
||||
- 🌐 **Anonymous or Nostr**: Buy tickets anonymously or login with Nostr
|
||||
- 📱 **Mobile First**: Beautiful responsive design optimized for all devices
|
||||
- 🏆 **Automatic Payouts**: Winners get paid automatically to their Lightning Address
|
||||
- ⏰ **Multiple Cycles**: Support for hourly, daily, weekly, and monthly draws
|
||||
- ⏰ **Flexible Cycles**: Configurable draw intervals (minutes, hours, days, weekly, or cron)
|
||||
- 🔄 **Auto-Redraw**: If payout fails, automatically selects a new winner
|
||||
- 🎰 **Random Tickets**: Ticket numbers are randomly generated, not sequential
|
||||
- 📊 **Swagger Docs**: Full API documentation at `/api-docs`
|
||||
- 🤖 **Telegram Bot**: Full-featured bot for buying tickets and receiving notifications
|
||||
|
||||
## Architecture
|
||||
|
||||
The system consists of three main components:
|
||||
The system consists of four main components:
|
||||
|
||||
1. **Backend API** (Node.js + TypeScript + Express)
|
||||
2. **Frontend** (Next.js + React + TypeScript + TailwindCSS)
|
||||
3. **Database** (PostgreSQL or SQLite)
|
||||
3. **Telegram Bot** (Node.js + TypeScript + node-telegram-bot-api)
|
||||
4. **Database** (PostgreSQL or SQLite)
|
||||
|
||||
### Backend
|
||||
|
||||
- RESTful API with comprehensive endpoints
|
||||
- LNbits integration for Lightning payments
|
||||
- Automated scheduler for draws and cycle generation
|
||||
- Configurable cycle types (minutes, hours, days, weekly, cron)
|
||||
- JWT authentication for Nostr users
|
||||
- Admin API for manual operations
|
||||
- Payment monitoring with polling fallback
|
||||
@@ -36,13 +39,23 @@ The system consists of three main components:
|
||||
### Frontend
|
||||
|
||||
- Server-side rendered Next.js application
|
||||
- Redux state management
|
||||
- Real-time countdown timers
|
||||
- Invoice QR code display with payment animations
|
||||
- Automatic status polling
|
||||
- Nostr NIP-07 authentication
|
||||
- Draw animation with winner reveal
|
||||
- Past winners display
|
||||
- Fully responsive mobile-first design
|
||||
|
||||
### Telegram Bot
|
||||
|
||||
- Buy tickets directly from Telegram
|
||||
- Lightning Address management (with 21Tipbot & Bittip integration)
|
||||
- View tickets and wins
|
||||
- Notification preferences (draw reminders, results, new jackpot alerts)
|
||||
- Group support with admin controls
|
||||
- Customizable reminders (up to 3 reminder slots per group)
|
||||
- SQLite database for persistent user/group settings
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -58,7 +71,8 @@ cd LightningLotto
|
||||
```bash
|
||||
cp back_end/env.example back_end/.env
|
||||
cp front_end/env.example front_end/.env.local
|
||||
# Edit both files with your configuration
|
||||
cp telegram_bot/env.example telegram_bot/.env
|
||||
# Edit all files with your configuration
|
||||
```
|
||||
|
||||
3. Start services:
|
||||
@@ -113,6 +127,22 @@ npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
#### Telegram Bot
|
||||
|
||||
```bash
|
||||
cd telegram_bot
|
||||
npm install
|
||||
cp env.example .env
|
||||
# Edit .env with your bot token and API URL
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
|
||||
# Or build and run production
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
@@ -138,6 +168,15 @@ ADMIN_API_KEY=your-admin-api-key
|
||||
DEFAULT_TICKET_PRICE_SATS=1000
|
||||
DEFAULT_HOUSE_FEE_PERCENT=5
|
||||
PAYOUT_MAX_ATTEMPTS_BEFORE_REDRAW=2
|
||||
|
||||
# Cycle Configuration (choose one type)
|
||||
CYCLE_TYPE=hours # minutes | hours | days | weekly | cron
|
||||
CYCLE_INTERVAL_MINUTES=30 # For 'minutes' type
|
||||
CYCLE_INTERVAL_HOURS=1 # For 'hours' type
|
||||
CYCLE_DAILY_TIME=20:00 # For 'days' type (HH:MM UTC)
|
||||
CYCLE_WEEKLY_DAY=saturday # For 'weekly' type
|
||||
CYCLE_WEEKLY_TIME=20:00 # For 'weekly' type (HH:MM UTC)
|
||||
CYCLE_CRON_EXPRESSION=0 */6 * * * # For 'cron' type
|
||||
```
|
||||
|
||||
#### Frontend (.env.local)
|
||||
@@ -146,7 +185,16 @@ PAYOUT_MAX_ATTEMPTS_BEFORE_REDRAW=2
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
See `back_end/env.example` and `front_end/env.example` for all configuration options.
|
||||
#### Telegram Bot (.env)
|
||||
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||
API_BASE_URL=http://localhost:3000
|
||||
FRONTEND_BASE_URL=http://localhost:3001
|
||||
# BOT_DATABASE_PATH=./data/bot.db # Optional, defaults to ./data/bot.db
|
||||
```
|
||||
|
||||
See `back_end/env.example`, `front_end/env.example`, and `telegram_bot/env.example` for all configuration options.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
@@ -186,9 +234,39 @@ See `back_end/env.example` and `front_end/env.example` for all configuration opt
|
||||
|
||||
Full API documentation available at `/api-docs` (Swagger UI).
|
||||
|
||||
## Telegram Bot Commands
|
||||
|
||||
### Private Chat Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/start` | Start the bot and register |
|
||||
| `/lottomenu` | Show main menu |
|
||||
| `/buyticket` | Buy lottery tickets |
|
||||
| `/tickets` | View your tickets |
|
||||
| `/wins` | View your wins |
|
||||
| `/lottoaddress` | Set your Lightning Address |
|
||||
| `/lottosettings` | Notification settings |
|
||||
| `/lottohelp` | Show help |
|
||||
|
||||
### Group Commands (Admin Only)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/lottosettings` | Configure group lottery settings |
|
||||
| `/jackpot` | Show current jackpot info |
|
||||
|
||||
### Group Features
|
||||
|
||||
- Enable/disable draw announcements
|
||||
- Enable/disable new jackpot alerts
|
||||
- Up to 3 customizable draw reminders
|
||||
- Adjustable reminder times (minutes/hours/days)
|
||||
- Auto-deleting settings messages (2 min TTL)
|
||||
|
||||
## Database Schema
|
||||
|
||||
7 main tables:
|
||||
### Backend Database (7 main tables)
|
||||
|
||||
- `lotteries` - Lottery configuration
|
||||
- `jackpot_cycles` - Draw cycles with status and winners
|
||||
@@ -198,15 +276,23 @@ Full API documentation available at `/api-docs` (Swagger UI).
|
||||
- `users` - Nostr user accounts (optional)
|
||||
- `draw_logs` - Audit trail for transparency
|
||||
|
||||
### Telegram Bot Database (SQLite)
|
||||
|
||||
- `users` - Telegram user profiles and notification preferences
|
||||
- `user_purchases` - Tracks purchases per user
|
||||
- `cycle_participants` - Tracks participants per cycle
|
||||
- `groups` - Group settings and reminder configurations
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Cycle Generation**: Scheduler automatically creates future draw cycles
|
||||
2. **Ticket Purchase**: Users buy tickets, receive Lightning invoice
|
||||
2. **Ticket Purchase**: Users buy tickets via web or Telegram, receive Lightning invoice
|
||||
3. **Payment Processing**: LNbits webhook or polling confirms payment
|
||||
4. **Ticket Issuance**: Random ticket numbers assigned in database transaction
|
||||
5. **Draw Execution**: At scheduled time, winner selected using CSPRNG
|
||||
6. **Payout**: Winner's Lightning Address paid automatically
|
||||
7. **Retry/Redraw**: Failed payouts retried; new winner drawn after max attempts
|
||||
6. **Notifications**: Telegram bot sends draw results to participants and groups
|
||||
7. **Payout**: Winner's Lightning Address paid automatically
|
||||
8. **Retry/Redraw**: Failed payouts retried; new winner drawn after max attempts
|
||||
|
||||
## Security Features
|
||||
|
||||
@@ -219,6 +305,7 @@ Full API documentation available at `/api-docs` (Swagger UI).
|
||||
- `crypto.randomBytes()` for winner selection
|
||||
- `crypto.randomInt()` for ticket number generation
|
||||
- No floating-point math (BIGINT for all sats)
|
||||
- Lightning Address LNURL verification
|
||||
|
||||
## Frontend Pages
|
||||
|
||||
@@ -233,21 +320,18 @@ Full API documentation available at `/api-docs` (Swagger UI).
|
||||
| `/dashboard/tickets` | User's ticket history |
|
||||
| `/dashboard/wins` | User's win history |
|
||||
|
||||
## Testing
|
||||
## Production Deployment
|
||||
|
||||
### Backend Tests
|
||||
```bash
|
||||
cd back_end
|
||||
npm test
|
||||
```
|
||||
### Systemd Services & Nginx
|
||||
|
||||
### Frontend Tests
|
||||
```bash
|
||||
cd front_end
|
||||
npm test
|
||||
```
|
||||
The `setup/` folder contains production-ready configuration files:
|
||||
|
||||
## Deployment
|
||||
- `lightning-lotto-backend.service` - Backend systemd service
|
||||
- `lightning-lotto-frontend.service` - Frontend systemd service
|
||||
- `lightning-lotto-telegram.service` - Telegram bot systemd service
|
||||
- `nginx-lightning-lotto.conf` - Nginx reverse proxy with SSL
|
||||
|
||||
See `setup/README.md` for detailed deployment instructions.
|
||||
|
||||
### Production Considerations
|
||||
|
||||
@@ -298,11 +382,38 @@ LightningLotto/
|
||||
│ ├── components/ # React components
|
||||
│ ├── config/ # Frontend config
|
||||
│ ├── constants/ # Text strings
|
||||
│ ├── lib/ # API client and utilities
|
||||
│ └── store/ # Redux state
|
||||
│ └── lib/ # API client and utilities
|
||||
├── telegram_bot/
|
||||
│ ├── src/
|
||||
│ │ ├── config/ # Bot configuration
|
||||
│ │ ├── handlers/ # Command and callback handlers
|
||||
│ │ ├── messages/ # Centralized message strings
|
||||
│ │ ├── services/ # Database, API, notifications
|
||||
│ │ ├── types/ # TypeScript types
|
||||
│ │ └── utils/ # Keyboards, formatting
|
||||
│ └── data/ # SQLite database for bot
|
||||
├── setup/ # Production deployment configs
|
||||
│ ├── *.service # Systemd service files
|
||||
│ ├── nginx-*.conf # Nginx configuration
|
||||
│ └── README.md # Deployment guide
|
||||
├── App_info/ # Documentation
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend Tests
|
||||
```bash
|
||||
cd back_end
|
||||
npm test
|
||||
```
|
||||
|
||||
### Frontend Tests
|
||||
```bash
|
||||
cd front_end
|
||||
npm test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
@@ -311,6 +422,7 @@ MIT License - see LICENSE file for details
|
||||
|
||||
- Built with [LNbits](https://lnbits.com/) for Lightning Network integration
|
||||
- Uses [Nostr](https://nostr.com/) for decentralized authentication
|
||||
- Telegram bot integration for mobile-first experience
|
||||
- Inspired by the Bitcoin Lightning Network community
|
||||
|
||||
---
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,68 @@ import config from '../config';
|
||||
import { JackpotCycle, TicketPurchase, Ticket, Payout } from '../types';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
|
||||
/**
|
||||
* GET /status/maintenance
|
||||
* Public endpoint to check maintenance mode
|
||||
*/
|
||||
export async function getPublicMaintenanceStatus(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await db.query(
|
||||
`SELECT value FROM system_settings WHERE key = 'maintenance_mode'`
|
||||
);
|
||||
const pendingResult = await db.query(
|
||||
`SELECT value FROM system_settings WHERE key = 'maintenance_pending'`
|
||||
);
|
||||
const messageResult = await db.query(
|
||||
`SELECT value FROM system_settings WHERE key = 'maintenance_message'`
|
||||
);
|
||||
|
||||
const enabled = result.rows[0]?.value === 'true';
|
||||
const pending = pendingResult.rows[0]?.value === 'true';
|
||||
const message = messageResult.rows[0]?.value || 'System is under maintenance. Please try again later.';
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
maintenance_mode: enabled,
|
||||
maintenance_pending: pending,
|
||||
message: enabled ? message : (pending ? 'Maintenance will begin after the current draw completes.' : null),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
// If table doesn't exist yet, return not in maintenance
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
maintenance_mode: false,
|
||||
maintenance_pending: false,
|
||||
message: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if system is in maintenance mode
|
||||
*/
|
||||
async function isMaintenanceMode(): Promise<{ enabled: boolean; message: string }> {
|
||||
try {
|
||||
const result = await db.query(
|
||||
`SELECT value FROM system_settings WHERE key = 'maintenance_mode'`
|
||||
);
|
||||
const messageResult = await db.query(
|
||||
`SELECT value FROM system_settings WHERE key = 'maintenance_message'`
|
||||
);
|
||||
|
||||
return {
|
||||
enabled: result.rows[0]?.value === 'true',
|
||||
message: messageResult.rows[0]?.value || 'System is under maintenance. Please try again later.',
|
||||
};
|
||||
} catch {
|
||||
return { enabled: false, message: '' };
|
||||
}
|
||||
}
|
||||
|
||||
const toIsoString = (value: any): string => {
|
||||
if (!value) {
|
||||
return new Date().toISOString();
|
||||
@@ -65,16 +127,28 @@ export async function getNextJackpot(req: Request, res: Response) {
|
||||
|
||||
const lottery = lotteryResult.rows[0];
|
||||
|
||||
// Get next cycle
|
||||
const cycleResult = await db.query<JackpotCycle>(
|
||||
// Get next cycle - first try to find one that hasn't drawn yet
|
||||
let cycleResult = await db.query<JackpotCycle>(
|
||||
`SELECT * FROM jackpot_cycles
|
||||
WHERE lottery_id = $1
|
||||
AND status IN ('scheduled', 'sales_open')
|
||||
AND status IN ('scheduled', 'sales_open', 'drawing')
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT 1`,
|
||||
[lottery.id]
|
||||
);
|
||||
|
||||
// If no active cycles, get the next upcoming one
|
||||
if (cycleResult.rows.length === 0) {
|
||||
cycleResult = await db.query<JackpotCycle>(
|
||||
`SELECT * FROM jackpot_cycles
|
||||
WHERE lottery_id = $1
|
||||
AND status = 'scheduled'
|
||||
AND scheduled_at > NOW()
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT 1`,
|
||||
[lottery.id]
|
||||
);
|
||||
}
|
||||
|
||||
if (cycleResult.rows.length === 0) {
|
||||
return res.status(503).json({
|
||||
@@ -128,6 +202,16 @@ export async function getNextJackpot(req: Request, res: Response) {
|
||||
*/
|
||||
export async function buyTickets(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
// Check maintenance mode first
|
||||
const maintenance = await isMaintenanceMode();
|
||||
if (maintenance.enabled) {
|
||||
return res.status(503).json({
|
||||
version: '1.0',
|
||||
error: 'MAINTENANCE_MODE',
|
||||
message: maintenance.message,
|
||||
});
|
||||
}
|
||||
|
||||
const { tickets, lightning_address, nostr_pubkey, name, buyer_name } = req.body;
|
||||
const userId = req.user?.id || null;
|
||||
const authNostrPubkey = req.user?.nostr_pubkey || null;
|
||||
@@ -419,6 +503,23 @@ interface PastWinRow {
|
||||
pot_after_fee_sats: number | null;
|
||||
buyer_name: string | null;
|
||||
serial_number: number | null;
|
||||
winning_lightning_address: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate lightning address for privacy
|
||||
* "username@blink.sv" -> "us******@blink.sv"
|
||||
*/
|
||||
function truncateLightningAddress(address: string | null): string | null {
|
||||
if (!address || !address.includes('@')) return address;
|
||||
|
||||
const [username, domain] = address.split('@');
|
||||
|
||||
// Show first 2 chars of username, then asterisks
|
||||
const visibleChars = Math.min(2, username.length);
|
||||
const truncatedUsername = username.substring(0, visibleChars) + '******';
|
||||
|
||||
return `${truncatedUsername}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -439,6 +540,7 @@ export async function getPastWins(req: Request, res: Response) {
|
||||
jc.scheduled_at,
|
||||
jc.pot_total_sats,
|
||||
jc.pot_after_fee_sats,
|
||||
jc.winning_lightning_address,
|
||||
tp.buyer_name,
|
||||
t.serial_number
|
||||
FROM jackpot_cycles jc
|
||||
@@ -460,6 +562,7 @@ export async function getPastWins(req: Request, res: Response) {
|
||||
? parseInt(row.pot_after_fee_sats.toString())
|
||||
: null,
|
||||
winner_name: row.buyer_name || 'Anon',
|
||||
winner_address: truncateLightningAddress(row.winning_lightning_address),
|
||||
winning_ticket_serial: row.serial_number
|
||||
? parseInt(row.serial_number.toString())
|
||||
: null,
|
||||
|
||||
@@ -158,6 +158,17 @@ class DatabaseWrapper {
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Initialize default settings
|
||||
INSERT OR IGNORE INTO system_settings (key, value) VALUES ('maintenance_mode', 'false');
|
||||
INSERT OR IGNORE INTO system_settings (key, value) VALUES ('maintenance_pending', 'false');
|
||||
INSERT OR IGNORE INTO system_settings (key, value) VALUES ('maintenance_message', 'System is under maintenance. Please try again later.');
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_cycles_status_time ON jackpot_cycles(status, scheduled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_paymenthash ON ticket_purchases(lnbits_payment_hash);
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
listCycles,
|
||||
runDrawManually,
|
||||
retryPayout,
|
||||
listPayouts
|
||||
listPayouts,
|
||||
getMaintenanceStatus,
|
||||
setMaintenanceMode
|
||||
} from '../controllers/admin';
|
||||
import { verifyAdmin } from '../middleware/auth';
|
||||
|
||||
@@ -126,5 +128,78 @@ router.get('/payouts', listPayouts);
|
||||
*/
|
||||
router.post('/payouts/:id/retry', retryPayout);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/maintenance:
|
||||
* get:
|
||||
* summary: Get maintenance mode status
|
||||
* tags: [Admin]
|
||||
* security:
|
||||
* - adminKey: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Maintenance status
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* maintenance_mode:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* 403:
|
||||
* description: Invalid admin key
|
||||
*/
|
||||
router.get('/maintenance', getMaintenanceStatus);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/maintenance:
|
||||
* post:
|
||||
* summary: Enable or disable maintenance mode
|
||||
* description: When enabling, maintenance is set to "pending" and activates after current draw completes. Use immediate=true to activate immediately.
|
||||
* tags: [Admin]
|
||||
* security:
|
||||
* - adminKey: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - enabled
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* description: Whether to enable maintenance mode
|
||||
* message:
|
||||
* type: string
|
||||
* description: Custom maintenance message
|
||||
* immediate:
|
||||
* type: boolean
|
||||
* description: If true, activate immediately instead of waiting for draw to complete
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Maintenance mode updated
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* maintenance_mode:
|
||||
* type: boolean
|
||||
* maintenance_pending:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Invalid input
|
||||
* 403:
|
||||
* description: Invalid admin key
|
||||
*/
|
||||
router.post('/maintenance', setMaintenanceMode);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
getNextJackpot,
|
||||
buyTickets,
|
||||
getTicketStatus,
|
||||
getPastWins
|
||||
getPastWins,
|
||||
getPublicMaintenanceStatus
|
||||
} from '../controllers/public';
|
||||
import { buyRateLimiter, ticketStatusRateLimiter } from '../middleware/rateLimit';
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
@@ -187,5 +188,32 @@ router.get('/jackpot/past-wins', getPastWins);
|
||||
*/
|
||||
router.get('/tickets/:id', ticketStatusRateLimiter, getTicketStatus);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /status/maintenance:
|
||||
* get:
|
||||
* summary: Check if system is in maintenance mode
|
||||
* tags: [Public]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Maintenance status
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* version:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* maintenance_mode:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* nullable: true
|
||||
*/
|
||||
router.get('/status/maintenance', getPublicMaintenanceStatus);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -242,6 +242,10 @@ async function checkAndExecuteDraws(): Promise<void> {
|
||||
for (const cycle of result.rows) {
|
||||
console.log(`Executing draw for cycle ${cycle.id} (${cycle.cycle_type})`);
|
||||
await executeDraw(cycle.id);
|
||||
// Cancel unpaid invoices for this cycle after draw
|
||||
await cancelUnpaidPurchases(cycle.id);
|
||||
// Check if maintenance was pending and activate it
|
||||
await activatePendingMaintenance();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,8 @@ export default function BuyPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [jackpot, setJackpot] = useState<any>(null);
|
||||
const [maintenanceMode, setMaintenanceMode] = useState(false);
|
||||
const [maintenanceMessage, setMaintenanceMessage] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [lightningAddress, setLightningAddress] = useState('');
|
||||
@@ -125,6 +127,11 @@ export default function BuyPage() {
|
||||
|
||||
const loadJackpot = async () => {
|
||||
try {
|
||||
// Check maintenance status first
|
||||
const maintenanceStatus = await api.getMaintenanceStatus();
|
||||
setMaintenanceMode(maintenanceStatus.maintenance_mode);
|
||||
setMaintenanceMessage(maintenanceStatus.message);
|
||||
|
||||
const response = await api.getNextJackpot();
|
||||
if (response.data) {
|
||||
setJackpot(response.data);
|
||||
@@ -221,16 +228,40 @@ export default function BuyPage() {
|
||||
const ticketPriceSats = jackpot.lottery.ticket_price_sats;
|
||||
const totalCost = ticketPriceSats * tickets;
|
||||
|
||||
// Show maintenance message if in maintenance mode
|
||||
if (maintenanceMode) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white">
|
||||
<div className="max-w-2xl mx-auto px-1">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-6 sm:mb-8 text-center text-white">
|
||||
{STRINGS.buy.title}
|
||||
</h1>
|
||||
<div className="bg-gradient-to-r from-amber-900/60 via-amber-800/50 to-amber-900/60 border border-amber-500/50 rounded-xl p-6 sm:p-8 text-center">
|
||||
<span className="text-5xl mb-4 block">🔧</span>
|
||||
<h2 className="text-2xl font-bold text-amber-300 mb-3">Maintenance Mode</h2>
|
||||
<p className="text-amber-200/80 text-lg">
|
||||
{maintenanceMessage || 'System is under maintenance. Please try again later.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="mt-6 bg-amber-700 hover:bg-amber-600 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Back to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-1">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-6 sm:mb-8 text-center text-white">
|
||||
{STRINGS.buy.title}
|
||||
</h1>
|
||||
|
||||
{!invoice ? (
|
||||
/* Purchase Form */
|
||||
<div className="bg-gray-900 rounded-xl p-8 border border-gray-800">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="bg-gray-900 rounded-xl p-5 sm:p-8 border border-gray-800">
|
||||
<form onSubmit={handleSubmit} className="space-y-5 sm:space-y-6">
|
||||
{/* Lightning Address */}
|
||||
<div>
|
||||
<label className="block text-gray-300 mb-2 font-medium">
|
||||
@@ -321,7 +352,7 @@ export default function BuyPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-bitcoin-orange hover:bg-orange-600 disabled:bg-gray-600 text-white py-4 rounded-lg text-lg font-bold transition-colors"
|
||||
className="w-full bg-bitcoin-orange hover:bg-orange-600 active:bg-orange-700 disabled:bg-gray-600 text-white py-3.5 sm:py-4 rounded-lg text-base sm:text-lg font-bold transition-colors"
|
||||
>
|
||||
{loading ? 'Creating Invoice...' : STRINGS.buy.createInvoice}
|
||||
</button>
|
||||
|
||||
@@ -10,8 +10,17 @@
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: rgb(var(--background-rgb));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Prevent horizontal overflow on mobile */
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
/* Touch-friendly tap targets */
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
@@ -20,6 +29,39 @@ body {
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Mobile-friendly tap targets */
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar while maintaining functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Safe area padding for notched devices */
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-left {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
.safe-right {
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
@@ -33,7 +75,8 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
/* Custom scrollbar (desktop) */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
@@ -50,4 +93,50 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific styles */
|
||||
@media (max-width: 640px) {
|
||||
/* Larger touch targets on mobile */
|
||||
button,
|
||||
a,
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
[role="button"] {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Better input styling on mobile */
|
||||
input, textarea, select {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
/* Smoother scrolling on mobile */
|
||||
* {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent iOS bounce/pull-to-refresh on certain elements */
|
||||
.no-bounce {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Focus visible only for keyboard navigation */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
nav, footer, button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
@@ -7,9 +7,25 @@ import { Footer } from '@/components/Footer';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
userScalable: true,
|
||||
themeColor: '#0b0b0b',
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Lightning Lottery - Win Bitcoin',
|
||||
description: 'Bitcoin Lightning Network powered lottery with instant payouts',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'Lightning Lotto',
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -33,6 +33,20 @@ export default function HomePage() {
|
||||
const [isRecentWin, setIsRecentWin] = useState(false);
|
||||
const [awaitingNextCycle, setAwaitingNextCycle] = useState(false);
|
||||
const [pendingWinner, setPendingWinner] = useState<RecentWinner | null>(null);
|
||||
const [maintenanceMode, setMaintenanceMode] = useState(false);
|
||||
const [maintenancePending, setMaintenancePending] = useState(false);
|
||||
const [maintenanceMessage, setMaintenanceMessage] = useState<string | null>(null);
|
||||
|
||||
const loadMaintenanceStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await api.getMaintenanceStatus();
|
||||
setMaintenanceMode(status.maintenance_mode);
|
||||
setMaintenancePending(status.maintenance_pending);
|
||||
setMaintenanceMessage(status.message);
|
||||
} catch {
|
||||
// Ignore errors, assume not in maintenance
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadJackpot = useCallback(async () => {
|
||||
try {
|
||||
@@ -76,9 +90,10 @@ export default function HomePage() {
|
||||
}, [drawJustCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMaintenanceStatus();
|
||||
loadJackpot();
|
||||
loadRecentWinner();
|
||||
}, [loadJackpot, loadRecentWinner]);
|
||||
}, [loadMaintenanceStatus, loadJackpot, loadRecentWinner]);
|
||||
|
||||
// Detect when draw time passes and trigger draw animation (only if tickets were sold)
|
||||
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 */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-6xl font-bold mb-4 text-white">
|
||||
<div className="text-center mb-8 sm:mb-12">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-6xl font-bold mb-3 sm:mb-4 text-white">
|
||||
{STRINGS.app.title}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-400">{STRINGS.app.tagline}</p>
|
||||
<p className="text-lg sm:text-xl text-gray-400">{STRINGS.app.tagline}</p>
|
||||
</div>
|
||||
|
||||
{/* Recent Winner Banner - Only shown for 60 seconds after draw */}
|
||||
{showWinnerBanner && (
|
||||
<div className="bg-gradient-to-r from-yellow-900/40 via-yellow-800/30 to-yellow-900/40 border border-yellow-600/50 rounded-2xl p-6 mb-8 animate-fade-in relative">
|
||||
<div className="bg-gradient-to-r from-yellow-900/40 via-yellow-800/30 to-yellow-900/40 border border-yellow-600/50 rounded-xl sm:rounded-2xl p-4 sm:p-6 mb-6 sm:mb-8 animate-fade-in relative">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleDismissWinnerBanner}
|
||||
className="absolute top-3 right-3 text-yellow-400/60 hover:text-yellow-400 transition-colors p-1"
|
||||
className="absolute top-2 right-2 sm:top-3 sm:right-3 text-yellow-400/60 hover:text-yellow-400 transition-colors p-2"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -267,16 +312,16 @@ export default function HomePage() {
|
||||
</svg>
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<div className="text-yellow-400 text-sm uppercase tracking-wider mb-2">
|
||||
<div className="text-yellow-400 text-xs sm:text-sm uppercase tracking-wider mb-2">
|
||||
🏆 Latest Winner
|
||||
</div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white mb-2">
|
||||
<div className="text-xl sm:text-2xl md:text-3xl font-bold text-white mb-2">
|
||||
{recentWinner.winner_name || 'Anon'}
|
||||
</div>
|
||||
<div className="text-yellow-400 text-xl font-mono mb-3">
|
||||
<div className="text-yellow-400 text-lg sm:text-xl font-mono mb-2 sm:mb-3">
|
||||
Won {recentWinner.pot_after_fee_sats.toLocaleString()} sats
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm">
|
||||
<div className="text-gray-400 text-xs sm:text-sm">
|
||||
Ticket #{recentWinner.winning_ticket_serial.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,22 +329,22 @@ export default function HomePage() {
|
||||
)}
|
||||
|
||||
{/* Current Jackpot Card */}
|
||||
<div className="bg-gray-900 rounded-2xl p-8 md:p-12 mb-8 border border-gray-800">
|
||||
<h2 className="text-2xl font-semibold text-center mb-6 text-gray-300">
|
||||
<div className="bg-gray-900 rounded-xl sm:rounded-2xl p-5 sm:p-8 md:p-12 mb-6 sm:mb-8 border border-gray-800">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-center mb-4 sm:mb-6 text-gray-300">
|
||||
{STRINGS.home.currentJackpot}
|
||||
</h2>
|
||||
|
||||
{/* Pot Display */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<JackpotPotDisplay potTotalSats={jackpot.cycle.pot_total_sats} />
|
||||
</div>
|
||||
|
||||
{/* Countdown */}
|
||||
<div className="mb-8">
|
||||
<div className="text-center text-gray-400 mb-4">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="text-center text-gray-400 mb-3 sm:mb-4 text-sm sm:text-base">
|
||||
{STRINGS.home.drawIn}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div className="flex justify-center overflow-x-auto scrollbar-hide">
|
||||
<JackpotCountdown
|
||||
scheduledAt={jackpot.cycle.scheduled_at}
|
||||
drawCompleted={awaitingNextCycle || drawJustCompleted}
|
||||
@@ -308,16 +353,16 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
{/* Ticket Price */}
|
||||
<div className="text-center text-gray-400 mb-8">
|
||||
<div className="text-center text-gray-400 mb-6 sm:mb-8 text-sm sm:text-base">
|
||||
Ticket Price: {jackpot.lottery.ticket_price_sats.toLocaleString()} sats
|
||||
</div>
|
||||
|
||||
{/* Buy Button - Hide when waiting for next round */}
|
||||
{!isWaitingForNextRound && (
|
||||
{!isWaitingForNextRound && !maintenanceMode && (
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/buy"
|
||||
className="bg-bitcoin-orange hover:bg-orange-600 text-white px-12 py-4 rounded-lg text-xl font-bold transition-colors shadow-lg text-center"
|
||||
className="bg-bitcoin-orange hover:bg-orange-600 active:bg-orange-700 text-white px-8 sm:px-12 py-3 sm:py-4 rounded-lg text-lg sm:text-xl font-bold transition-colors shadow-lg text-center w-full sm:w-auto"
|
||||
>
|
||||
{STRINGS.home.buyTickets}
|
||||
</Link>
|
||||
@@ -326,8 +371,8 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
{/* Check Ticket Section */}
|
||||
<div className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||
<h3 className="text-xl font-semibold text-center mb-4 text-gray-300">
|
||||
<div className="bg-gray-900 rounded-xl sm:rounded-2xl p-5 sm:p-8 border border-gray-800">
|
||||
<h3 className="text-lg sm:text-xl font-semibold text-center mb-4 text-gray-300">
|
||||
{STRINGS.home.checkTicket}
|
||||
</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
@@ -336,12 +381,12 @@ export default function HomePage() {
|
||||
value={ticketId}
|
||||
onChange={(e) => setTicketId(e.target.value)}
|
||||
placeholder={STRINGS.home.ticketIdPlaceholder}
|
||||
className="flex-1 bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange"
|
||||
className="flex-1 bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange text-base"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCheckTicket()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCheckTicket}
|
||||
className="bg-gray-700 hover:bg-gray-600 text-white px-8 py-3 rounded-lg font-medium transition-colors"
|
||||
className="bg-gray-700 hover:bg-gray-600 active:bg-gray-500 text-white px-6 sm:px-8 py-3 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Check Status
|
||||
</button>
|
||||
@@ -349,25 +394,25 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-4xl mb-3">⚡</div>
|
||||
<h4 className="text-lg font-semibold mb-2 text-white">Instant</h4>
|
||||
<p className="text-gray-400 text-sm">
|
||||
<div className="mt-8 sm:mt-12 grid grid-cols-1 sm:grid-cols-3 gap-4 sm:gap-6">
|
||||
<div className="bg-gray-900 p-5 sm:p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-3xl sm:text-4xl mb-2 sm:mb-3">⚡</div>
|
||||
<h4 className="text-base sm:text-lg font-semibold mb-1 sm:mb-2 text-white">Instant</h4>
|
||||
<p className="text-gray-400 text-xs sm:text-sm">
|
||||
Lightning-fast ticket purchases and payouts
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-4xl mb-3">🔒</div>
|
||||
<h4 className="text-lg font-semibold mb-2 text-white">Secure</h4>
|
||||
<p className="text-gray-400 text-sm">
|
||||
<div className="bg-gray-900 p-5 sm:p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-3xl sm:text-4xl mb-2 sm:mb-3">🔒</div>
|
||||
<h4 className="text-base sm:text-lg font-semibold mb-1 sm:mb-2 text-white">Secure</h4>
|
||||
<p className="text-gray-400 text-xs sm:text-sm">
|
||||
Cryptographically secure random number generation
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-4xl mb-3">🎯</div>
|
||||
<h4 className="text-lg font-semibold mb-2 text-white">Fair</h4>
|
||||
<p className="text-gray-400 text-sm">
|
||||
<div className="bg-gray-900 p-5 sm:p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-3xl sm:text-4xl mb-2 sm:mb-3">🎯</div>
|
||||
<h4 className="text-base sm:text-lg font-semibold mb-1 sm:mb-2 text-white">Fair</h4>
|
||||
<p className="text-gray-400 text-xs sm:text-sm">
|
||||
Transparent draws with verifiable results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ interface PastWin {
|
||||
pot_total_sats: number;
|
||||
pot_after_fee_sats: number | null;
|
||||
winner_name: string;
|
||||
winner_address: string | null;
|
||||
winning_ticket_serial: number | null;
|
||||
}
|
||||
|
||||
@@ -87,13 +88,19 @@ export default function PastWinsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">{STRINGS.pastWins.winner}</div>
|
||||
<div className="text-white font-semibold">
|
||||
{win.winner_name || 'Anon'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">{STRINGS.pastWins.address}</div>
|
||||
<div className="text-white font-mono text-xs">
|
||||
{win.winner_address || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">{STRINGS.pastWins.ticket}</div>
|
||||
<div className="text-white">
|
||||
|
||||
@@ -96,30 +96,30 @@ export default function TicketStatusPage() {
|
||||
const { purchase, tickets, cycle, result } = data;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white">
|
||||
<div className="max-w-4xl mx-auto px-1">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-6 sm:mb-8 text-center text-white">
|
||||
{STRINGS.ticket.title}
|
||||
</h1>
|
||||
|
||||
{/* Save This Link */}
|
||||
<div className="bg-gradient-to-r from-blue-900/30 to-purple-900/30 border border-blue-700/50 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-3xl">🔖</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Save This Link!</h3>
|
||||
<p className="text-gray-300 text-sm mb-3">
|
||||
<div className="bg-gradient-to-r from-blue-900/30 to-purple-900/30 border border-blue-700/50 rounded-xl p-4 sm:p-6 mb-5 sm:mb-6">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="text-2xl sm:text-3xl">🔖</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-white mb-1 sm:mb-2">Save This Link!</h3>
|
||||
<p className="text-gray-300 text-xs sm:text-sm mb-3">
|
||||
Bookmark or save this page to check if you've won after the draw. This is your only way to view your ticket status.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row 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="flex flex-col gap-2">
|
||||
<div className="bg-gray-800/80 rounded-lg px-3 py-2 font-mono text-xs sm:text-sm text-gray-300 break-all overflow-x-auto">
|
||||
{ticketUrl || `/tickets/${ticketId}`}
|
||||
</div>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className={`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
|
||||
? '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 ? (
|
||||
@@ -144,11 +144,11 @@ export default function TicketStatusPage() {
|
||||
</div>
|
||||
|
||||
{/* Purchase Info */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-gray-900 rounded-xl p-4 sm:p-6 mb-5 sm:mb-6 border border-gray-800">
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 text-xs sm:text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">Purchase ID:</span>
|
||||
<div className="text-white font-mono break-all">{purchase.id}</div>
|
||||
<div className="text-white font-mono break-all text-xs sm:text-sm">{purchase.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Status:</span>
|
||||
@@ -167,15 +167,15 @@ export default function TicketStatusPage() {
|
||||
|
||||
{/* Payment Status */}
|
||||
{purchase.invoice_status === 'pending' && (
|
||||
<div className="bg-yellow-900/30 text-yellow-200 px-6 py-4 rounded-lg mb-6 text-center">
|
||||
<div className="bg-yellow-900/30 text-yellow-200 px-4 sm:px-6 py-3 sm:py-4 rounded-lg mb-5 sm:mb-6 text-center text-sm sm:text-base">
|
||||
{STRINGS.ticket.waiting}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tickets */}
|
||||
{purchase.ticket_issue_status === 'issued' && (
|
||||
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-300">
|
||||
<div className="bg-gray-900 rounded-xl p-4 sm:p-6 mb-5 sm:mb-6 border border-gray-800">
|
||||
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 text-gray-300">
|
||||
{STRINGS.ticket.ticketNumbers}
|
||||
</h2>
|
||||
<TicketList tickets={tickets} />
|
||||
@@ -183,43 +183,45 @@ export default function TicketStatusPage() {
|
||||
)}
|
||||
|
||||
{/* Draw Info */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-300">
|
||||
<div className="bg-gray-900 rounded-xl p-4 sm:p-6 mb-5 sm:mb-6 border border-gray-800">
|
||||
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 text-gray-300">
|
||||
Draw Information
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<span className="text-gray-400">Draw Time:</span>
|
||||
<div className="text-white">{formatDateTime(cycle.scheduled_at)}</div>
|
||||
<span className="text-gray-400 text-sm">Draw Time:</span>
|
||||
<div className="text-white text-sm sm:text-base">{formatDateTime(cycle.scheduled_at)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-400">Current Pot:</span>
|
||||
<div className="text-2xl font-bold text-bitcoin-orange">
|
||||
<span className="text-gray-400 text-sm">Current Pot:</span>
|
||||
<div className="text-xl sm:text-2xl font-bold text-bitcoin-orange">
|
||||
{cycle.pot_total_sats.toLocaleString()} sats
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cycle.status !== 'completed' && (
|
||||
<div>
|
||||
<span className="text-gray-400 block mb-2">Time Until Draw:</span>
|
||||
<span className="text-gray-400 text-sm block mb-2">Time Until Draw:</span>
|
||||
<div className="overflow-x-auto scrollbar-hide">
|
||||
<JackpotCountdown scheduledAt={cycle.scheduled_at} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result.has_drawn && (
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-300">
|
||||
<div className="bg-gray-900 rounded-xl p-4 sm:p-6 border border-gray-800">
|
||||
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 text-gray-300">
|
||||
Draw Results
|
||||
</h2>
|
||||
|
||||
{result.is_winner ? (
|
||||
<div>
|
||||
<div className="bg-green-900/30 text-green-200 px-6 py-4 rounded-lg mb-4 text-center text-2xl font-bold">
|
||||
<div className="bg-green-900/30 text-green-200 px-4 sm:px-6 py-3 sm:py-4 rounded-lg mb-4 text-center text-xl sm:text-2xl font-bold">
|
||||
🎉 {STRINGS.ticket.congratulations}
|
||||
</div>
|
||||
{result.payout && (
|
||||
@@ -231,10 +233,10 @@ export default function TicketStatusPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="bg-gray-800 px-6 py-4 rounded-lg mb-4 text-center">
|
||||
<div className="text-gray-400 mb-2">{STRINGS.ticket.betterLuck}</div>
|
||||
<div className="bg-gray-800 px-4 sm:px-6 py-3 sm:py-4 rounded-lg mb-4 text-center">
|
||||
<div className="text-gray-400 mb-2 text-sm sm:text-base">{STRINGS.ticket.betterLuck}</div>
|
||||
{cycle.winning_ticket_id && (
|
||||
<div className="text-gray-300">
|
||||
<div className="text-gray-300 text-sm sm:text-base">
|
||||
{STRINGS.ticket.winningTicket}: <span className="font-bold text-bitcoin-orange">#{cycle.winning_ticket_id.substring(0, 8)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -147,17 +147,17 @@ export function DrawAnimation({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center px-6 max-w-lg">
|
||||
<div className="text-center px-4 sm:px-6 max-w-lg w-full">
|
||||
{/* Spinning Phase */}
|
||||
{(phase === 'spinning' || phase === 'revealing') && (
|
||||
<>
|
||||
<div className="text-2xl text-yellow-400 mb-4 animate-pulse">
|
||||
<div className="text-xl sm:text-2xl text-yellow-400 mb-3 sm:mb-4 animate-pulse">
|
||||
🎰 Drawing Winner...
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-2xl p-8 border-2 border-yellow-500/50 shadow-2xl shadow-yellow-500/20">
|
||||
<div className="text-gray-400 text-sm mb-2">Ticket Number</div>
|
||||
<div className="bg-gray-900 rounded-xl sm:rounded-2xl p-5 sm:p-8 border-2 border-yellow-500/50 shadow-2xl shadow-yellow-500/20">
|
||||
<div className="text-gray-400 text-xs sm:text-sm mb-2">Ticket Number</div>
|
||||
<div
|
||||
className={`text-5xl md:text-6xl font-mono font-bold text-bitcoin-orange ${
|
||||
className={`text-3xl sm:text-5xl md:text-6xl font-mono font-bold text-bitcoin-orange break-all ${
|
||||
phase === 'spinning' ? 'animate-number-spin' : ''
|
||||
}`}
|
||||
>
|
||||
@@ -170,26 +170,26 @@ export function DrawAnimation({
|
||||
{/* Winner Phase */}
|
||||
{phase === 'winner' && hasWinner && (
|
||||
<div className="animate-winner-reveal">
|
||||
<div className="text-4xl mb-4">🎉🏆🎉</div>
|
||||
<div className="text-3xl md:text-4xl font-bold text-yellow-400 mb-6">
|
||||
<div className="text-3xl sm:text-4xl mb-3 sm:mb-4">🎉🏆🎉</div>
|
||||
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-yellow-400 mb-4 sm:mb-6">
|
||||
We Have a Winner!
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-yellow-900/60 to-orange-900/60 rounded-2xl p-8 border-2 border-yellow-500 shadow-2xl shadow-yellow-500/30">
|
||||
<div className="text-gray-300 text-sm mb-1">Winner</div>
|
||||
<div className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
<div className="bg-gradient-to-br from-yellow-900/60 to-orange-900/60 rounded-xl sm:rounded-2xl p-5 sm:p-8 border-2 border-yellow-500 shadow-2xl shadow-yellow-500/30">
|
||||
<div className="text-gray-300 text-xs sm:text-sm mb-1">Winner</div>
|
||||
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-white mb-3 sm:mb-4 break-all">
|
||||
{winnerName || 'Anon'}
|
||||
</div>
|
||||
<div className="text-gray-300 text-sm mb-1">Winning Ticket</div>
|
||||
<div className="text-2xl font-mono text-bitcoin-orange mb-4">
|
||||
<div className="text-gray-300 text-xs sm:text-sm mb-1">Winning Ticket</div>
|
||||
<div className="text-xl sm:text-2xl font-mono text-bitcoin-orange mb-3 sm:mb-4">
|
||||
#{winningTicket!.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-gray-300 text-sm mb-1">Prize</div>
|
||||
<div className="text-4xl md:text-5xl font-bold text-green-400">
|
||||
<div className="text-gray-300 text-xs sm:text-sm mb-1">Prize</div>
|
||||
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-green-400">
|
||||
{potAmount!.toLocaleString()} sats
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 text-gray-400 text-sm animate-pulse">
|
||||
Click anywhere to continue
|
||||
<div className="mt-4 sm:mt-6 text-gray-400 text-xs sm:text-sm animate-pulse">
|
||||
Tap anywhere to continue
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -197,20 +197,20 @@ export function DrawAnimation({
|
||||
{/* No Winner Phase (no tickets sold) */}
|
||||
{phase === 'no-winner' && (
|
||||
<div className="animate-winner-reveal">
|
||||
<div className="text-4xl mb-4">😔</div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-gray-400 mb-6">
|
||||
<div className="text-3xl sm:text-4xl mb-3 sm:mb-4">😔</div>
|
||||
<div className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-400 mb-4 sm:mb-6">
|
||||
No Tickets This Round
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-2xl p-8 border-2 border-gray-600 shadow-2xl">
|
||||
<div className="text-gray-300 text-lg mb-4">
|
||||
<div className="bg-gray-900 rounded-xl sm:rounded-2xl p-5 sm:p-8 border-2 border-gray-600 shadow-2xl">
|
||||
<div className="text-gray-300 text-base sm:text-lg mb-3 sm:mb-4">
|
||||
No tickets were sold for this draw.
|
||||
</div>
|
||||
<div className="text-bitcoin-orange text-xl font-semibold">
|
||||
<div className="text-bitcoin-orange text-lg sm:text-xl font-semibold">
|
||||
Next draw starting soon!
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 text-gray-400 text-sm animate-pulse">
|
||||
Click anywhere to continue
|
||||
<div className="mt-4 sm:mt-6 text-gray-400 text-xs sm:text-sm animate-pulse">
|
||||
Tap anywhere to continue
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,22 +2,22 @@ import Link from 'next/link';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="bg-gray-900 border-t border-gray-800 py-8 mt-12">
|
||||
<footer className="bg-gray-900 border-t border-gray-800 py-6 sm:py-8 mt-8 sm:mt-12 safe-bottom">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="text-gray-400 text-sm mb-4 md:mb-0">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="text-gray-400 text-xs sm:text-sm text-center md:text-left">
|
||||
© 2025 Lightning Lottery. Powered by Bitcoin Lightning Network.
|
||||
</div>
|
||||
<div className="flex space-x-6">
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm py-2"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
href="/past-wins"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm py-2"
|
||||
>
|
||||
Past Winners
|
||||
</Link>
|
||||
|
||||
@@ -31,34 +31,34 @@ export function JackpotCountdown({ scheduledAt, drawCompleted = false }: Jackpot
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex space-x-4" role="timer" aria-live="polite">
|
||||
<div className="flex space-x-2 sm:space-x-4" role="timer" aria-live="polite">
|
||||
{countdown.days > 0 && (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
{countdown.days}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">days</div>
|
||||
<div className="text-xs sm:text-sm text-gray-400">days</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
{countdown.hours.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">hours</div>
|
||||
<div className="text-xs sm:text-sm text-gray-400">hours</div>
|
||||
</div>
|
||||
<div className="text-4xl md:text-5xl font-bold text-gray-500">:</div>
|
||||
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-gray-500">:</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
{countdown.minutes.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">minutes</div>
|
||||
<div className="text-xs sm:text-sm text-gray-400">min</div>
|
||||
</div>
|
||||
<div className="text-4xl md:text-5xl font-bold text-gray-500">:</div>
|
||||
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-gray-500">:</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
{countdown.seconds.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">seconds</div>
|
||||
<div className="text-xs sm:text-sm text-gray-400">sec</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatSats, satsToBTC } from '@/lib/format';
|
||||
import { formatSats } from '@/lib/format';
|
||||
|
||||
interface JackpotPotDisplayProps {
|
||||
potTotalSats: number;
|
||||
@@ -7,12 +7,9 @@ interface JackpotPotDisplayProps {
|
||||
export function JackpotPotDisplay({ potTotalSats }: JackpotPotDisplayProps) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-5xl md:text-7xl font-bold text-bitcoin-orange mb-2">
|
||||
<div className="text-4xl sm:text-5xl md:text-7xl font-bold text-bitcoin-orange">
|
||||
{formatSats(potTotalSats)}
|
||||
<span className="text-3xl md:text-4xl ml-2 text-gray-400">sats</span>
|
||||
</div>
|
||||
<div className="text-xl md:text-2xl text-gray-400">
|
||||
≈ {satsToBTC(potTotalSats)} BTC
|
||||
<span className="text-2xl sm:text-3xl md:text-4xl ml-1 sm:ml-2 text-gray-400">sats</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ export function LightningInvoiceCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-lg relative overflow-hidden">
|
||||
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-lg relative overflow-hidden">
|
||||
{/* QR Code Container */}
|
||||
<div className="flex justify-center mb-4 relative">
|
||||
<div
|
||||
@@ -37,9 +37,10 @@ export function LightningInvoiceCard({
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={paymentRequest.toUpperCase()}
|
||||
size={260}
|
||||
size={typeof window !== 'undefined' && window.innerWidth < 400 ? 200 : 260}
|
||||
level="M"
|
||||
includeMargin={true}
|
||||
className="w-full max-w-[260px] h-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -87,19 +88,19 @@ export function LightningInvoiceCard({
|
||||
showPaidAnimation ? 'opacity-100' : 'opacity-0 h-0 overflow-hidden'
|
||||
}`}
|
||||
>
|
||||
<div className="text-green-600 font-bold text-lg">Payment Received!</div>
|
||||
<div className="text-green-600 font-bold text-base sm:text-lg">Payment Received!</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
<div className="text-xl sm:text-2xl font-bold text-gray-900">
|
||||
{amountSats.toLocaleString()} sats
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice */}
|
||||
<div className="mb-4">
|
||||
<div className="bg-gray-100 p-3 rounded text-xs break-all text-gray-700 max-h-24 overflow-y-auto">
|
||||
<div className="bg-gray-100 p-2 sm:p-3 rounded text-[10px] sm:text-xs break-all text-gray-700 max-h-20 sm:max-h-24 overflow-y-auto">
|
||||
{paymentRequest}
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,10 +109,10 @@ export function LightningInvoiceCard({
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={showPaidAnimation}
|
||||
className={`w-full py-3 rounded-lg font-medium transition-all duration-300 ${
|
||||
className={`w-full py-3 rounded-lg font-medium transition-all duration-300 text-sm sm:text-base ${
|
||||
showPaidAnimation
|
||||
? 'bg-green-500 text-white cursor-default'
|
||||
: 'bg-bitcoin-orange hover:bg-orange-600 text-white'
|
||||
: 'bg-bitcoin-orange hover:bg-orange-600 active:bg-orange-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{showPaidAnimation ? '✓ Paid' : copied ? '✓ Copied!' : '📋 Copy Invoice'}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { NostrLoginButton } from './NostrLoginButton';
|
||||
import { shortNpub, hexToNpub } from '@/lib/nostr';
|
||||
@@ -9,9 +9,87 @@ import STRINGS from '@/constants/strings';
|
||||
|
||||
export function TopBar() {
|
||||
const user = useAppSelector((state) => state.user);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
// Close menu when clicking outside or pressing escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
if (mobileMenuOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
const closeMenu = () => setMobileMenuOpen(false);
|
||||
|
||||
const NavLinks = ({ mobile = false }: { mobile?: boolean }) => (
|
||||
<>
|
||||
<Link
|
||||
href="/"
|
||||
onClick={closeMenu}
|
||||
className={`${
|
||||
mobile
|
||||
? 'block py-3 px-4 text-lg text-gray-200 hover:bg-gray-800 rounded-lg transition-colors'
|
||||
: 'text-gray-300 hover:text-white transition-colors'
|
||||
}`}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/buy"
|
||||
onClick={closeMenu}
|
||||
className={`${
|
||||
mobile
|
||||
? 'block py-3 px-4 text-lg text-gray-200 hover:bg-gray-800 rounded-lg transition-colors'
|
||||
: 'text-gray-300 hover:text-white transition-colors'
|
||||
}`}
|
||||
>
|
||||
Buy Tickets
|
||||
</Link>
|
||||
<Link
|
||||
href="/past-wins"
|
||||
onClick={closeMenu}
|
||||
className={`${
|
||||
mobile
|
||||
? 'block py-3 px-4 text-lg text-gray-200 hover:bg-gray-800 rounded-lg transition-colors'
|
||||
: 'text-gray-300 hover:text-white transition-colors'
|
||||
}`}
|
||||
>
|
||||
Past Winners
|
||||
</Link>
|
||||
|
||||
{user.authenticated ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
onClick={closeMenu}
|
||||
className={`${
|
||||
mobile
|
||||
? 'block py-3 px-4 text-lg text-gray-200 hover:bg-gray-800 rounded-lg transition-colors'
|
||||
: 'text-gray-300 hover:text-white transition-colors'
|
||||
}`}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<div className={mobile ? 'py-3 px-4' : ''} onClick={closeMenu}>
|
||||
<NostrLoginButton />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="bg-gray-900 border-b border-gray-800">
|
||||
<nav className="bg-gray-900 border-b border-gray-800 sticky top-0 z-40">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
@@ -22,41 +100,106 @@ export function TopBar() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center space-x-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/buy"
|
||||
className="text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Buy Tickets
|
||||
</Link>
|
||||
<Link
|
||||
href="/past-wins"
|
||||
className="text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Past Winners
|
||||
</Link>
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-6">
|
||||
<NavLinks />
|
||||
</div>
|
||||
|
||||
{user.authenticated ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-gray-300 hover:text-white transition-colors"
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="md:hidden p-2 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-bitcoin-orange rounded-lg"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
{mobileMenuOpen ? (
|
||||
// X icon
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<NostrLoginButton />
|
||||
// Hamburger icon
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 md:hidden"
|
||||
onClick={closeMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Panel */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-72 bg-gray-900 border-l border-gray-800 z-50 transform transition-transform duration-300 ease-in-out md:hidden ${
|
||||
mobileMenuOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Mobile Menu Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<span className="text-lg font-semibold text-white">Menu</span>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-bitcoin-orange rounded-lg"
|
||||
onClick={closeMenu}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Links */}
|
||||
<div className="p-4 space-y-2">
|
||||
<NavLinks mobile />
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Footer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-800">
|
||||
<div className="text-center text-gray-500 text-sm">
|
||||
⚡ Lightning Lotto
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ export const STRINGS = {
|
||||
description: 'Recent jackpots and their champions.',
|
||||
noWins: 'No completed jackpots yet. Check back soon!',
|
||||
winner: 'Winner',
|
||||
address: 'Lightning Address',
|
||||
ticket: 'Ticket #',
|
||||
pot: 'Pot',
|
||||
drawTime: 'Draw Time',
|
||||
|
||||
@@ -157,6 +157,16 @@ class ApiClient {
|
||||
return this.request(`/jackpot/past-wins?${params.toString()}`);
|
||||
}
|
||||
|
||||
async getMaintenanceStatus(): Promise<{ maintenance_mode: boolean; maintenance_pending: boolean; message: string | null }> {
|
||||
try {
|
||||
const response = await this.request<{ data: { maintenance_mode: boolean; maintenance_pending: boolean; message: string | null } }>('/status/maintenance');
|
||||
return response.data;
|
||||
} catch {
|
||||
// If endpoint doesn't exist or fails, assume not in maintenance
|
||||
return { maintenance_mode: false, maintenance_pending: false, message: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
async nostrAuth(nostrPubkey: string, signedMessage: string, nonce: string) {
|
||||
return this.request('/auth/nostr', {
|
||||
|
||||
6
telegram_bot/.gitignore
vendored
6
telegram_bot/.gitignore
vendored
@@ -14,6 +14,12 @@ logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Database
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
71
telegram_bot/botfather_commands.txt
Normal file
71
telegram_bot/botfather_commands.txt
Normal file
@@ -0,0 +1,71 @@
|
||||
================================
|
||||
BOTFATHER COMMAND SETUP
|
||||
================================
|
||||
|
||||
Send to @BotFather: /setcommands
|
||||
Then select your bot and paste the commands below:
|
||||
|
||||
================================
|
||||
PRIVATE CHAT COMMANDS
|
||||
================================
|
||||
|
||||
start - Start the bot and register
|
||||
lottomenu - Show main menu
|
||||
buyticket - Buy lottery tickets
|
||||
tickets - View your tickets
|
||||
wins - View your wins
|
||||
lottoaddress - Set your Lightning Address
|
||||
lottosettings - Notification settings
|
||||
lottohelp - Show help
|
||||
jackpot - Show current jackpot
|
||||
|
||||
================================
|
||||
GROUP COMMANDS (Optional)
|
||||
================================
|
||||
|
||||
If you want separate commands for groups, use /setcommands again
|
||||
and select "Edit commands for groups":
|
||||
|
||||
jackpot - Show current jackpot info
|
||||
lottosettings - Configure lottery settings (admin only)
|
||||
lottohelp - Show help and commands
|
||||
|
||||
================================
|
||||
BOT DESCRIPTION
|
||||
================================
|
||||
|
||||
Send to @BotFather: /setdescription
|
||||
|
||||
Lightning Lotto - Win Bitcoin on the Lightning Network! ⚡
|
||||
|
||||
Buy lottery tickets with Lightning, get instant payouts if you win.
|
||||
• Buy tickets with sats
|
||||
• Set your Lightning Address for payouts
|
||||
• Get notified about draws and wins
|
||||
• Join groups to get jackpot alerts
|
||||
|
||||
================================
|
||||
ABOUT TEXT
|
||||
================================
|
||||
|
||||
Send to @BotFather: /setabouttext
|
||||
|
||||
⚡ Lightning Lotto Bot ⚡
|
||||
|
||||
A provably fair Bitcoin lottery powered by the Lightning Network.
|
||||
|
||||
🎟️ Buy tickets with Lightning
|
||||
💰 Instant payouts to your Lightning Address
|
||||
🔔 Draw reminders & win notifications
|
||||
👥 Group support with announcements
|
||||
|
||||
Start with /start to begin!
|
||||
|
||||
================================
|
||||
SHORT DESCRIPTION
|
||||
================================
|
||||
|
||||
Send to @BotFather: /setshortdescription
|
||||
|
||||
Win Bitcoin on the Lightning Network! Buy lottery tickets, get instant payouts. ⚡
|
||||
|
||||
@@ -7,8 +7,8 @@ API_BASE_URL=http://localhost:3000
|
||||
# Frontend URL (for generating ticket links)
|
||||
FRONTEND_BASE_URL=http://localhost:3001
|
||||
|
||||
# Redis Configuration (optional - falls back to in-memory if not set)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# SQLite Database Path (optional - defaults to ./data/bot.db)
|
||||
# BOT_DATABASE_PATH=./data/bot.db
|
||||
|
||||
# Bot Configuration
|
||||
MAX_TICKETS_PER_PURCHASE=100
|
||||
|
||||
463
telegram_bot/package-lock.json
generated
463
telegram_bot/package-lock.json
generated
@@ -10,13 +10,14 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"node-telegram-bot-api": "^0.64.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/node": "^20.10.4",
|
||||
"@types/node-telegram-bot-api": "^0.64.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -136,12 +137,6 @@
|
||||
"kuler": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
|
||||
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -208,6 +203,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/caseless": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
|
||||
@@ -535,6 +540,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
@@ -544,6 +569,17 @@
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
|
||||
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -557,6 +593,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz",
|
||||
@@ -597,6 +642,30 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
@@ -684,6 +753,12 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
@@ -695,15 +770,6 @@
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||
@@ -849,6 +915,7 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -871,6 +938,30 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
@@ -914,13 +1005,13 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
@@ -1144,6 +1235,15 @@
|
||||
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -1188,6 +1288,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -1282,6 +1388,12 @@
|
||||
"node": ">= 0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1416,6 +1528,12 @@
|
||||
"assert-plus": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
@@ -1584,6 +1702,26 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore-by-default": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
||||
@@ -1597,6 +1735,12 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -1611,30 +1755,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
|
||||
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.4.0",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"denque": "^2.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -2106,18 +2226,6 @@
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/logform": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
|
||||
@@ -2184,6 +2292,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -2197,12 +2317,45 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.85.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
|
||||
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-telegram-bot-api": {
|
||||
"version": "0.64.0",
|
||||
"resolved": "https://registry.npmjs.org/node-telegram-bot-api/-/node-telegram-bot-api-0.64.0.tgz",
|
||||
@@ -2439,6 +2592,42 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/pump": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -2527,6 +2716,21 @@
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
@@ -2561,27 +2765,6 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -2878,7 +3061,6 @@
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -3011,6 +3193,51 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
@@ -3058,12 +3285,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stealthy-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz",
|
||||
@@ -3183,6 +3404,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
@@ -3196,6 +3426,69 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs/node_modules/pump": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream/node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/text-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||
|
||||
@@ -19,13 +19,14 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"node-telegram-bot-api": "^0.64.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/node": "^20.10.4",
|
||||
"@types/node-telegram-bot-api": "^0.64.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
|
||||
@@ -31,8 +31,8 @@ export const config = {
|
||||
frontend: {
|
||||
baseUrl: optional('FRONTEND_BASE_URL', 'http://localhost:3001'),
|
||||
},
|
||||
redis: {
|
||||
url: process.env.REDIS_URL || null,
|
||||
database: {
|
||||
path: process.env.BOT_DATABASE_PATH || null, // Defaults to ./data/bot.db in database.ts
|
||||
},
|
||||
bot: {
|
||||
maxTicketsPerPurchase: optionalInt('MAX_TICKETS_PER_PURCHASE', 100),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { stateManager } from '../services/state';
|
||||
import { logger, logUserAction } from '../services/logger';
|
||||
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards';
|
||||
import { isValidLightningAddress } from '../utils/format';
|
||||
import { getMainMenuKeyboard, getLightningAddressKeyboard, getCancelKeyboard } from '../utils/keyboards';
|
||||
import { isValidLightningAddress, verifyLightningAddress } from '../utils/format';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
@@ -14,6 +14,7 @@ export async function handleAddressCommand(
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
const username = msg.from?.username;
|
||||
|
||||
if (!userId) {
|
||||
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
|
||||
@@ -31,12 +32,12 @@ export async function handleAddressCommand(
|
||||
}
|
||||
|
||||
const message = user.lightningAddress
|
||||
? messages.address.currentAddress(user.lightningAddress)
|
||||
: messages.address.noAddressSet;
|
||||
? messages.address.currentAddressWithOptions(user.lightningAddress, username)
|
||||
: messages.address.noAddressSetWithOptions(username);
|
||||
|
||||
await bot.sendMessage(chatId, message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
reply_markup: getLightningAddressKeyboard(username),
|
||||
});
|
||||
|
||||
await stateManager.updateUserState(userId, 'updating_address');
|
||||
@@ -55,6 +56,7 @@ export async function handleLightningAddressInput(
|
||||
): Promise<boolean> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
const username = msg.from?.username;
|
||||
const text = msg.text?.trim();
|
||||
|
||||
if (!userId || !text) return false;
|
||||
@@ -73,7 +75,19 @@ export async function handleLightningAddressInput(
|
||||
if (!isValidLightningAddress(text)) {
|
||||
await bot.sendMessage(chatId, messages.address.invalidFormat, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
reply_markup: getLightningAddressKeyboard(username),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Verify the lightning address actually works
|
||||
await bot.sendMessage(chatId, messages.address.verifying);
|
||||
const verification = await verifyLightningAddress(text);
|
||||
|
||||
if (!verification.valid) {
|
||||
await bot.sendMessage(chatId, messages.address.verificationFailed(text, verification.error), {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getLightningAddressKeyboard(username),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -81,7 +95,7 @@ export async function handleLightningAddressInput(
|
||||
// Save the lightning address
|
||||
await stateManager.updateLightningAddress(userId, text);
|
||||
|
||||
logUserAction(userId, 'Lightning address updated');
|
||||
logUserAction(userId, 'Lightning address updated', { address: text });
|
||||
|
||||
const responseMessage = user.state === 'awaiting_lightning_address'
|
||||
? messages.address.firstTimeSuccess(text)
|
||||
@@ -100,7 +114,85 @@ export async function handleLightningAddressInput(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle lightning address selection callback (21Tipbot/Bittip)
|
||||
*/
|
||||
export async function handleLightningAddressCallback(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery,
|
||||
action: string
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
const userId = query.from.id;
|
||||
const username = query.from.username;
|
||||
|
||||
if (!chatId) return;
|
||||
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
if (!user) {
|
||||
await bot.sendMessage(chatId, messages.errors.startFirst);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has a username
|
||||
if (!username) {
|
||||
await bot.sendMessage(chatId, messages.address.noUsername, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate address based on selection
|
||||
let address: string;
|
||||
let serviceName: string;
|
||||
|
||||
if (action === '21tipbot') {
|
||||
address = `${username}@twentyone.tips`;
|
||||
serviceName = '21Tipbot';
|
||||
} else if (action === 'bittip') {
|
||||
address = `${username}@btip.nl`;
|
||||
serviceName = 'Bittip';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the address
|
||||
await bot.sendMessage(chatId, messages.address.verifyingService(serviceName, address));
|
||||
const verification = await verifyLightningAddress(address);
|
||||
|
||||
if (!verification.valid) {
|
||||
await bot.sendMessage(chatId, messages.address.serviceNotSetup(serviceName, verification.error), {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getLightningAddressKeyboard(username),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the address
|
||||
await stateManager.updateLightningAddress(userId, address);
|
||||
|
||||
logUserAction(userId, 'Lightning address set via tipbot', { address, serviceName });
|
||||
|
||||
const responseMessage = user.state === 'awaiting_lightning_address'
|
||||
? messages.address.firstTimeSuccess(address)
|
||||
: messages.address.updateSuccess(address);
|
||||
|
||||
await bot.sendMessage(chatId, responseMessage, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in handleLightningAddressCallback', { error, userId, action });
|
||||
await bot.sendMessage(chatId, messages.errors.generic);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
handleAddressCommand,
|
||||
handleLightningAddressInput,
|
||||
handleLightningAddressCallback,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,13 @@ import { formatSats, formatDate, formatTimeUntil } from '../utils/format';
|
||||
import { PendingPurchaseData, AwaitingPaymentData } from '../types';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
* Check if chat is a group (negative ID for supergroups, or check type)
|
||||
*/
|
||||
function isGroupChat(chatId: number): boolean {
|
||||
return chatId < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /buy command or "Buy Tickets" button
|
||||
*/
|
||||
@@ -33,6 +40,26 @@ export async function handleBuyCommand(
|
||||
logUserAction(userId, 'Initiated ticket purchase');
|
||||
|
||||
try {
|
||||
// Check maintenance mode first
|
||||
const maintenance = await apiClient.checkMaintenanceStatus();
|
||||
if (maintenance.enabled) {
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
messages.errors.maintenance(maintenance.message || 'System is under maintenance.'),
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn if maintenance is pending (but still allow purchase)
|
||||
if (maintenance.pending) {
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
messages.errors.maintenancePending,
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await stateManager.getUser(userId);
|
||||
|
||||
if (!user) {
|
||||
@@ -273,11 +300,11 @@ export async function handlePurchaseConfirmation(
|
||||
|
||||
logUserAction(userId, 'Confirmed purchase', { tickets: pendingData.ticketCount });
|
||||
|
||||
// Create invoice
|
||||
// Create invoice with user's display name (defaults to @username)
|
||||
const purchaseResult = await apiClient.buyTickets(
|
||||
pendingData.ticketCount,
|
||||
user.lightningAddress,
|
||||
userId
|
||||
stateManager.getDisplayName(user)
|
||||
);
|
||||
|
||||
logPaymentEvent(userId, purchaseResult.ticket_purchase_id, 'created', {
|
||||
@@ -295,21 +322,23 @@ export async function handlePurchaseConfirmation(
|
||||
parse_mode: 'Markdown',
|
||||
});
|
||||
|
||||
// Send QR code
|
||||
await bot.sendPhoto(chatId, qrBuffer, {
|
||||
// Send QR code with caption
|
||||
const qrMessage = await bot.sendPhoto(chatId, qrBuffer, {
|
||||
caption: messages.buy.invoiceCaption(
|
||||
pendingData.ticketCount,
|
||||
formatSats(pendingData.totalAmount),
|
||||
purchaseResult.invoice.payment_request,
|
||||
config.bot.invoiceExpiryMinutes
|
||||
),
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getViewTicketKeyboard(
|
||||
purchaseResult.ticket_purchase_id,
|
||||
purchaseResult.public_url
|
||||
),
|
||||
});
|
||||
|
||||
// Send invoice string as separate message for easy copying
|
||||
const invoiceMessage = await bot.sendMessage(
|
||||
chatId,
|
||||
messages.buy.invoiceString(purchaseResult.invoice.payment_request),
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
|
||||
// Store purchase and start polling
|
||||
const paymentData: AwaitingPaymentData = {
|
||||
...pendingData,
|
||||
@@ -317,17 +346,21 @@ export async function handlePurchaseConfirmation(
|
||||
paymentRequest: purchaseResult.invoice.payment_request,
|
||||
publicUrl: purchaseResult.public_url,
|
||||
pollStartTime: Date.now(),
|
||||
headerMessageId: messageId,
|
||||
invoiceMessageId: invoiceMessage.message_id,
|
||||
qrMessageId: qrMessage.message_id,
|
||||
};
|
||||
|
||||
await stateManager.storePurchase(userId, purchaseResult.ticket_purchase_id, paymentData);
|
||||
await stateManager.updateUserState(userId, 'awaiting_invoice_payment', paymentData);
|
||||
|
||||
// Start payment polling
|
||||
pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id);
|
||||
// Start payment polling - pass all message IDs to delete on completion
|
||||
pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id, messageId, qrMessage.message_id, invoiceMessage.message_id);
|
||||
} catch (error) {
|
||||
logger.error('Error in handlePurchaseConfirmation', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.invoiceCreationFailed, {
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
// Only show reply keyboard in private chats, not in groups
|
||||
...(isGroupChat(chatId) ? {} : { reply_markup: getMainMenuKeyboard() }),
|
||||
});
|
||||
await stateManager.clearUserStateData(userId);
|
||||
}
|
||||
@@ -340,7 +373,10 @@ async function pollPaymentStatus(
|
||||
bot: TelegramBot,
|
||||
chatId: number,
|
||||
userId: number,
|
||||
purchaseId: string
|
||||
purchaseId: string,
|
||||
headerMessageId?: number,
|
||||
qrMessageId?: number,
|
||||
invoiceMessageId?: number
|
||||
): Promise<void> {
|
||||
const pollInterval = config.bot.paymentPollIntervalMs;
|
||||
const timeout = config.bot.paymentPollTimeoutMs;
|
||||
@@ -348,14 +384,45 @@ async function pollPaymentStatus(
|
||||
|
||||
logPaymentEvent(userId, purchaseId, 'polling');
|
||||
|
||||
// Helper to delete all invoice-related messages
|
||||
const deleteInvoiceMessages = async () => {
|
||||
// Delete in reverse order (bottom to top)
|
||||
if (invoiceMessageId) {
|
||||
try {
|
||||
await bot.deleteMessage(chatId, invoiceMessageId);
|
||||
} catch (e) {
|
||||
// Ignore if message already deleted
|
||||
}
|
||||
}
|
||||
if (qrMessageId) {
|
||||
try {
|
||||
await bot.deleteMessage(chatId, qrMessageId);
|
||||
} catch (e) {
|
||||
// Ignore if message already deleted
|
||||
}
|
||||
}
|
||||
if (headerMessageId) {
|
||||
try {
|
||||
await bot.deleteMessage(chatId, headerMessageId);
|
||||
} catch (e) {
|
||||
// Ignore if message already deleted
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkPayment = async (): Promise<void> => {
|
||||
try {
|
||||
// Check if we've timed out
|
||||
if (Date.now() - startTime > timeout) {
|
||||
logPaymentEvent(userId, purchaseId, 'expired');
|
||||
|
||||
// Delete the invoice messages
|
||||
await deleteInvoiceMessages();
|
||||
|
||||
await bot.sendMessage(chatId, messages.buy.invoiceExpired, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
// Only show reply keyboard in private chats, not in groups
|
||||
...(isGroupChat(chatId) ? {} : { reply_markup: getMainMenuKeyboard() }),
|
||||
});
|
||||
await stateManager.clearUserStateData(userId);
|
||||
return;
|
||||
@@ -374,6 +441,12 @@ async function pollPaymentStatus(
|
||||
tickets: status.tickets.length,
|
||||
});
|
||||
|
||||
// Delete the invoice messages
|
||||
await deleteInvoiceMessages();
|
||||
|
||||
// Track user as cycle participant for notifications
|
||||
await stateManager.addCycleParticipant(status.purchase.cycle_id, userId, purchaseId);
|
||||
|
||||
// Payment received!
|
||||
const ticketNumbers = status.tickets
|
||||
.map((t) => `#${t.serial_number.toString().padStart(4, '0')}`)
|
||||
@@ -397,9 +470,14 @@ async function pollPaymentStatus(
|
||||
|
||||
if (status.purchase.invoice_status === 'expired') {
|
||||
logPaymentEvent(userId, purchaseId, 'expired');
|
||||
|
||||
// Delete the invoice messages
|
||||
await deleteInvoiceMessages();
|
||||
|
||||
await bot.sendMessage(chatId, messages.buy.invoiceExpiredShort, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
// Only show reply keyboard in private chats, not in groups
|
||||
...(isGroupChat(chatId) ? {} : { reply_markup: getMainMenuKeyboard() }),
|
||||
});
|
||||
await stateManager.clearUserStateData(userId);
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,20 @@ import TelegramBot from 'node-telegram-bot-api';
|
||||
import { groupStateManager } from '../services/groupState';
|
||||
import { logger, logUserAction } from '../services/logger';
|
||||
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
|
||||
@@ -63,6 +77,7 @@ export async function handleBotRemovedFromGroup(
|
||||
|
||||
/**
|
||||
* Handle /settings command (group admin only)
|
||||
* Opens settings in private DM, not in the group
|
||||
*/
|
||||
export async function handleGroupSettings(
|
||||
bot: TelegramBot,
|
||||
@@ -106,36 +121,108 @@ export async function handleGroupSettings(
|
||||
return;
|
||||
}
|
||||
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
// Send settings to user's private DM
|
||||
try {
|
||||
const sentMessage = await bot.sendMessage(
|
||||
userId,
|
||||
messages.groups.settingsOverview(currentSettings),
|
||||
{
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getGroupSettingsKeyboard(currentSettings),
|
||||
}
|
||||
);
|
||||
|
||||
// Schedule auto-delete after 2 minutes
|
||||
scheduleSettingsMessageDeletion(bot, userId, sentMessage.message_id);
|
||||
|
||||
// Notify in the group that settings were sent to DM
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
`⚙️ @${msg.from?.username || 'Admin'}, I've sent the group settings to your DMs!`
|
||||
);
|
||||
} 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) {
|
||||
logger.error('Error in handleGroupSettings', { error, chatId });
|
||||
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
|
||||
* Settings can now be managed from DMs, so group ID is included in callback data
|
||||
*/
|
||||
export async function handleGroupSettingsCallback(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery,
|
||||
action: string
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
const dmChatId = query.message?.chat.id;
|
||||
const userId = query.from.id;
|
||||
const messageId = query.message?.message_id;
|
||||
|
||||
if (!chatId || !messageId) return;
|
||||
if (!dmChatId || !messageId) return;
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isGroupAdmin(bot, chatId, userId);
|
||||
// Parse the action to extract the group ID
|
||||
const parsed = parseGroupAction(action);
|
||||
if (!parsed) {
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Invalid action' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { action: settingAction, groupId } = parsed;
|
||||
|
||||
// Check if user is admin of the target group
|
||||
const isAdmin = await isGroupAdmin(bot, groupId, userId);
|
||||
if (!isAdmin) {
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: messages.groups.adminOnly,
|
||||
@@ -144,10 +231,23 @@ export async function handleGroupSettingsCallback(
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed';
|
||||
// Refresh auto-delete timer on any interaction (in the DM)
|
||||
scheduleSettingsMessageDeletion(bot, dmChatId, messageId);
|
||||
|
||||
switch (action) {
|
||||
try {
|
||||
const currentSettings = await groupStateManager.getGroup(groupId);
|
||||
if (!currentSettings) {
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Group not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedSettings: GroupSettings | null = null;
|
||||
|
||||
// Handle toggle actions
|
||||
if (settingAction.startsWith('toggle_')) {
|
||||
let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed' | 'newJackpotAnnouncement' | 'reminder1Enabled' | 'reminder2Enabled' | 'reminder3Enabled';
|
||||
|
||||
switch (settingAction) {
|
||||
case 'toggle_enabled':
|
||||
setting = 'enabled';
|
||||
break;
|
||||
@@ -160,109 +260,310 @@ export async function handleGroupSettingsCallback(
|
||||
case 'toggle_purchases':
|
||||
setting = 'ticketPurchaseAllowed';
|
||||
break;
|
||||
case 'toggle_newjackpot':
|
||||
setting = 'newJackpotAnnouncement';
|
||||
break;
|
||||
case 'toggle_reminder1':
|
||||
setting = 'reminder1Enabled';
|
||||
break;
|
||||
case 'toggle_reminder2':
|
||||
setting = 'reminder2Enabled';
|
||||
break;
|
||||
case 'toggle_reminder3':
|
||||
setting = 'reminder3Enabled';
|
||||
break;
|
||||
default:
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSettings = await groupStateManager.getGroup(chatId);
|
||||
if (!currentSettings) {
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Group not found' });
|
||||
return;
|
||||
const currentValue = currentSettings[setting] !== false; // Default true for new settings
|
||||
const newValue = !currentValue;
|
||||
updatedSettings = await groupStateManager.updateSetting(groupId, setting, newValue);
|
||||
|
||||
if (updatedSettings) {
|
||||
logUserAction(userId, 'Updated group setting', {
|
||||
groupId,
|
||||
setting,
|
||||
newValue,
|
||||
});
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: `${setting} ${newValue ? 'enabled' : 'disabled'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newValue = !currentSettings[setting];
|
||||
const updatedSettings = await groupStateManager.updateSetting(chatId, setting, newValue);
|
||||
// Legacy handlers removed - now using 3-slot reminder system with toggle_reminder1/2/3 and time adjustments
|
||||
|
||||
// Handle announcement delay selection (announce_delay_30 -> seconds=30)
|
||||
const announceDelayMatch = settingAction.match(/^announce_delay_(\d+)$/);
|
||||
if (announceDelayMatch) {
|
||||
const seconds = parseInt(announceDelayMatch[1], 10);
|
||||
if (!isNaN(seconds)) {
|
||||
updatedSettings = await groupStateManager.updateAnnouncementDelay(groupId, seconds);
|
||||
if (updatedSettings) {
|
||||
logUserAction(userId, 'Updated announcement delay', { groupId, seconds });
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: seconds === 0 ? 'Announce immediately' : `Announce ${seconds}s after draw`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle new jackpot delay selection (newjackpot_delay_5 -> minutes=5)
|
||||
const newJackpotDelayMatch = settingAction.match(/^newjackpot_delay_(\d+)$/);
|
||||
if (newJackpotDelayMatch) {
|
||||
const minutes = parseInt(newJackpotDelayMatch[1], 10);
|
||||
if (!isNaN(minutes)) {
|
||||
updatedSettings = await groupStateManager.updateNewJackpotDelay(groupId, minutes);
|
||||
if (updatedSettings) {
|
||||
logUserAction(userId, 'Updated new jackpot delay', { groupId, minutes });
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: minutes === 0 ? 'Announce immediately' : `Announce ${minutes} min after new jackpot`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reminder time adjustments (reminder1_add_1_hours, reminder2_sub_1_days, etc.)
|
||||
const reminderTimeMatch = settingAction.match(/^reminder(\d)_(add|sub)_(\d+)_(minutes|hours|days)$/);
|
||||
if (reminderTimeMatch) {
|
||||
const slot = parseInt(reminderTimeMatch[1], 10) as 1 | 2 | 3;
|
||||
const operation = reminderTimeMatch[2] as 'add' | 'sub';
|
||||
const amount = parseInt(reminderTimeMatch[3], 10);
|
||||
const unit = reminderTimeMatch[4] as 'minutes' | 'hours' | 'days';
|
||||
|
||||
// Get current time for this slot
|
||||
const currentTimeKey = `reminder${slot}Time` as 'reminder1Time' | 'reminder2Time' | 'reminder3Time';
|
||||
const defaultTimes: Record<string, ReminderTime> = {
|
||||
reminder1Time: { value: 1, unit: 'hours' },
|
||||
reminder2Time: { value: 1, unit: 'days' },
|
||||
reminder3Time: { value: 6, unit: 'days' },
|
||||
};
|
||||
const currentTime = currentSettings[currentTimeKey] || defaultTimes[currentTimeKey];
|
||||
|
||||
// Convert to minutes for calculation
|
||||
const currentMinutes = reminderTimeToMinutes(currentTime);
|
||||
const adjustMinutes = unit === 'minutes' ? amount : unit === 'hours' ? amount * 60 : amount * 60 * 24;
|
||||
const newMinutes = operation === 'add'
|
||||
? currentMinutes + adjustMinutes
|
||||
: Math.max(1, currentMinutes - adjustMinutes); // Minimum 1 minute
|
||||
|
||||
// Convert back to best unit
|
||||
let newTime: ReminderTime;
|
||||
if (newMinutes >= 1440 && newMinutes % 1440 === 0) {
|
||||
newTime = { value: newMinutes / 1440, unit: 'days' };
|
||||
} else if (newMinutes >= 60 && newMinutes % 60 === 0) {
|
||||
newTime = { value: newMinutes / 60, unit: 'hours' };
|
||||
} else {
|
||||
newTime = { value: newMinutes, unit: 'minutes' };
|
||||
}
|
||||
|
||||
updatedSettings = await groupStateManager.updateReminderTime(groupId, slot, newTime);
|
||||
if (updatedSettings) {
|
||||
logUserAction(userId, 'Updated reminder time', { groupId, slot, newTime });
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: `Reminder ${slot}: ${formatReminderTime(newTime)} before draw`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!updatedSettings) {
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Failed to update' });
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Updated group setting', {
|
||||
groupId: chatId,
|
||||
setting,
|
||||
newValue,
|
||||
});
|
||||
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: `${setting} ${newValue ? 'enabled' : 'disabled'}`,
|
||||
});
|
||||
|
||||
// Update the message with new settings
|
||||
// Update the message with new settings (in the DM)
|
||||
await bot.editMessageText(
|
||||
messages.groups.settingsOverview(updatedSettings),
|
||||
{
|
||||
chat_id: chatId,
|
||||
chat_id: dmChatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getGroupSettingsKeyboard(updatedSettings),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error in handleGroupSettingsCallback', { error, chatId, action });
|
||||
logger.error('Error in handleGroupSettingsCallback', { error, groupId, action });
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Error updating settings' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Format delay option for display (seconds)
|
||||
*/
|
||||
function formatDelayOption(seconds: number): string {
|
||||
if (seconds === 0) return 'Instant';
|
||||
if (seconds >= 60) {
|
||||
const minutes = seconds / 60;
|
||||
return minutes === 1 ? '1 min' : `${minutes} min`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format new jackpot delay option for display (minutes)
|
||||
*/
|
||||
function formatNewJackpotDelay(minutes: number): string {
|
||||
if (minutes === 0) return 'Instant';
|
||||
return minutes === 1 ? '1 min' : `${minutes} min`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time adjustment buttons for a reminder slot
|
||||
*/
|
||||
function getReminderTimeAdjustButtons(slot: number, currentTime: ReminderTime, groupId: number): TelegramBot.InlineKeyboardButton[] {
|
||||
return [
|
||||
{ text: '−1m', callback_data: `group_reminder${slot}_sub_1_minutes_${groupId}` },
|
||||
{ text: '+1m', callback_data: `group_reminder${slot}_add_1_minutes_${groupId}` },
|
||||
{ text: '−1h', callback_data: `group_reminder${slot}_sub_1_hours_${groupId}` },
|
||||
{ text: '+1h', callback_data: `group_reminder${slot}_add_1_hours_${groupId}` },
|
||||
{ text: '−1d', callback_data: `group_reminder${slot}_sub_1_days_${groupId}` },
|
||||
{ text: '+1d', callback_data: `group_reminder${slot}_add_1_days_${groupId}` },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a reminder time is already set
|
||||
*/
|
||||
function hasReminder(settings: GroupSettings, rt: ReminderTime): boolean {
|
||||
if (!settings.reminderTimes) return false;
|
||||
return settings.reminderTimes.some(
|
||||
r => r.value === rt.value && r.unit === rt.unit
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate inline keyboard for group settings
|
||||
* groupId is included in callback data so settings can be managed from DMs
|
||||
*/
|
||||
function getGroupSettingsKeyboard(settings: {
|
||||
enabled: boolean;
|
||||
drawAnnouncements: boolean;
|
||||
reminders: boolean;
|
||||
ticketPurchaseAllowed: boolean;
|
||||
}): TelegramBot.InlineKeyboardMarkup {
|
||||
const onOff = (val: boolean) => val ? '✅' : '❌';
|
||||
function getGroupSettingsKeyboard(settings: GroupSettings): TelegramBot.InlineKeyboardMarkup {
|
||||
const onOff = (val: boolean | undefined) => val !== false ? '✅' : '❌';
|
||||
const selected = (current: number, option: number) => current === option ? '●' : '○';
|
||||
const gid = settings.groupId; // Include group ID in all callbacks
|
||||
|
||||
return {
|
||||
inline_keyboard: [
|
||||
const keyboard: TelegramBot.InlineKeyboardButton[][] = [
|
||||
[{
|
||||
text: `${onOff(settings.enabled)} Bot Enabled`,
|
||||
callback_data: 'group_toggle_enabled',
|
||||
callback_data: `group_toggle_enabled_${gid}`,
|
||||
}],
|
||||
[{
|
||||
text: `${onOff(settings.drawAnnouncements)} Draw Announcements`,
|
||||
callback_data: 'group_toggle_announcements',
|
||||
text: `${onOff(settings.newJackpotAnnouncement)} New Jackpot Announcement`,
|
||||
callback_data: `group_toggle_newjackpot_${gid}`,
|
||||
}],
|
||||
[{
|
||||
];
|
||||
|
||||
// Add new jackpot delay options if enabled
|
||||
if (settings.newJackpotAnnouncement !== false) {
|
||||
keyboard.push(
|
||||
NEW_JACKPOT_DELAY_OPTIONS.map(minutes => ({
|
||||
text: `${selected(settings.newJackpotDelayMinutes ?? 5, minutes)} ${formatNewJackpotDelay(minutes)}`,
|
||||
callback_data: `group_newjackpot_delay_${minutes}_${gid}`,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
keyboard.push([{
|
||||
text: `${onOff(settings.drawAnnouncements)} Draw Result Announcements`,
|
||||
callback_data: `group_toggle_announcements_${gid}`,
|
||||
}]);
|
||||
|
||||
// Add announcement delay options if announcements are enabled
|
||||
if (settings.drawAnnouncements) {
|
||||
keyboard.push(
|
||||
ANNOUNCEMENT_DELAY_OPTIONS.map(seconds => ({
|
||||
text: `${selected(settings.announcementDelaySeconds || 0, seconds)} ${formatDelayOption(seconds)}`,
|
||||
callback_data: `group_announce_delay_${seconds}_${gid}`,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
keyboard.push([{
|
||||
text: `${onOff(settings.reminders)} Draw Reminders`,
|
||||
callback_data: 'group_toggle_reminders',
|
||||
}],
|
||||
callback_data: `group_toggle_reminders_${gid}`,
|
||||
}]);
|
||||
|
||||
// Add 3-tier reminder options if reminders are enabled
|
||||
if (settings.reminders) {
|
||||
// Get default values with fallback for migration
|
||||
const r1Enabled = settings.reminder1Enabled !== false;
|
||||
const r2Enabled = settings.reminder2Enabled === true;
|
||||
const r3Enabled = settings.reminder3Enabled === true;
|
||||
|
||||
// Get times with defaults
|
||||
const r1Time = settings.reminder1Time || { value: 1, unit: 'hours' as const };
|
||||
const r2Time = settings.reminder2Time || { value: 1, unit: 'days' as const };
|
||||
const r3Time = settings.reminder3Time || { value: 6, unit: 'days' as const };
|
||||
|
||||
// Reminder 1
|
||||
keyboard.push([{
|
||||
text: `${onOff(r1Enabled)} Reminder 1: ${formatReminderTime(r1Time)} before`,
|
||||
callback_data: `group_toggle_reminder1_${gid}`,
|
||||
}]);
|
||||
if (r1Enabled) {
|
||||
keyboard.push(getReminderTimeAdjustButtons(1, r1Time, gid));
|
||||
}
|
||||
|
||||
// Reminder 2
|
||||
keyboard.push([{
|
||||
text: `${onOff(r2Enabled)} Reminder 2: ${formatReminderTime(r2Time)} before`,
|
||||
callback_data: `group_toggle_reminder2_${gid}`,
|
||||
}]);
|
||||
if (r2Enabled) {
|
||||
keyboard.push(getReminderTimeAdjustButtons(2, r2Time, gid));
|
||||
}
|
||||
|
||||
// Reminder 3
|
||||
keyboard.push([{
|
||||
text: `${onOff(r3Enabled)} Reminder 3: ${formatReminderTime(r3Time)} before`,
|
||||
callback_data: `group_toggle_reminder3_${gid}`,
|
||||
}]);
|
||||
if (r3Enabled) {
|
||||
keyboard.push(getReminderTimeAdjustButtons(3, r3Time, gid));
|
||||
}
|
||||
}
|
||||
|
||||
keyboard.push(
|
||||
[{
|
||||
text: `${onOff(settings.ticketPurchaseAllowed)} Allow Ticket Purchases`,
|
||||
callback_data: 'group_toggle_purchases',
|
||||
callback_data: `group_toggle_purchases_${gid}`,
|
||||
}],
|
||||
[{
|
||||
text: '🔄 Refresh',
|
||||
callback_data: 'group_refresh',
|
||||
}],
|
||||
],
|
||||
};
|
||||
callback_data: `group_refresh_${gid}`,
|
||||
}]
|
||||
);
|
||||
|
||||
return { inline_keyboard: keyboard };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle refresh callback
|
||||
* groupId is extracted from callback data since settings are managed in DMs
|
||||
*/
|
||||
export async function handleGroupRefresh(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery
|
||||
query: TelegramBot.CallbackQuery,
|
||||
groupId: number
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
const dmChatId = query.message?.chat.id;
|
||||
const messageId = query.message?.message_id;
|
||||
|
||||
if (!chatId || !messageId) return;
|
||||
if (!dmChatId || !messageId) return;
|
||||
|
||||
// Refresh auto-delete timer
|
||||
scheduleSettingsMessageDeletion(bot, dmChatId, messageId);
|
||||
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Refreshed!' });
|
||||
|
||||
const settings = await groupStateManager.getGroup(chatId);
|
||||
const settings = await groupStateManager.getGroup(groupId);
|
||||
if (!settings) return;
|
||||
|
||||
await bot.editMessageText(
|
||||
messages.groups.settingsOverview(settings),
|
||||
{
|
||||
chat_id: chatId,
|
||||
chat_id: dmChatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getGroupSettingsKeyboard(settings),
|
||||
|
||||
@@ -4,23 +4,32 @@ import { getMainMenuKeyboard } from '../utils/keyboards';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
* Handle /help command
|
||||
* Handle /lottohelp command
|
||||
*/
|
||||
export async function handleHelpCommand(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
msg: TelegramBot.Message,
|
||||
isGroup: boolean = false
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
|
||||
if (userId) {
|
||||
logUserAction(userId, 'Viewed help');
|
||||
logUserAction(userId, 'Viewed help', { isGroup });
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
// Show group-specific help with admin commands
|
||||
await bot.sendMessage(chatId, messages.help.groupMessage, {
|
||||
parse_mode: 'Markdown',
|
||||
});
|
||||
} else {
|
||||
// Show user help in DM
|
||||
await bot.sendMessage(chatId, messages.help.message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default handleHelpCommand;
|
||||
|
||||
@@ -2,6 +2,7 @@ export { handleStart } from './start';
|
||||
export {
|
||||
handleAddressCommand,
|
||||
handleLightningAddressInput,
|
||||
handleLightningAddressCallback,
|
||||
} from './address';
|
||||
export {
|
||||
handleBuyCommand,
|
||||
@@ -11,6 +12,7 @@ export {
|
||||
} from './buy';
|
||||
export {
|
||||
handleTicketsCommand,
|
||||
handleTicketsPage,
|
||||
handleViewTicket,
|
||||
handleStatusCheck,
|
||||
} from './tickets';
|
||||
@@ -21,6 +23,11 @@ export {
|
||||
handleCancel,
|
||||
handleMenuCallback,
|
||||
} from './menu';
|
||||
export {
|
||||
handleSettingsCommand,
|
||||
handleSettingsCallback,
|
||||
handleDisplayNameInput,
|
||||
} from './settings';
|
||||
export {
|
||||
handleBotAddedToGroup,
|
||||
handleBotRemovedFromGroup,
|
||||
|
||||
173
telegram_bot/src/handlers/settings.ts
Normal file
173
telegram_bot/src/handlers/settings.ts
Normal 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,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { stateManager } from '../services/state';
|
||||
import { logger, logUserAction } from '../services/logger';
|
||||
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards';
|
||||
import { getMainMenuKeyboard, getLightningAddressKeyboard } from '../utils/keyboards';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
@@ -10,6 +10,7 @@ import { messages } from '../messages';
|
||||
export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
const username = msg.from?.username;
|
||||
|
||||
if (!userId) {
|
||||
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
|
||||
@@ -17,7 +18,7 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Started bot', {
|
||||
username: msg.from?.username,
|
||||
username: username,
|
||||
firstName: msg.from?.first_name,
|
||||
});
|
||||
|
||||
@@ -29,7 +30,7 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P
|
||||
// Create new user
|
||||
user = await stateManager.createUser(
|
||||
userId,
|
||||
msg.from?.username,
|
||||
username,
|
||||
msg.from?.first_name,
|
||||
msg.from?.last_name
|
||||
);
|
||||
@@ -40,9 +41,9 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P
|
||||
|
||||
// Check if lightning address is set
|
||||
if (!user.lightningAddress) {
|
||||
await bot.sendMessage(chatId, messages.start.needAddress, {
|
||||
await bot.sendMessage(chatId, messages.start.needAddressWithOptions(username), {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
reply_markup: getLightningAddressKeyboard(username),
|
||||
});
|
||||
|
||||
await stateManager.updateUserState(userId, 'awaiting_lightning_address');
|
||||
|
||||
@@ -7,12 +7,25 @@ import { getMainMenuKeyboard, getViewTicketKeyboard } from '../utils/keyboards';
|
||||
import { formatSats, formatDate } from '../utils/format';
|
||||
import { messages } from '../messages';
|
||||
|
||||
const TICKETS_PER_PAGE = 5;
|
||||
|
||||
interface PurchaseInfo {
|
||||
id: string;
|
||||
cycleId: string;
|
||||
ticketCount: number;
|
||||
scheduledAt: string;
|
||||
invoiceStatus: string;
|
||||
isWinner: boolean;
|
||||
hasDrawn: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /tickets command or "My Tickets" button
|
||||
*/
|
||||
export async function handleTicketsCommand(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
msg: TelegramBot.Message,
|
||||
page: number = 0
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
@@ -22,8 +35,46 @@ export async function handleTicketsCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Viewed tickets');
|
||||
logUserAction(userId, 'Viewed tickets', { page });
|
||||
|
||||
await sendTicketsList(bot, chatId, userId, page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tickets page navigation callback
|
||||
*/
|
||||
export async function handleTicketsPage(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery,
|
||||
page: number
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
const userId = query.from.id;
|
||||
const messageId = query.message?.message_id;
|
||||
|
||||
if (!chatId || !messageId) return;
|
||||
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
|
||||
// Delete old message and send new one
|
||||
try {
|
||||
await bot.deleteMessage(chatId, messageId);
|
||||
} catch (e) {
|
||||
// Ignore delete errors
|
||||
}
|
||||
|
||||
await sendTicketsList(bot, chatId, userId, page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tickets list with pagination
|
||||
*/
|
||||
async function sendTicketsList(
|
||||
bot: TelegramBot,
|
||||
chatId: number,
|
||||
userId: number,
|
||||
page: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
|
||||
@@ -32,8 +83,12 @@ export async function handleTicketsCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user's purchase IDs from state
|
||||
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 10);
|
||||
// Get current cycle to identify current round tickets
|
||||
const currentJackpot = await apiClient.getNextJackpot();
|
||||
const currentCycleId = currentJackpot?.cycle?.id;
|
||||
|
||||
// Get ALL user's purchase IDs from state (increase limit for pagination)
|
||||
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 100);
|
||||
|
||||
if (purchaseIds.length === 0) {
|
||||
await bot.sendMessage(chatId, messages.tickets.empty, {
|
||||
@@ -44,21 +99,21 @@ export async function handleTicketsCommand(
|
||||
}
|
||||
|
||||
// Fetch status for each purchase
|
||||
const purchases: Array<{
|
||||
id: string;
|
||||
ticketCount: number;
|
||||
scheduledAt: string;
|
||||
invoiceStatus: string;
|
||||
isWinner: boolean;
|
||||
hasDrawn: boolean;
|
||||
}> = [];
|
||||
const allPurchases: PurchaseInfo[] = [];
|
||||
|
||||
for (const purchaseId of purchaseIds) {
|
||||
try {
|
||||
const status = await apiClient.getTicketStatus(purchaseId);
|
||||
if (status) {
|
||||
purchases.push({
|
||||
// Skip expired invoices - don't show tickets that were never paid
|
||||
if (status.purchase.invoice_status === 'expired') {
|
||||
logger.debug('Skipping expired invoice', { purchaseId });
|
||||
continue;
|
||||
}
|
||||
|
||||
allPurchases.push({
|
||||
id: status.purchase.id,
|
||||
cycleId: status.purchase.cycle_id,
|
||||
ticketCount: status.purchase.number_of_tickets,
|
||||
scheduledAt: status.cycle.scheduled_at,
|
||||
invoiceStatus: status.purchase.invoice_status,
|
||||
@@ -72,7 +127,7 @@ export async function handleTicketsCommand(
|
||||
}
|
||||
}
|
||||
|
||||
if (purchases.length === 0) {
|
||||
if (allPurchases.length === 0) {
|
||||
await bot.sendMessage(chatId, messages.tickets.notFound, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
@@ -80,56 +135,134 @@ export async function handleTicketsCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
// Format purchases list
|
||||
let message = messages.tickets.header;
|
||||
// Separate current round and past tickets
|
||||
const currentRoundTickets = allPurchases.filter(p =>
|
||||
p.cycleId === currentCycleId && !p.hasDrawn
|
||||
);
|
||||
const pastTickets = allPurchases.filter(p =>
|
||||
p.cycleId !== currentCycleId || p.hasDrawn
|
||||
);
|
||||
|
||||
for (let i = 0; i < purchases.length; i++) {
|
||||
const p = purchases[i];
|
||||
// Sort past tickets by date (newest first)
|
||||
pastTickets.sort((a, b) =>
|
||||
new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime()
|
||||
);
|
||||
|
||||
// Build message
|
||||
let message = '';
|
||||
const inlineKeyboard: TelegramBot.InlineKeyboardButton[][] = [];
|
||||
|
||||
// Current round section (always shown on page 0)
|
||||
if (page === 0 && currentRoundTickets.length > 0) {
|
||||
message += `🎯 *Current Round*\n\n`;
|
||||
|
||||
for (const p of currentRoundTickets) {
|
||||
const drawDate = new Date(p.scheduledAt);
|
||||
const statusInfo = getStatusInfo(p);
|
||||
message += `${statusInfo.emoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} – Draw: ${formatDate(drawDate)}\n`;
|
||||
|
||||
let statusEmoji: string;
|
||||
let statusText: string;
|
||||
|
||||
if (p.invoiceStatus === 'pending') {
|
||||
statusEmoji = '⏳';
|
||||
statusText = messages.tickets.statusPending;
|
||||
} else if (p.invoiceStatus === 'expired') {
|
||||
statusEmoji = '❌';
|
||||
statusText = messages.tickets.statusExpired;
|
||||
} else if (!p.hasDrawn) {
|
||||
statusEmoji = '🎟';
|
||||
statusText = messages.tickets.statusActive;
|
||||
} else if (p.isWinner) {
|
||||
statusEmoji = '🏆';
|
||||
statusText = messages.tickets.statusWon;
|
||||
} else {
|
||||
statusEmoji = '😔';
|
||||
statusText = messages.tickets.statusLost;
|
||||
}
|
||||
|
||||
message += `${i + 1}. ${statusEmoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} – ${formatDate(drawDate)} – ${statusText}\n`;
|
||||
}
|
||||
|
||||
message += messages.tickets.tapForDetails;
|
||||
|
||||
// Create inline buttons for each purchase
|
||||
const inlineKeyboard = purchases.map((p, i) => [{
|
||||
text: `${i + 1}. View Ticket #${p.id.substring(0, 8)}...`,
|
||||
inlineKeyboard.push([{
|
||||
text: `🎟 View Current Tickets #${p.id.substring(0, 8)}...`,
|
||||
callback_data: `view_ticket_${p.id}`,
|
||||
}]);
|
||||
}
|
||||
|
||||
if (pastTickets.length > 0) {
|
||||
message += `\n📜 *Past Tickets*\n\n`;
|
||||
}
|
||||
} else if (page === 0) {
|
||||
message += messages.tickets.header;
|
||||
} else {
|
||||
message += `📜 *Past Tickets (Page ${page + 1})*\n\n`;
|
||||
}
|
||||
|
||||
// Calculate pagination for past tickets
|
||||
const startIdx = page === 0 && currentRoundTickets.length > 0
|
||||
? 0
|
||||
: page * TICKETS_PER_PAGE - (currentRoundTickets.length > 0 ? 0 : 0);
|
||||
const adjustedStartIdx = page === 0 ? 0 : (page - 1) * TICKETS_PER_PAGE + (currentRoundTickets.length > 0 ? TICKETS_PER_PAGE : 0);
|
||||
const pageStartIdx = page === 0 ? 0 : (page - (currentRoundTickets.length > 0 ? 1 : 0)) * TICKETS_PER_PAGE;
|
||||
const ticketsToShow = pastTickets.slice(pageStartIdx, pageStartIdx + TICKETS_PER_PAGE);
|
||||
|
||||
// Past tickets section
|
||||
for (let i = 0; i < ticketsToShow.length; i++) {
|
||||
const p = ticketsToShow[i];
|
||||
const drawDate = new Date(p.scheduledAt);
|
||||
const statusInfo = getStatusInfo(p);
|
||||
const globalIdx = pageStartIdx + i + 1;
|
||||
|
||||
message += `${globalIdx}. ${statusInfo.emoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} – ${formatDate(drawDate)} – ${statusInfo.text}\n`;
|
||||
|
||||
inlineKeyboard.push([{
|
||||
text: `${globalIdx}. ${statusInfo.emoji} View #${p.id.substring(0, 8)}...`,
|
||||
callback_data: `view_ticket_${p.id}`,
|
||||
}]);
|
||||
}
|
||||
|
||||
// Pagination buttons
|
||||
const totalPastPages = Math.ceil(pastTickets.length / TICKETS_PER_PAGE);
|
||||
const hasCurrentRound = currentRoundTickets.length > 0;
|
||||
const effectivePage = hasCurrentRound ? page : page;
|
||||
const maxPage = hasCurrentRound ? totalPastPages : totalPastPages - 1;
|
||||
|
||||
const navButtons: TelegramBot.InlineKeyboardButton[] = [];
|
||||
|
||||
if (page > 0) {
|
||||
navButtons.push({
|
||||
text: '⬅️ Previous',
|
||||
callback_data: `tickets_page_${page - 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (pageStartIdx + TICKETS_PER_PAGE < pastTickets.length) {
|
||||
navButtons.push({
|
||||
text: '➡️ Next',
|
||||
callback_data: `tickets_page_${page + 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (navButtons.length > 0) {
|
||||
inlineKeyboard.push(navButtons);
|
||||
}
|
||||
|
||||
// Add page info if paginated
|
||||
if (pastTickets.length > TICKETS_PER_PAGE) {
|
||||
const currentPageNum = page + 1;
|
||||
const totalPages = Math.ceil(pastTickets.length / TICKETS_PER_PAGE) + (hasCurrentRound ? 1 : 0);
|
||||
message += `\n_Page ${currentPageNum} of ${totalPages}_`;
|
||||
}
|
||||
|
||||
message += `\n\nTap a ticket to view details.`;
|
||||
|
||||
await bot.sendMessage(chatId, message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: { inline_keyboard: inlineKeyboard },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in handleTicketsCommand', { error, userId });
|
||||
logger.error('Error in sendTicketsList', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.fetchTicketsFailed, {
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status emoji and text for a purchase
|
||||
*/
|
||||
function getStatusInfo(p: PurchaseInfo): { emoji: string; text: string } {
|
||||
if (p.invoiceStatus === 'pending') {
|
||||
return { emoji: '⏳', text: messages.tickets.statusPending };
|
||||
} else if (p.invoiceStatus === 'expired') {
|
||||
return { emoji: '❌', text: messages.tickets.statusExpired };
|
||||
} else if (!p.hasDrawn) {
|
||||
return { emoji: '🎟', text: messages.tickets.statusActive };
|
||||
} else if (p.isWinner) {
|
||||
return { emoji: '🏆', text: messages.tickets.statusWon };
|
||||
} else {
|
||||
return { emoji: '😔', text: messages.tickets.statusLost };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle viewing a specific ticket
|
||||
*/
|
||||
@@ -254,6 +387,7 @@ export async function handleStatusCheck(
|
||||
|
||||
export default {
|
||||
handleTicketsCommand,
|
||||
handleTicketsPage,
|
||||
handleViewTicket,
|
||||
handleStatusCheck,
|
||||
};
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import config from './config';
|
||||
import { botDatabase } from './services/database';
|
||||
import { stateManager } from './services/state';
|
||||
import { groupStateManager } from './services/groupState';
|
||||
import { apiClient } from './services/api';
|
||||
import { logger, logUserAction } from './services/logger';
|
||||
import { notificationScheduler } from './services/notificationScheduler';
|
||||
import {
|
||||
handleStart,
|
||||
handleAddressCommand,
|
||||
handleLightningAddressInput,
|
||||
handleLightningAddressCallback,
|
||||
handleBuyCommand,
|
||||
handleTicketAmountSelection,
|
||||
handleCustomTicketAmount,
|
||||
handlePurchaseConfirmation,
|
||||
handleTicketsCommand,
|
||||
handleTicketsPage,
|
||||
handleViewTicket,
|
||||
handleStatusCheck,
|
||||
handleWinsCommand,
|
||||
@@ -20,6 +24,9 @@ import {
|
||||
handleMenuCommand,
|
||||
handleCancel,
|
||||
handleMenuCallback,
|
||||
handleSettingsCommand,
|
||||
handleSettingsCallback,
|
||||
handleDisplayNameInput,
|
||||
handleBotAddedToGroup,
|
||||
handleBotRemovedFromGroup,
|
||||
handleGroupSettings,
|
||||
@@ -90,7 +97,7 @@ bot.onText(/\/start/, async (msg) => {
|
||||
if (isGroupChat(msg)) {
|
||||
await bot.sendMessage(
|
||||
msg.chat.id,
|
||||
`⚡ *Lightning Jackpot Bot*\n\nTo buy tickets and manage your account, message me directly!\n\nUse /jackpot to see current jackpot info.\nAdmins: Use /settings to configure the bot.`,
|
||||
`⚡ *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' }
|
||||
);
|
||||
return;
|
||||
@@ -99,8 +106,8 @@ bot.onText(/\/start/, async (msg) => {
|
||||
await handleStart(bot, msg);
|
||||
});
|
||||
|
||||
// Handle /buy command
|
||||
bot.onText(/\/buy/, async (msg) => {
|
||||
// Handle /buyticket command
|
||||
bot.onText(/\/buyticket/, async (msg) => {
|
||||
if (!shouldProcessMessage(msg.message_id)) return;
|
||||
|
||||
// Check if in group
|
||||
@@ -143,8 +150,8 @@ bot.onText(/\/wins/, async (msg) => {
|
||||
await handleWinsCommand(bot, msg);
|
||||
});
|
||||
|
||||
// Handle /address command
|
||||
bot.onText(/\/address/, async (msg) => {
|
||||
// Handle /lottoaddress command
|
||||
bot.onText(/\/lottoaddress/, async (msg) => {
|
||||
if (!shouldProcessMessage(msg.message_id)) return;
|
||||
|
||||
// Only in private chat
|
||||
@@ -156,8 +163,8 @@ bot.onText(/\/address/, async (msg) => {
|
||||
await handleAddressCommand(bot, msg);
|
||||
});
|
||||
|
||||
// Handle /menu command
|
||||
bot.onText(/\/menu/, async (msg) => {
|
||||
// Handle /lottomenu command
|
||||
bot.onText(/\/lottomenu/, async (msg) => {
|
||||
if (!shouldProcessMessage(msg.message_id)) return;
|
||||
|
||||
// Only in private chat
|
||||
@@ -169,10 +176,10 @@ bot.onText(/\/menu/, async (msg) => {
|
||||
await handleMenuCommand(bot, msg);
|
||||
});
|
||||
|
||||
// Handle /help command
|
||||
bot.onText(/\/help/, async (msg) => {
|
||||
// Handle /lottohelp command
|
||||
bot.onText(/\/lottohelp/, async (msg) => {
|
||||
if (!shouldProcessMessage(msg.message_id)) return;
|
||||
await handleHelpCommand(bot, msg);
|
||||
await handleHelpCommand(bot, msg, isGroupChat(msg));
|
||||
});
|
||||
|
||||
// Handle /jackpot command (works in groups and DMs)
|
||||
@@ -197,7 +204,7 @@ bot.onText(/\/jackpot/, async (msg) => {
|
||||
⏰ *Draw at:* ${formatDate(drawTime)}
|
||||
⏳ *Time left:* ${formatTimeUntil(drawTime)}
|
||||
|
||||
Use /buy to get your tickets! 🍀`;
|
||||
Use /buyticket to get your tickets! 🍀`;
|
||||
|
||||
await bot.sendMessage(msg.chat.id, message, { parse_mode: 'Markdown' });
|
||||
} catch (error) {
|
||||
@@ -206,8 +213,8 @@ Use /buy to get your tickets! 🍀`;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle /settings command (groups only, admin only)
|
||||
bot.onText(/\/settings/, async (msg) => {
|
||||
// Handle /lottosettings command (groups only, admin only)
|
||||
bot.onText(/\/lottosettings/, async (msg) => {
|
||||
if (!shouldProcessMessage(msg.message_id)) return;
|
||||
await handleGroupSettings(bot, msg);
|
||||
});
|
||||
@@ -231,6 +238,33 @@ bot.on('message', async (msg) => {
|
||||
|
||||
// Handle menu button presses
|
||||
switch (text) {
|
||||
case '🎰 Upcoming Jackpot':
|
||||
try {
|
||||
const jackpot = await apiClient.getNextJackpot();
|
||||
|
||||
if (!jackpot) {
|
||||
await bot.sendMessage(msg.chat.id, messages.buy.noActiveJackpot, {
|
||||
parse_mode: 'Markdown',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const drawTime = new Date(jackpot.cycle.scheduled_at);
|
||||
const jackpotMessage = `🎰 *Upcoming Jackpot*
|
||||
|
||||
💰 *Prize Pool:* ${formatSats(jackpot.cycle.pot_total_sats)} sats
|
||||
🎟 *Ticket Price:* ${formatSats(jackpot.lottery.ticket_price_sats)} sats
|
||||
⏰ *Draw at:* ${formatDate(drawTime)}
|
||||
⏳ *Time left:* ${formatTimeUntil(drawTime)}
|
||||
|
||||
Use /buyticket to get your tickets! 🍀`;
|
||||
|
||||
await bot.sendMessage(msg.chat.id, jackpotMessage, { parse_mode: 'Markdown' });
|
||||
} catch (error) {
|
||||
logger.error('Error showing jackpot', { error });
|
||||
await bot.sendMessage(msg.chat.id, messages.errors.systemUnavailable);
|
||||
}
|
||||
return;
|
||||
case '🎟 Buy Tickets':
|
||||
await handleBuyCommand(bot, msg);
|
||||
return;
|
||||
@@ -243,6 +277,9 @@ bot.on('message', async (msg) => {
|
||||
case '⚡ Lightning Address':
|
||||
await handleAddressCommand(bot, msg);
|
||||
return;
|
||||
case '⚙️ Settings':
|
||||
await handleSettingsCommand(bot, msg);
|
||||
return;
|
||||
case 'ℹ️ Help':
|
||||
await handleHelpCommand(bot, msg);
|
||||
return;
|
||||
@@ -264,6 +301,12 @@ bot.on('message', async (msg) => {
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
// Handle display name input
|
||||
if (user.state === 'awaiting_display_name') {
|
||||
await handleDisplayNameInput(bot, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle custom ticket amount input
|
||||
if (user.state === 'awaiting_ticket_amount') {
|
||||
const handled = await handleCustomTicketAmount(bot, msg);
|
||||
@@ -297,16 +340,55 @@ bot.on('callback_query', async (query) => {
|
||||
logUserAction(query.from.id, 'Callback', { data });
|
||||
|
||||
try {
|
||||
// Handle group settings toggles
|
||||
// Handle group settings toggles (now includes group ID: group_toggle_enabled_-123456789)
|
||||
if (data.startsWith('group_toggle_')) {
|
||||
const action = data.replace('group_', '');
|
||||
await handleGroupSettingsCallback(bot, query, action);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle group refresh
|
||||
if (data === 'group_refresh') {
|
||||
await handleGroupRefresh(bot, query);
|
||||
// Handle group reminder time adjustment (reminder1_add_1_hours_-123456789, etc.)
|
||||
if (data.match(/^group_reminder\d_(add|sub)_\d+_(minutes|hours|days)_-?\d+$/)) {
|
||||
const action = data.replace('group_', '');
|
||||
await handleGroupSettingsCallback(bot, query, action);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle group announcement delay selection (group_announce_delay_30_-123456789)
|
||||
if (data.match(/^group_announce_delay_\d+_-?\d+$/)) {
|
||||
const action = data.replace('group_', '');
|
||||
await handleGroupSettingsCallback(bot, query, action);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle new jackpot announcement delay selection (group_newjackpot_delay_5_-123456789)
|
||||
if (data.match(/^group_newjackpot_delay_\d+_-?\d+$/)) {
|
||||
const action = data.replace('group_', '');
|
||||
await handleGroupSettingsCallback(bot, query, action);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle group refresh (group_refresh_-123456789)
|
||||
if (data.match(/^group_refresh_-?\d+$/)) {
|
||||
const groupIdMatch = data.match(/^group_refresh_(-?\d+)$/);
|
||||
if (groupIdMatch) {
|
||||
const groupId = parseInt(groupIdMatch[1], 10);
|
||||
await handleGroupRefresh(bot, query, groupId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle user settings callbacks
|
||||
if (data.startsWith('settings_')) {
|
||||
const action = data.replace('settings_', '');
|
||||
await handleSettingsCallback(bot, query, action);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle lightning address selection (21Tipbot/Bittip)
|
||||
if (data.startsWith('ln_addr_')) {
|
||||
const action = data.replace('ln_addr_', '');
|
||||
await handleLightningAddressCallback(bot, query, action);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -342,6 +424,15 @@ bot.on('callback_query', async (query) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle tickets pagination
|
||||
if (data.startsWith('tickets_page_')) {
|
||||
const page = parseInt(data.replace('tickets_page_', ''), 10);
|
||||
if (!isNaN(page)) {
|
||||
await handleTicketsPage(bot, query, page);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle view ticket
|
||||
if (data.startsWith('view_ticket_')) {
|
||||
const purchaseId = data.replace('view_ticket_', '');
|
||||
@@ -389,6 +480,7 @@ async function shutdown(): Promise<void> {
|
||||
bot.stopPolling();
|
||||
await stateManager.close();
|
||||
await groupStateManager.close();
|
||||
botDatabase.close();
|
||||
logger.info('Shutdown complete');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -399,28 +491,31 @@ process.on('SIGTERM', shutdown);
|
||||
// Start bot
|
||||
async function start(): Promise<void> {
|
||||
try {
|
||||
// Initialize state managers
|
||||
// Initialize SQLite database
|
||||
botDatabase.init();
|
||||
|
||||
// Initialize state managers (now use SQLite)
|
||||
await stateManager.init();
|
||||
await groupStateManager.init(config.redis.url);
|
||||
await groupStateManager.init();
|
||||
|
||||
// Set bot commands for private chats
|
||||
await bot.setMyCommands([
|
||||
{ command: 'start', description: 'Start the bot' },
|
||||
{ command: 'menu', description: 'Show main menu' },
|
||||
{ command: 'buy', description: 'Buy lottery tickets' },
|
||||
{ command: 'lottomenu', description: 'Show main menu' },
|
||||
{ command: 'buyticket', description: 'Buy lottery tickets' },
|
||||
{ command: 'tickets', description: 'View your tickets' },
|
||||
{ command: 'wins', description: 'View your past wins' },
|
||||
{ command: 'address', description: 'Update Lightning Address' },
|
||||
{ command: 'lottoaddress', description: 'Update Lightning Address' },
|
||||
{ command: 'jackpot', description: 'View current jackpot info' },
|
||||
{ command: 'help', description: 'Help & information' },
|
||||
{ command: 'lottohelp', description: 'Help & information' },
|
||||
]);
|
||||
|
||||
// Set bot commands for groups (different scope)
|
||||
await bot.setMyCommands(
|
||||
[
|
||||
{ command: 'jackpot', description: 'View current jackpot info' },
|
||||
{ command: 'settings', description: 'Group settings (admin only)' },
|
||||
{ command: 'help', description: 'Help & information' },
|
||||
{ command: 'lottosettings', description: 'Group settings (admin only)' },
|
||||
{ command: 'lottohelp', description: 'Help & information' },
|
||||
],
|
||||
{ scope: { type: 'all_group_chats' } }
|
||||
);
|
||||
@@ -431,10 +526,15 @@ async function start(): Promise<void> {
|
||||
username: botInfo.username,
|
||||
});
|
||||
|
||||
// Initialize and start notification scheduler
|
||||
notificationScheduler.init(bot);
|
||||
notificationScheduler.start();
|
||||
|
||||
logger.info('⚡ Lightning Jackpot Telegram Bot is running!');
|
||||
logger.info(`📡 API URL: ${config.api.baseUrl}`);
|
||||
logger.info(`🌐 Frontend URL: ${config.frontend.baseUrl}`);
|
||||
logger.info('👥 Group support enabled');
|
||||
logger.info('📢 Notification scheduler started');
|
||||
} catch (error) {
|
||||
logger.error('Failed to start bot', { error });
|
||||
process.exit(1);
|
||||
|
||||
@@ -8,7 +8,7 @@ export const messages = {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
errors: {
|
||||
userNotIdentified: '❌ Could not identify user.',
|
||||
startFirst: '❌ Please start the bot first with /start',
|
||||
startFirst: '❌ Hold on! Before using this command, initiate a private chat with me (@LightningLottoBot).',
|
||||
generic: '❌ An error occurred. Please try again.',
|
||||
startAgain: '❌ An error occurred. Please try again with /start',
|
||||
systemUnavailable: '❌ The lottery system is temporarily unavailable. Please try again soon.',
|
||||
@@ -18,8 +18,19 @@ export const messages = {
|
||||
ticketNotFound: '❌ Ticket not found.',
|
||||
fetchTicketDetailsFailed: '❌ Failed to fetch ticket details.',
|
||||
checkStatusFailed: '❌ Failed to check status',
|
||||
noPendingPurchase: '❌ No pending purchase. Please start again with /buy',
|
||||
noPendingPurchase: '❌ No pending purchase. Please start again with /buyticket',
|
||||
setAddressFirst: '❌ Please set your Lightning Address first.',
|
||||
maintenance: (message: string) => `🔧 *Maintenance Mode*
|
||||
|
||||
${message}
|
||||
|
||||
Please try again later. We'll be back soon! ⚡`,
|
||||
|
||||
maintenancePending: `⏳ *Maintenance Scheduled*
|
||||
|
||||
Maintenance will begin after the current draw completes.
|
||||
|
||||
This is your *last chance* to buy tickets for the current round! 🎟️`,
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -42,6 +53,21 @@ You can buy Bitcoin Lightning lottery tickets, and if you win, your prize is pai
|
||||
|
||||
Please send your Lightning Address now:`,
|
||||
|
||||
needAddressWithOptions: (username?: string) => {
|
||||
let msg = `Before you can play, I need your Lightning Address to send any winnings.\n\n`;
|
||||
|
||||
if (username) {
|
||||
msg += `🤖 *Quick Setup* - Choose a tip bot below:\n\n`;
|
||||
msg += `Or send me your Lightning Address to set a custom one.\n`;
|
||||
msg += `*Example:* \`yourname@getalby.com\``;
|
||||
} else {
|
||||
msg += `*Example:* \`yourname@getalby.com\`\n\n`;
|
||||
msg += `Please send your Lightning Address now:`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
},
|
||||
|
||||
addressSet: (address: string) =>
|
||||
`✅ Your payout address is set to: \`${address}\`
|
||||
|
||||
@@ -60,12 +86,40 @@ Use the menu below to get started! Good luck! 🍀`,
|
||||
|
||||
Send me your new Lightning Address to update it:`,
|
||||
|
||||
currentAddressWithOptions: (address: string, username?: string) => {
|
||||
let msg = `⚡ *Your Current Payout Address:*\n\`${address}\`\n\n`;
|
||||
|
||||
if (username) {
|
||||
msg += `🤖 *Quick Options* - Choose a tip bot below:\n\n`;
|
||||
msg += `Or send me your Lightning Address to set a custom one.`;
|
||||
} else {
|
||||
msg += `Send me your new Lightning Address to update it:`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
},
|
||||
|
||||
noAddressSet: `⚡ You don't have a Lightning Address set yet.
|
||||
|
||||
Send me your Lightning Address now:
|
||||
|
||||
*Example:* \`yourname@getalby.com\``,
|
||||
|
||||
noAddressSetWithOptions: (username?: string) => {
|
||||
let msg = `⚡ You don't have a Lightning Address set yet.\n\n`;
|
||||
|
||||
if (username) {
|
||||
msg += `🤖 *Quick Setup* - Choose a tip bot below:\n\n`;
|
||||
msg += `Or send me your Lightning Address to set a custom one.\n`;
|
||||
msg += `*Example:* \`yourname@getalby.com\``;
|
||||
} else {
|
||||
msg += `Send me your Lightning Address now:\n\n`;
|
||||
msg += `*Example:* \`yourname@getalby.com\``;
|
||||
}
|
||||
|
||||
return msg;
|
||||
},
|
||||
|
||||
invalidFormat: `❌ That doesn't look like a valid Lightning Address.
|
||||
|
||||
*Format:* \`username@domain.com\`
|
||||
@@ -73,6 +127,31 @@ Send me your Lightning Address now:
|
||||
|
||||
Please try again:`,
|
||||
|
||||
verifying: '🔍 Verifying your Lightning Address...',
|
||||
|
||||
verificationFailed: (address: string, error?: string) =>
|
||||
`❌ *Could not verify Lightning Address*
|
||||
|
||||
Address: \`${address}\`
|
||||
${error ? `Error: ${error}\n` : ''}
|
||||
Please check the address and try again, or choose a different option:`,
|
||||
|
||||
verifyingService: (service: string, address: string) =>
|
||||
`🔍 Verifying your ${service} address: \`${address}\`...`,
|
||||
|
||||
serviceNotSetup: (service: string, error?: string) =>
|
||||
`❌ *${service} address not found*
|
||||
|
||||
It looks like you haven't set up ${service} yet, or your username doesn't match.
|
||||
|
||||
${error ? `Error: ${error}\n\n` : ''}Please set up ${service} first, or enter a different Lightning Address:`,
|
||||
|
||||
noUsername: `❌ You don't have a Telegram username set!
|
||||
|
||||
To use 21Tipbot or Bittip, you need a Telegram username.
|
||||
|
||||
Please set a username in Telegram settings, or enter a custom Lightning Address:`,
|
||||
|
||||
firstTimeSuccess: (address: string) =>
|
||||
`✅ *Perfect!* I'll use \`${address}\` to send any winnings.
|
||||
|
||||
@@ -153,16 +232,14 @@ Confirm this purchase?`,
|
||||
invoiceCaption: (
|
||||
ticketCount: number,
|
||||
totalAmount: string,
|
||||
paymentRequest: string,
|
||||
expiryMinutes: number
|
||||
) =>
|
||||
`🎟 *${ticketCount} ticket${ticketCount > 1 ? 's' : ''}*
|
||||
💰 *Amount:* ${totalAmount} sats
|
||||
⏳ Expires in ${expiryMinutes} minutes`,
|
||||
|
||||
\`${paymentRequest}\`
|
||||
|
||||
⏳ This invoice expires in ${expiryMinutes} minutes.
|
||||
I'll notify you when payment is received!`,
|
||||
invoiceString: (paymentRequest: string) =>
|
||||
`\`${paymentRequest}\``,
|
||||
|
||||
paymentReceived: (ticketNumbers: string, drawTime: string) =>
|
||||
`🎉 *Payment Received!*
|
||||
@@ -180,13 +257,13 @@ Good luck! 🍀 I'll notify you after the draw!`,
|
||||
|
||||
No payment was received in time. No tickets were issued.
|
||||
|
||||
Use /buy to try again.`,
|
||||
Use /buyticket to try again.`,
|
||||
|
||||
invoiceExpiredShort: `❌ *Invoice Expired*
|
||||
|
||||
This invoice has expired. No tickets were issued.
|
||||
|
||||
Use /buy to try again.`,
|
||||
Use /buyticket to try again.`,
|
||||
|
||||
jackpotUnavailable: '❌ Jackpot is no longer available.',
|
||||
},
|
||||
@@ -201,13 +278,13 @@ Use /buy to try again.`,
|
||||
|
||||
You haven't purchased any tickets yet!
|
||||
|
||||
Use /buy to get started! 🎟`,
|
||||
Use /buyticket to get started! 🎟`,
|
||||
|
||||
notFound: `🧾 *Your Tickets*
|
||||
|
||||
No ticket purchases found. Purchase history may have expired.
|
||||
|
||||
Use /buy to get new tickets! 🎟`,
|
||||
Use /buyticket to get new tickets! 🎟`,
|
||||
|
||||
tapForDetails: `\nTap a ticket below for details:`,
|
||||
|
||||
@@ -266,13 +343,13 @@ ${statusSection}`,
|
||||
|
||||
You haven't purchased any tickets yet, so no wins to show!
|
||||
|
||||
Use /buy to get started! 🎟`,
|
||||
Use /buyticket to get started! 🎟`,
|
||||
|
||||
noWinsYet: `🏆 *Your Wins*
|
||||
|
||||
You haven't won any jackpots yet. Keep playing!
|
||||
|
||||
Use /buy to get more tickets! 🎟🍀`,
|
||||
Use /buyticket to get more tickets! 🎟🍀`,
|
||||
|
||||
header: (totalWinnings: string, paidWinnings: string) =>
|
||||
`🏆 *Your Wins*
|
||||
@@ -300,20 +377,47 @@ This is the Lightning Jackpot lottery bot! Buy tickets with Bitcoin Lightning, a
|
||||
5️⃣ If you win, sats are sent to your address instantly!
|
||||
|
||||
*Commands:*
|
||||
• /buy — Buy lottery tickets
|
||||
• /buyticket — Buy lottery tickets
|
||||
• /tickets — View your tickets
|
||||
• /wins — View your past wins
|
||||
• /address — Update Lightning Address
|
||||
• /menu — Show main menu
|
||||
• /help — Show this help
|
||||
• /lottoaddress — Update Lightning Address
|
||||
• /lottomenu — Show main menu
|
||||
• /lottohelp — Show this help
|
||||
• /jackpot — View current jackpot info
|
||||
|
||||
*Settings (use ⚙️ Settings button):*
|
||||
• Set your display name (shown if you win)
|
||||
• Enable/disable draw reminders
|
||||
• Enable/disable draw results notifications
|
||||
• Enable/disable new jackpot alerts
|
||||
|
||||
*Tips:*
|
||||
🎟 Each ticket is one chance to win
|
||||
💰 Prize pool grows with each ticket sold
|
||||
⚡ Winnings are paid instantly via Lightning
|
||||
🔔 You'll be notified after every draw
|
||||
🔔 You'll be notified after every draw you participate in
|
||||
|
||||
Good luck! 🍀`,
|
||||
|
||||
groupMessage: `⚡🎰 *Lightning Jackpot Bot - Group Help* 🎰⚡
|
||||
|
||||
*User Commands:*
|
||||
• /jackpot — View current jackpot info
|
||||
• /buyticket — Buy lottery tickets
|
||||
• /lottohelp — Show this help
|
||||
|
||||
*Admin Commands:*
|
||||
• /lottosettings — Configure bot settings for this group
|
||||
|
||||
*Admin Settings:*
|
||||
👉 *Bot Enabled* — Enable/disable the bot in this group
|
||||
👉 *Draw Announcements* — Announce draw winners in this group
|
||||
👉 *Draw Reminders* — Send reminders before draws
|
||||
👉 *Ticket Purchases* — Allow /buyticket command in group
|
||||
|
||||
💡 *Tip:* For privacy, ticket purchases in groups can be disabled. Users can always buy tickets by messaging the bot directly.
|
||||
|
||||
To buy tickets privately, message me directly!`,
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -360,29 +464,118 @@ Good luck next round! 🍀`,
|
||||
winnerName: string,
|
||||
winningTicket: string,
|
||||
prizeSats: string,
|
||||
totalTickets: number
|
||||
totalTickets: number,
|
||||
winnerAddress?: string
|
||||
) =>
|
||||
`🎰 *JACKPOT DRAW COMPLETE!* 🎰
|
||||
|
||||
🏆 *Winner:* ${winnerName}
|
||||
⚡ *Address:* \`${winnerAddress || 'N/A'}\`
|
||||
🎟 *Winning Ticket:* #${winningTicket}
|
||||
💰 *Prize:* ${prizeSats} sats
|
||||
📊 *Total Tickets:* ${totalTickets}
|
||||
|
||||
Congratulations to the winner! ⚡
|
||||
|
||||
Use /buy to enter the next draw! 🍀`,
|
||||
Use /buyticket to enter the next draw! 🍀`,
|
||||
|
||||
drawReminder: (potSats: string, drawTime: string, timeLeft: string) =>
|
||||
`⏰ *Draw Reminder!*
|
||||
drawCompleted: (potSats: number, hasWinner: boolean) =>
|
||||
hasWinner
|
||||
? `🎰 *JACKPOT DRAW COMPLETE!* 🎰
|
||||
|
||||
🎰 The next Lightning Jackpot draw is coming up!
|
||||
💰 *Jackpot:* ${potSats.toLocaleString()} sats
|
||||
🏆 A winner has been selected!
|
||||
|
||||
💰 *Current Prize Pool:* ${potSats} sats
|
||||
🕐 *Draw Time:* ${drawTime}
|
||||
⏳ *Time Left:* ${timeLeft}
|
||||
Use /buyticket to enter the next round! 🍀`
|
||||
: `🎰 *JACKPOT DRAW COMPLETE!* 🎰
|
||||
|
||||
Don't miss your chance to win! Use /buy to get your tickets! 🎟`,
|
||||
No tickets were sold this round.
|
||||
The jackpot rolls over to the next draw!
|
||||
|
||||
Use /buyticket to be the first to enter! 🍀`,
|
||||
|
||||
drawReminder: (value: number, unit: string, drawTime: Date, potSats: number) => {
|
||||
const timeStr = unit === 'days'
|
||||
? `${value} day${value > 1 ? 's' : ''}`
|
||||
: unit === 'hours'
|
||||
? `${value} hour${value > 1 ? 's' : ''}`
|
||||
: `${value} minute${value > 1 ? 's' : ''}`;
|
||||
|
||||
const drawTimeStr = drawTime.toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
return `⏰ *Draw Reminder!*
|
||||
|
||||
🎰 The next Lightning Jackpot draw is in *${timeStr}*!
|
||||
|
||||
💰 *Current Prize Pool:* ${potSats.toLocaleString()} sats
|
||||
🕐 *Draw Time:* ${drawTimeStr}
|
||||
|
||||
Don't miss your chance to win! Use /buyticket to get your tickets! 🎟`;
|
||||
},
|
||||
|
||||
newJackpot: (lotteryName: string, ticketPrice: number, drawTime: Date) => {
|
||||
const drawTimeStr = drawTime.toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
return `🎉 *NEW JACKPOT STARTED!* 🎉
|
||||
|
||||
⚡ *${lotteryName}*
|
||||
|
||||
A new lottery round has begun!
|
||||
|
||||
🎟 *Ticket Price:* ${ticketPrice} sats
|
||||
🕐 *Draw Time:* ${drawTimeStr}
|
||||
|
||||
Be first to enter! Use /buyticket to buy tickets! 🍀`;
|
||||
},
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// USER SETTINGS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
settings: {
|
||||
overview: (displayName: string, notifications: { drawReminders: boolean; drawResults: boolean; newJackpotAlerts: boolean }) =>
|
||||
`⚙️ *Your Settings*
|
||||
|
||||
👤 *Display Name:* ${displayName}
|
||||
_(Shown when announcing winners. Defaults to your @username)_
|
||||
|
||||
*Notifications:*
|
||||
${notifications.drawReminders ? '✅' : '❌'} Draw Reminders _(15 min before draws)_
|
||||
${notifications.drawResults ? '✅' : '❌'} Draw Results _(when your tickets are drawn)_
|
||||
${notifications.newJackpotAlerts ? '✅' : '❌'} New Jackpot Alerts _(when new rounds start)_
|
||||
|
||||
Tap buttons below to change settings:`,
|
||||
|
||||
enterDisplayName: `👤 *Set Display Name*
|
||||
|
||||
Enter your display name (max 20 characters).
|
||||
This name will be shown if you win!
|
||||
|
||||
_Your Telegram @username is used by default._
|
||||
_Send "Anon" to stay anonymous._`,
|
||||
|
||||
nameTooLong: '❌ Display name must be 20 characters or less. Please try again:',
|
||||
|
||||
nameUpdated: (name: string) =>
|
||||
`✅ *Display Name Updated!*
|
||||
|
||||
Your display name is now: *${name}*
|
||||
|
||||
This will be shown when announcing winners.`,
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -397,16 +590,16 @@ Hello *${groupName}*! I'm the Lightning Jackpot lottery bot.
|
||||
I can announce lottery draws and remind you when jackpots are coming up!
|
||||
|
||||
*Group Admin Commands:*
|
||||
• /settings — Configure bot settings for this group
|
||||
• /lottosettings — Configure bot settings for this group
|
||||
|
||||
*User Commands:*
|
||||
• /buy — Buy lottery tickets (in DM)
|
||||
• /buyticket — Buy lottery tickets
|
||||
• /jackpot — View current jackpot info
|
||||
• /help — Get help
|
||||
• /lottohelp — Get help
|
||||
|
||||
To buy tickets, message me directly @LightningLottoBot! 🎟`,
|
||||
To buy tickets privately, message me directly! 🎟`,
|
||||
|
||||
privateChat: '❌ This command only works in groups. Use /menu to see available commands.',
|
||||
privateChat: '❌ This command only works in groups. Use /lottomenu to see available commands.',
|
||||
|
||||
adminOnly: '⚠️ Only group administrators can change these settings.',
|
||||
|
||||
@@ -415,19 +608,65 @@ To buy tickets, message me directly @LightningLottoBot! 🎟`,
|
||||
enabled: boolean;
|
||||
drawAnnouncements: boolean;
|
||||
reminders: boolean;
|
||||
newJackpotAnnouncement?: boolean;
|
||||
ticketPurchaseAllowed: boolean;
|
||||
}) =>
|
||||
`⚙️ *Group Settings*
|
||||
reminder1Enabled?: boolean;
|
||||
reminder1Time?: { value: number; unit: string };
|
||||
reminder2Enabled?: boolean;
|
||||
reminder2Time?: { value: number; unit: string };
|
||||
reminder3Enabled?: boolean;
|
||||
reminder3Time?: { value: number; unit: string };
|
||||
announcementDelaySeconds?: number;
|
||||
newJackpotDelayMinutes?: number;
|
||||
}) => {
|
||||
const announceDelay = settings.announcementDelaySeconds ?? 10;
|
||||
const formatAnnounce = announceDelay === 0
|
||||
? 'Immediately'
|
||||
: announceDelay >= 60
|
||||
? `${announceDelay / 60} min after draw`
|
||||
: `${announceDelay}s after draw`;
|
||||
|
||||
const newJackpotDelay = settings.newJackpotDelayMinutes ?? 5;
|
||||
const formatNewJackpotDelay = newJackpotDelay === 0
|
||||
? 'Immediately'
|
||||
: `${newJackpotDelay} min after start`;
|
||||
|
||||
// Format helper for reminder times
|
||||
const formatTime = (t?: { value: number; unit: string }) => {
|
||||
if (!t) return '?';
|
||||
if (t.unit === 'minutes') return `${t.value}m`;
|
||||
if (t.unit === 'hours') return t.value === 1 ? '1h' : `${t.value}h`;
|
||||
return t.value === 1 ? '1d' : `${t.value}d`;
|
||||
};
|
||||
|
||||
// Format reminder times (3-tier system)
|
||||
const r1 = settings.reminder1Enabled !== false;
|
||||
const r2 = settings.reminder2Enabled === true;
|
||||
const r3 = settings.reminder3Enabled === true;
|
||||
const activeReminders: string[] = [];
|
||||
if (r1) activeReminders.push(formatTime(settings.reminder1Time) || '1h');
|
||||
if (r2) activeReminders.push(formatTime(settings.reminder2Time) || '1d');
|
||||
if (r3) activeReminders.push(formatTime(settings.reminder3Time) || '6d');
|
||||
const formatReminderList = activeReminders.length > 0
|
||||
? activeReminders.join(', ')
|
||||
: 'None';
|
||||
|
||||
const newJackpot = settings.newJackpotAnnouncement !== false;
|
||||
|
||||
return `⚙️ *Group Settings*
|
||||
|
||||
📍 *Group:* ${settings.groupTitle}
|
||||
|
||||
*Current Configuration:*
|
||||
${settings.enabled ? '✅' : '❌'} Bot Enabled
|
||||
${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements
|
||||
${settings.reminders ? '✅' : '❌'} Draw Reminders
|
||||
${newJackpot ? '✅' : '❌'} New Jackpot Announcements ${newJackpot ? `_(${formatNewJackpotDelay})_` : ''}
|
||||
${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements ${settings.drawAnnouncements ? `_(${formatAnnounce})_` : ''}
|
||||
${settings.reminders ? '✅' : '❌'} Draw Reminders ${settings.reminders ? `_(${formatReminderList})_` : ''}
|
||||
${settings.ticketPurchaseAllowed ? '✅' : '❌'} Ticket Purchases in Group
|
||||
|
||||
Tap a button below to toggle settings:`,
|
||||
_Tap buttons to toggle features or adjust times._
|
||||
_This message will auto-delete in 2 minutes._`;
|
||||
},
|
||||
|
||||
settingUpdated: (setting: string, enabled: boolean) =>
|
||||
`✅ *${setting}* has been ${enabled ? 'enabled' : 'disabled'}.`,
|
||||
|
||||
@@ -70,7 +70,7 @@ class ApiClient {
|
||||
async buyTickets(
|
||||
tickets: number,
|
||||
lightningAddress: string,
|
||||
telegramUserId: number
|
||||
displayName: string = 'Anon'
|
||||
): Promise<BuyTicketsResponse> {
|
||||
try {
|
||||
const response = await this.client.post<ApiResponse<BuyTicketsResponse>>(
|
||||
@@ -78,7 +78,7 @@ class ApiClient {
|
||||
{
|
||||
tickets,
|
||||
lightning_address: lightningAddress,
|
||||
buyer_name: `TG:${telegramUserId}`,
|
||||
buyer_name: displayName || 'Anon',
|
||||
}
|
||||
);
|
||||
return response.data.data;
|
||||
@@ -138,6 +138,51 @@ class ApiClient {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if system is in maintenance mode
|
||||
*/
|
||||
async checkMaintenanceStatus(): Promise<{ enabled: boolean; pending: boolean; message: string | null }> {
|
||||
try {
|
||||
const response = await this.client.get<ApiResponse<{ maintenance_mode: boolean; maintenance_pending: boolean; message: string | null }>>(
|
||||
'/status/maintenance'
|
||||
);
|
||||
return {
|
||||
enabled: response.data.data.maintenance_mode,
|
||||
pending: response.data.data.maintenance_pending,
|
||||
message: response.data.data.message,
|
||||
};
|
||||
} catch (error) {
|
||||
// If endpoint doesn't exist or fails, assume not in maintenance
|
||||
return { enabled: false, pending: false, message: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get past wins (for group announcements)
|
||||
*/
|
||||
async getPastWins(limit: number = 1, offset: number = 0): Promise<PastWin[]> {
|
||||
try {
|
||||
const response = await this.client.get<ApiResponse<{ wins: PastWin[] }>>(
|
||||
`/jackpot/past-wins?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
return response.data.data.wins || [];
|
||||
} catch (error) {
|
||||
logger.error('Failed to get past wins', { error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PastWin {
|
||||
cycle_id: string;
|
||||
cycle_type: string;
|
||||
scheduled_at: string;
|
||||
pot_total_sats: number;
|
||||
pot_after_fee_sats: number | null;
|
||||
winner_name: string;
|
||||
winner_address: string | null;
|
||||
winning_ticket_serial: number | null;
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
689
telegram_bot/src/services/database.ts
Normal file
689
telegram_bot/src/services/database.ts
Normal 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;
|
||||
|
||||
@@ -1,225 +1,223 @@
|
||||
import Redis from 'ioredis';
|
||||
import config from '../config';
|
||||
import { botDatabase } from './database';
|
||||
import { logger } from './logger';
|
||||
import { GroupSettings, DEFAULT_GROUP_SETTINGS } from '../types/groups';
|
||||
|
||||
const GROUP_PREFIX = 'tg_group:';
|
||||
const GROUPS_LIST_KEY = 'tg_groups_list';
|
||||
const STATE_TTL = 60 * 60 * 24 * 365; // 1 year
|
||||
import { GroupSettings, ReminderTime, reminderTimeToMinutes } from '../types/groups';
|
||||
|
||||
class GroupStateManager {
|
||||
private redis: Redis | null = null;
|
||||
private memoryStore: Map<string, string> = new Map();
|
||||
private useRedis: boolean = false;
|
||||
|
||||
async init(redisUrl: string | null): Promise<void> {
|
||||
if (redisUrl) {
|
||||
try {
|
||||
this.redis = new Redis(redisUrl);
|
||||
await this.redis.ping();
|
||||
this.useRedis = true;
|
||||
logger.info('Group state manager initialized with Redis');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to connect to Redis for groups, using in-memory store');
|
||||
this.redis = null;
|
||||
this.useRedis = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async get(key: string): Promise<string | null> {
|
||||
if (this.useRedis && this.redis) {
|
||||
return await this.redis.get(key);
|
||||
}
|
||||
return this.memoryStore.get(key) || null;
|
||||
}
|
||||
|
||||
private async set(key: string, value: string, ttl?: number): Promise<void> {
|
||||
if (this.useRedis && this.redis) {
|
||||
if (ttl) {
|
||||
await this.redis.setex(key, ttl, value);
|
||||
} else {
|
||||
await this.redis.set(key, value);
|
||||
}
|
||||
} else {
|
||||
this.memoryStore.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private async del(key: string): Promise<void> {
|
||||
if (this.useRedis && this.redis) {
|
||||
await this.redis.del(key);
|
||||
} else {
|
||||
this.memoryStore.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async sadd(key: string, value: string): Promise<void> {
|
||||
if (this.useRedis && this.redis) {
|
||||
await this.redis.sadd(key, value);
|
||||
} else {
|
||||
const existing = this.memoryStore.get(key);
|
||||
const set = existing ? new Set(JSON.parse(existing)) : new Set();
|
||||
set.add(value);
|
||||
this.memoryStore.set(key, JSON.stringify([...set]));
|
||||
}
|
||||
}
|
||||
|
||||
private async srem(key: string, value: string): Promise<void> {
|
||||
if (this.useRedis && this.redis) {
|
||||
await this.redis.srem(key, value);
|
||||
} else {
|
||||
const existing = this.memoryStore.get(key);
|
||||
if (existing) {
|
||||
const set = new Set(JSON.parse(existing));
|
||||
set.delete(value);
|
||||
this.memoryStore.set(key, JSON.stringify([...set]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async smembers(key: string): Promise<string[]> {
|
||||
if (this.useRedis && this.redis) {
|
||||
return await this.redis.smembers(key);
|
||||
}
|
||||
const existing = this.memoryStore.get(key);
|
||||
return existing ? JSON.parse(existing) : [];
|
||||
async init(): Promise<void> {
|
||||
// Database is initialized separately
|
||||
logger.info('Group state manager initialized (using SQLite database)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group settings
|
||||
*/
|
||||
async getGroup(groupId: number): Promise<GroupSettings | null> {
|
||||
const key = `${GROUP_PREFIX}${groupId}`;
|
||||
const data = await this.get(key);
|
||||
if (!data) return null;
|
||||
|
||||
try {
|
||||
const settings = JSON.parse(data);
|
||||
return {
|
||||
...settings,
|
||||
addedAt: new Date(settings.addedAt),
|
||||
updatedAt: new Date(settings.updatedAt),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse group settings', { groupId, error });
|
||||
return null;
|
||||
}
|
||||
return botDatabase.getGroup(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update group settings
|
||||
*/
|
||||
async saveGroup(settings: GroupSettings): Promise<void> {
|
||||
const key = `${GROUP_PREFIX}${settings.groupId}`;
|
||||
settings.updatedAt = new Date();
|
||||
await this.set(key, JSON.stringify(settings), STATE_TTL);
|
||||
await this.sadd(GROUPS_LIST_KEY, settings.groupId.toString());
|
||||
logger.debug('Group settings saved', { groupId: settings.groupId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new group when bot is added
|
||||
* Register a new group
|
||||
*/
|
||||
async registerGroup(
|
||||
groupId: number,
|
||||
groupTitle: string,
|
||||
addedBy: number
|
||||
): Promise<GroupSettings> {
|
||||
const existing = await this.getGroup(groupId);
|
||||
|
||||
if (existing) {
|
||||
// Update title if changed
|
||||
existing.groupTitle = groupTitle;
|
||||
existing.updatedAt = new Date();
|
||||
await this.saveGroup(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const settings: GroupSettings = {
|
||||
groupId,
|
||||
groupTitle,
|
||||
...DEFAULT_GROUP_SETTINGS,
|
||||
addedBy,
|
||||
addedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await this.saveGroup(settings);
|
||||
logger.info('New group registered', { groupId, groupTitle, addedBy });
|
||||
return settings;
|
||||
return botDatabase.registerGroup(groupId, groupTitle, addedBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove group when bot is removed
|
||||
* Remove a group
|
||||
*/
|
||||
async removeGroup(groupId: number): Promise<void> {
|
||||
const key = `${GROUP_PREFIX}${groupId}`;
|
||||
await this.del(key);
|
||||
await this.srem(GROUPS_LIST_KEY, groupId.toString());
|
||||
logger.info('Group removed', { groupId });
|
||||
botDatabase.removeGroup(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific setting
|
||||
* Save group settings
|
||||
*/
|
||||
async saveGroup(settings: GroupSettings): Promise<void> {
|
||||
botDatabase.saveGroup(settings);
|
||||
logger.debug('Group settings saved', { groupId: settings.groupId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a group setting
|
||||
*/
|
||||
async updateSetting(
|
||||
groupId: number,
|
||||
setting: keyof Pick<GroupSettings, 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed'>,
|
||||
setting:
|
||||
| 'enabled'
|
||||
| 'drawAnnouncements'
|
||||
| 'reminders'
|
||||
| 'newJackpotAnnouncement'
|
||||
| 'ticketPurchaseAllowed'
|
||||
| 'reminder1Enabled'
|
||||
| 'reminder2Enabled'
|
||||
| 'reminder3Enabled',
|
||||
value: boolean
|
||||
): Promise<GroupSettings | null> {
|
||||
const settings = await this.getGroup(groupId);
|
||||
if (!settings) return null;
|
||||
|
||||
settings[setting] = value;
|
||||
await this.saveGroup(settings);
|
||||
return settings;
|
||||
return botDatabase.updateGroupSetting(groupId, setting, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all groups with a specific feature enabled
|
||||
* Update reminder time for a slot
|
||||
*/
|
||||
async updateReminderTime(
|
||||
groupId: number,
|
||||
slot: 1 | 2 | 3,
|
||||
time: ReminderTime
|
||||
): Promise<GroupSettings | null> {
|
||||
return botDatabase.updateReminderTime(groupId, slot, time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update announcement delay
|
||||
*/
|
||||
async updateAnnouncementDelay(
|
||||
groupId: number,
|
||||
seconds: number
|
||||
): Promise<GroupSettings | null> {
|
||||
return botDatabase.updateAnnouncementDelay(groupId, seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update new jackpot announcement delay
|
||||
*/
|
||||
async updateNewJackpotDelay(
|
||||
groupId: number,
|
||||
minutes: number
|
||||
): Promise<GroupSettings | null> {
|
||||
return botDatabase.updateNewJackpotDelay(groupId, minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get groups with specific feature enabled
|
||||
*/
|
||||
async getGroupsWithFeature(
|
||||
feature: 'enabled' | 'drawAnnouncements' | 'reminders'
|
||||
feature: 'enabled' | 'drawAnnouncements' | 'reminders' | 'newJackpotAnnouncement'
|
||||
): Promise<GroupSettings[]> {
|
||||
const groupIds = await this.smembers(GROUPS_LIST_KEY);
|
||||
const groups: GroupSettings[] = [];
|
||||
|
||||
for (const id of groupIds) {
|
||||
const settings = await this.getGroup(parseInt(id, 10));
|
||||
if (settings && settings.enabled && settings[feature]) {
|
||||
groups.push(settings);
|
||||
if (feature === 'newJackpotAnnouncement') {
|
||||
const allGroups = await this.getAllGroups();
|
||||
return allGroups.filter(g => g.enabled && g.newJackpotAnnouncement);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
return botDatabase.getGroupsWithFeature(feature as 'enabled' | 'drawAnnouncements' | 'reminders');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered groups
|
||||
* Get all groups
|
||||
*/
|
||||
async getAllGroups(): Promise<GroupSettings[]> {
|
||||
const groupIds = await this.smembers(GROUPS_LIST_KEY);
|
||||
const groups: GroupSettings[] = [];
|
||||
return botDatabase.getAllGroups();
|
||||
}
|
||||
|
||||
for (const id of groupIds) {
|
||||
const settings = await this.getGroup(parseInt(id, 10));
|
||||
if (settings) {
|
||||
groups.push(settings);
|
||||
/**
|
||||
* Get groups that need reminders for a specific draw time
|
||||
*/
|
||||
async getGroupsNeedingReminders(drawTime: Date): Promise<Array<{
|
||||
settings: GroupSettings;
|
||||
reminderSlot: 1 | 2 | 3;
|
||||
}>> {
|
||||
const allGroups = await this.getGroupsWithFeature('reminders');
|
||||
const now = new Date();
|
||||
const minutesUntilDraw = (drawTime.getTime() - now.getTime()) / (1000 * 60);
|
||||
const results: Array<{ settings: GroupSettings; reminderSlot: 1 | 2 | 3 }> = [];
|
||||
|
||||
for (const group of allGroups) {
|
||||
// Check each reminder slot
|
||||
if (group.reminder1Enabled) {
|
||||
const reminderMinutes = reminderTimeToMinutes(group.reminder1Time);
|
||||
if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) {
|
||||
results.push({ settings: group, reminderSlot: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
if (group.reminder2Enabled) {
|
||||
const reminderMinutes = reminderTimeToMinutes(group.reminder2Time);
|
||||
if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) {
|
||||
results.push({ settings: group, reminderSlot: 2 });
|
||||
}
|
||||
}
|
||||
|
||||
if (group.reminder3Enabled) {
|
||||
const reminderMinutes = reminderTimeToMinutes(group.reminder3Time);
|
||||
if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) {
|
||||
results.push({ settings: group, reminderSlot: 3 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add time to a reminder
|
||||
*/
|
||||
async addReminderTime(
|
||||
groupId: number,
|
||||
slot: 1 | 2 | 3,
|
||||
amount: number,
|
||||
unit: 'minutes' | 'hours' | 'days'
|
||||
): Promise<GroupSettings | null> {
|
||||
const group = await this.getGroup(groupId);
|
||||
if (!group) return null;
|
||||
|
||||
const timeKey = `reminder${slot}Time` as 'reminder1Time' | 'reminder2Time' | 'reminder3Time';
|
||||
const currentTime = group[timeKey];
|
||||
|
||||
// Convert everything to minutes, add, then convert back
|
||||
let totalMinutes = reminderTimeToMinutes(currentTime);
|
||||
|
||||
switch (unit) {
|
||||
case 'minutes': totalMinutes += amount; break;
|
||||
case 'hours': totalMinutes += amount * 60; break;
|
||||
case 'days': totalMinutes += amount * 24 * 60; break;
|
||||
}
|
||||
|
||||
// Ensure minimum of 1 minute
|
||||
totalMinutes = Math.max(1, totalMinutes);
|
||||
|
||||
// Convert back to best unit
|
||||
const newTime = this.minutesToReminderTime(totalMinutes);
|
||||
return this.updateReminderTime(groupId, slot, newTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove time from a reminder
|
||||
*/
|
||||
async removeReminderTime(
|
||||
groupId: number,
|
||||
slot: 1 | 2 | 3,
|
||||
amount: number,
|
||||
unit: 'minutes' | 'hours' | 'days'
|
||||
): Promise<GroupSettings | null> {
|
||||
return this.addReminderTime(groupId, slot, -amount, unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert total minutes to the best ReminderTime representation
|
||||
*/
|
||||
private minutesToReminderTime(totalMinutes: number): ReminderTime {
|
||||
// Use days if evenly divisible and >= 1 day
|
||||
if (totalMinutes >= 1440 && totalMinutes % 1440 === 0) {
|
||||
return { value: totalMinutes / 1440, unit: 'days' };
|
||||
}
|
||||
// Use hours if evenly divisible and >= 1 hour
|
||||
if (totalMinutes >= 60 && totalMinutes % 60 === 0) {
|
||||
return { value: totalMinutes / 60, unit: 'hours' };
|
||||
}
|
||||
// Use minutes
|
||||
return { value: totalMinutes, unit: 'minutes' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.redis) {
|
||||
await this.redis.quit();
|
||||
}
|
||||
// Database close is handled separately
|
||||
logger.info('Group state manager closed');
|
||||
}
|
||||
}
|
||||
|
||||
export const groupStateManager = new GroupStateManager();
|
||||
export default groupStateManager;
|
||||
|
||||
|
||||
|
||||
807
telegram_bot/src/services/notificationScheduler.ts
Normal file
807
telegram_bot/src/services/notificationScheduler.ts
Normal 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;
|
||||
@@ -1,130 +1,31 @@
|
||||
import Redis from 'ioredis';
|
||||
import config from '../config';
|
||||
import { botDatabase } from './database';
|
||||
import { logger } from './logger';
|
||||
import {
|
||||
TelegramUser,
|
||||
UserState,
|
||||
AwaitingPaymentData,
|
||||
NotificationPreferences,
|
||||
DEFAULT_NOTIFICATIONS,
|
||||
} from '../types';
|
||||
|
||||
const STATE_PREFIX = 'tg_user:';
|
||||
const PURCHASE_PREFIX = 'tg_purchase:';
|
||||
const USER_PURCHASES_PREFIX = 'tg_user_purchases:';
|
||||
const STATE_TTL = 60 * 60 * 24 * 30; // 30 days
|
||||
|
||||
class StateManager {
|
||||
private redis: Redis | null = null;
|
||||
private memoryStore: Map<string, string> = new Map();
|
||||
private useRedis: boolean = false;
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (config.redis.url) {
|
||||
try {
|
||||
this.redis = new Redis(config.redis.url);
|
||||
|
||||
this.redis.on('error', (error) => {
|
||||
logger.error('Redis connection error', { error: error.message });
|
||||
});
|
||||
|
||||
this.redis.on('connect', () => {
|
||||
logger.info('Connected to Redis');
|
||||
});
|
||||
|
||||
// Test connection
|
||||
await this.redis.ping();
|
||||
this.useRedis = true;
|
||||
logger.info('State manager initialized with Redis');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to connect to Redis, falling back to in-memory store', {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
this.redis = null;
|
||||
this.useRedis = false;
|
||||
}
|
||||
} else {
|
||||
logger.info('State manager initialized with in-memory store');
|
||||
this.useRedis = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async get(key: string): Promise<string | null> {
|
||||
if (this.useRedis && this.redis) {
|
||||
return await this.redis.get(key);
|
||||
}
|
||||
return this.memoryStore.get(key) || null;
|
||||
}
|
||||
|
||||
private async set(key: string, value: string, ttl?: number): Promise<void> {
|
||||
if (this.useRedis && this.redis) {
|
||||
if (ttl) {
|
||||
await this.redis.setex(key, ttl, value);
|
||||
} else {
|
||||
await this.redis.set(key, value);
|
||||
}
|
||||
} else {
|
||||
this.memoryStore.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private async del(key: string): Promise<void> {
|
||||
if (this.useRedis && this.redis) {
|
||||
await this.redis.del(key);
|
||||
} else {
|
||||
this.memoryStore.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async lpush(key: string, value: string): Promise<void> {
|
||||
if (this.useRedis && this.redis) {
|
||||
await this.redis.lpush(key, value);
|
||||
await this.redis.ltrim(key, 0, 99); // Keep last 100 purchases
|
||||
} else {
|
||||
const existing = this.memoryStore.get(key);
|
||||
const list = existing ? JSON.parse(existing) : [];
|
||||
list.unshift(value);
|
||||
if (list.length > 100) list.pop();
|
||||
this.memoryStore.set(key, JSON.stringify(list));
|
||||
}
|
||||
}
|
||||
|
||||
private async lrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
if (this.useRedis && this.redis) {
|
||||
return await this.redis.lrange(key, start, stop);
|
||||
}
|
||||
const existing = this.memoryStore.get(key);
|
||||
if (!existing) return [];
|
||||
const list = JSON.parse(existing);
|
||||
return list.slice(start, stop + 1);
|
||||
// Database is initialized separately
|
||||
logger.info('State manager initialized (using SQLite database)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create user
|
||||
* Get user by Telegram ID
|
||||
*/
|
||||
async getUser(telegramId: number): Promise<TelegramUser | null> {
|
||||
const key = `${STATE_PREFIX}${telegramId}`;
|
||||
const data = await this.get(key);
|
||||
if (!data) return null;
|
||||
|
||||
try {
|
||||
const user = JSON.parse(data);
|
||||
return {
|
||||
...user,
|
||||
createdAt: new Date(user.createdAt),
|
||||
updatedAt: new Date(user.updatedAt),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse user data', { telegramId, error });
|
||||
return null;
|
||||
}
|
||||
return botDatabase.getUser(telegramId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update user
|
||||
* Save user
|
||||
*/
|
||||
async saveUser(user: TelegramUser): Promise<void> {
|
||||
const key = `${STATE_PREFIX}${user.telegramId}`;
|
||||
user.updatedAt = new Date();
|
||||
await this.set(key, JSON.stringify(user), STATE_TTL);
|
||||
botDatabase.saveUser(user);
|
||||
logger.debug('User saved', { telegramId: user.telegramId, state: user.state });
|
||||
}
|
||||
|
||||
@@ -137,18 +38,7 @@ class StateManager {
|
||||
firstName?: string,
|
||||
lastName?: string
|
||||
): Promise<TelegramUser> {
|
||||
const user: TelegramUser = {
|
||||
telegramId,
|
||||
username,
|
||||
firstName,
|
||||
lastName,
|
||||
state: 'awaiting_lightning_address',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
await this.saveUser(user);
|
||||
logger.info('New user created', { telegramId, username });
|
||||
return user;
|
||||
return botDatabase.createUser(telegramId, username, firstName, lastName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,14 +49,7 @@ class StateManager {
|
||||
state: UserState,
|
||||
stateData?: Record<string, any>
|
||||
): Promise<void> {
|
||||
const user = await this.getUser(telegramId);
|
||||
if (!user) {
|
||||
logger.warn('Attempted to update state for non-existent user', { telegramId });
|
||||
return;
|
||||
}
|
||||
user.state = state;
|
||||
user.stateData = stateData;
|
||||
await this.saveUser(user);
|
||||
botDatabase.updateUserState(telegramId, state, stateData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,15 +59,25 @@ class StateManager {
|
||||
telegramId: number,
|
||||
lightningAddress: string
|
||||
): Promise<void> {
|
||||
const user = await this.getUser(telegramId);
|
||||
if (!user) {
|
||||
logger.warn('Attempted to update address for non-existent user', { telegramId });
|
||||
return;
|
||||
botDatabase.updateLightningAddress(telegramId, lightningAddress);
|
||||
}
|
||||
user.lightningAddress = lightningAddress;
|
||||
user.state = 'idle';
|
||||
user.stateData = undefined;
|
||||
await this.saveUser(user);
|
||||
|
||||
/**
|
||||
* Update user display name
|
||||
*/
|
||||
async updateDisplayName(telegramId: number, displayName: string): Promise<void> {
|
||||
botDatabase.updateDisplayName(telegramId, displayName);
|
||||
logger.info('Display name updated', { telegramId, displayName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user notification preferences
|
||||
*/
|
||||
async updateNotifications(
|
||||
telegramId: number,
|
||||
updates: Partial<NotificationPreferences>
|
||||
): Promise<TelegramUser | null> {
|
||||
return botDatabase.updateNotifications(telegramId, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,33 +88,36 @@ class StateManager {
|
||||
purchaseId: string,
|
||||
data: AwaitingPaymentData
|
||||
): Promise<void> {
|
||||
// Store purchase data
|
||||
const purchaseKey = `${PURCHASE_PREFIX}${purchaseId}`;
|
||||
await this.set(purchaseKey, JSON.stringify({
|
||||
telegramId,
|
||||
...data,
|
||||
createdAt: new Date().toISOString(),
|
||||
}), STATE_TTL);
|
||||
|
||||
// Add to user's purchase list
|
||||
const userPurchasesKey = `${USER_PURCHASES_PREFIX}${telegramId}`;
|
||||
await this.lpush(userPurchasesKey, purchaseId);
|
||||
botDatabase.storePurchase(telegramId, purchaseId, {
|
||||
cycleId: data.cycleId,
|
||||
ticketCount: data.ticketCount,
|
||||
totalAmount: data.totalAmount,
|
||||
lightningAddress: data.paymentRequest ? '' : '', // Not storing sensitive data
|
||||
paymentRequest: data.paymentRequest,
|
||||
publicUrl: data.publicUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get purchase data
|
||||
*/
|
||||
async getPurchase(purchaseId: string): Promise<(AwaitingPaymentData & { telegramId: number }) | null> {
|
||||
const key = `${PURCHASE_PREFIX}${purchaseId}`;
|
||||
const data = await this.get(key);
|
||||
if (!data) return null;
|
||||
const purchase = botDatabase.getPurchase(purchaseId);
|
||||
if (!purchase) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse purchase data', { purchaseId, error });
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
telegramId: purchase.telegram_id,
|
||||
cycleId: purchase.cycle_id,
|
||||
ticketCount: purchase.ticket_count,
|
||||
scheduledAt: '', // Not stored locally
|
||||
ticketPrice: 0, // Not stored locally
|
||||
totalAmount: purchase.amount_sats,
|
||||
lotteryName: '', // Not stored locally
|
||||
purchaseId: purchase.purchase_id,
|
||||
paymentRequest: purchase.payment_request,
|
||||
publicUrl: purchase.public_url,
|
||||
pollStartTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,33 +127,70 @@ class StateManager {
|
||||
telegramId: number,
|
||||
limit: number = 10
|
||||
): Promise<string[]> {
|
||||
const key = `${USER_PURCHASES_PREFIX}${telegramId}`;
|
||||
return await this.lrange(key, 0, limit - 1);
|
||||
return botDatabase.getUserPurchaseIds(telegramId, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear user state data (keeping lightning address)
|
||||
*/
|
||||
async clearUserStateData(telegramId: number): Promise<void> {
|
||||
const user = await this.getUser(telegramId);
|
||||
if (!user) return;
|
||||
user.state = 'idle';
|
||||
user.stateData = undefined;
|
||||
await this.saveUser(user);
|
||||
botDatabase.updateUserState(telegramId, 'idle', undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add user as participant to a cycle
|
||||
*/
|
||||
async addCycleParticipant(cycleId: string, telegramId: number, purchaseId: string): Promise<void> {
|
||||
botDatabase.addCycleParticipant(cycleId, telegramId, purchaseId);
|
||||
logger.debug('Added cycle participant', { cycleId, telegramId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all participants for a cycle
|
||||
*/
|
||||
async getCycleParticipants(cycleId: string): Promise<Array<{ telegramId: number; purchaseId: string }>> {
|
||||
return botDatabase.getCycleParticipants(cycleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user participated in a cycle
|
||||
*/
|
||||
async didUserParticipate(cycleId: string, telegramId: number): Promise<boolean> {
|
||||
return botDatabase.didUserParticipate(cycleId, telegramId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users with specific notification preference enabled
|
||||
*/
|
||||
async getUsersWithNotification(
|
||||
preference: keyof NotificationPreferences
|
||||
): Promise<TelegramUser[]> {
|
||||
return botDatabase.getUsersWithNotification(preference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's display name (for announcements)
|
||||
* Priority: displayName > @username > 'Anon'
|
||||
*/
|
||||
getDisplayName(user: TelegramUser): string {
|
||||
if (user.displayName && user.displayName !== 'Anon') {
|
||||
return user.displayName;
|
||||
}
|
||||
// Fall back to @username if available
|
||||
if (user.username) {
|
||||
return `@${user.username}`;
|
||||
}
|
||||
return 'Anon';
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.redis) {
|
||||
await this.redis.quit();
|
||||
logger.info('Redis connection closed');
|
||||
}
|
||||
// Database close is handled separately
|
||||
logger.info('State manager closed');
|
||||
}
|
||||
}
|
||||
|
||||
export const stateManager = new StateManager();
|
||||
export default stateManager;
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Reminder time with unit
|
||||
*/
|
||||
export interface ReminderTime {
|
||||
value: number;
|
||||
unit: 'minutes' | 'hours' | 'days';
|
||||
}
|
||||
|
||||
/**
|
||||
* Group settings for lottery features
|
||||
*/
|
||||
@@ -7,12 +15,81 @@ export interface GroupSettings {
|
||||
enabled: boolean;
|
||||
drawAnnouncements: boolean;
|
||||
reminders: boolean;
|
||||
newJackpotAnnouncement: boolean; // Announce when a new jackpot starts
|
||||
ticketPurchaseAllowed: boolean;
|
||||
// Reminder slots (3 tiers - each with customizable time)
|
||||
reminder1Enabled: boolean;
|
||||
reminder1Time: ReminderTime; // Default: 1 hour before
|
||||
reminder2Enabled: boolean;
|
||||
reminder2Time: ReminderTime; // Default: 1 day before
|
||||
reminder3Enabled: boolean;
|
||||
reminder3Time: ReminderTime; // Default: 6 days before
|
||||
// Legacy field (kept for backwards compat, no longer used)
|
||||
reminderTimes: ReminderTime[];
|
||||
announcementDelaySeconds: number; // Delay after draw to send announcement (in seconds)
|
||||
newJackpotDelayMinutes: number; // Delay after new jackpot starts to send announcement (in minutes)
|
||||
addedBy: number;
|
||||
addedAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default reminder slots for groups (3 tiers)
|
||||
* 1st: 1 Hour before draw
|
||||
* 2nd: 1 Day before draw
|
||||
* 3rd: 6 Days before draw
|
||||
*/
|
||||
export const DEFAULT_GROUP_REMINDER_SLOTS: ReminderTime[] = [
|
||||
{ value: 1, unit: 'hours' }, // 1st reminder: 1 hour before
|
||||
{ value: 1, unit: 'days' }, // 2nd reminder: 1 day before
|
||||
{ value: 6, unit: 'days' }, // 3rd reminder: 6 days before
|
||||
];
|
||||
|
||||
/**
|
||||
* Available reminder time presets (for custom selection)
|
||||
*/
|
||||
export const REMINDER_PRESETS: ReminderTime[] = [
|
||||
{ value: 5, unit: 'minutes' },
|
||||
{ value: 15, unit: 'minutes' },
|
||||
{ value: 30, unit: 'minutes' },
|
||||
{ value: 1, unit: 'hours' },
|
||||
{ value: 6, unit: 'hours' },
|
||||
{ value: 12, unit: 'hours' },
|
||||
{ value: 1, unit: 'days' },
|
||||
{ value: 3, unit: 'days' },
|
||||
{ value: 6, unit: 'days' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert reminder time to minutes
|
||||
*/
|
||||
export function reminderTimeToMinutes(rt: ReminderTime): number {
|
||||
switch (rt.unit) {
|
||||
case 'minutes': return rt.value;
|
||||
case 'hours': return rt.value * 60;
|
||||
case 'days': return rt.value * 60 * 24;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reminder time for display
|
||||
*/
|
||||
export function formatReminderTime(rt: ReminderTime): string {
|
||||
if (rt.unit === 'minutes') return `${rt.value}m`;
|
||||
if (rt.unit === 'hours') return rt.value === 1 ? '1h' : `${rt.value}h`;
|
||||
return rt.value === 1 ? '1d' : `${rt.value}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available announcement delay options (seconds after draw)
|
||||
*/
|
||||
export const ANNOUNCEMENT_DELAY_OPTIONS = [0, 10, 30, 60, 120];
|
||||
|
||||
/**
|
||||
* Available new jackpot announcement delay options (minutes after start)
|
||||
*/
|
||||
export const NEW_JACKPOT_DELAY_OPTIONS = [0, 1, 5, 10, 15, 30];
|
||||
|
||||
/**
|
||||
* Default group settings
|
||||
*/
|
||||
@@ -20,7 +97,17 @@ export const DEFAULT_GROUP_SETTINGS: Omit<GroupSettings, 'groupId' | 'groupTitle
|
||||
enabled: true,
|
||||
drawAnnouncements: true,
|
||||
reminders: true,
|
||||
newJackpotAnnouncement: true,
|
||||
ticketPurchaseAllowed: false, // Disabled by default for privacy - users should buy in DM
|
||||
reminder1Enabled: true,
|
||||
reminder1Time: { value: 1, unit: 'hours' }, // Default: 1 hour before
|
||||
reminder2Enabled: false,
|
||||
reminder2Time: { value: 1, unit: 'days' }, // Default: 1 day before
|
||||
reminder3Enabled: false,
|
||||
reminder3Time: { value: 6, unit: 'days' }, // Default: 6 days before
|
||||
reminderTimes: [], // Legacy field
|
||||
announcementDelaySeconds: 10, // Default: 10 seconds after draw
|
||||
newJackpotDelayMinutes: 5, // Default: 5 minutes after new jackpot starts
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,15 @@ export type UserState =
|
||||
| 'awaiting_lightning_address'
|
||||
| 'awaiting_ticket_amount'
|
||||
| 'awaiting_invoice_payment'
|
||||
| 'updating_address';
|
||||
| 'updating_address'
|
||||
| 'awaiting_display_name';
|
||||
|
||||
// User notification preferences
|
||||
export interface NotificationPreferences {
|
||||
drawReminders: boolean;
|
||||
drawResults: boolean;
|
||||
newJackpotAlerts: boolean;
|
||||
}
|
||||
|
||||
// Telegram user data stored in state
|
||||
export interface TelegramUser {
|
||||
@@ -12,13 +20,22 @@ export interface TelegramUser {
|
||||
username?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
displayName?: string; // Custom display name for announcements (default: "Anon")
|
||||
lightningAddress?: string;
|
||||
state: UserState;
|
||||
stateData?: Record<string, any>;
|
||||
notifications: NotificationPreferences;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Default notification preferences
|
||||
export const DEFAULT_NOTIFICATIONS: NotificationPreferences = {
|
||||
drawReminders: true,
|
||||
drawResults: true,
|
||||
newJackpotAlerts: true,
|
||||
};
|
||||
|
||||
// API Response Types
|
||||
export interface ApiResponse<T> {
|
||||
version: string;
|
||||
@@ -131,6 +148,9 @@ export interface AwaitingPaymentData extends PendingPurchaseData {
|
||||
paymentRequest: string;
|
||||
publicUrl: string;
|
||||
pollStartTime: number;
|
||||
headerMessageId?: number;
|
||||
qrMessageId?: number;
|
||||
invoiceMessageId?: number;
|
||||
}
|
||||
|
||||
// Re-export group types
|
||||
|
||||
@@ -46,7 +46,7 @@ export function formatTimeUntil(date: Date | string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Lightning Address format
|
||||
* Validate Lightning Address format (basic regex check)
|
||||
*/
|
||||
export function isValidLightningAddress(address: string): boolean {
|
||||
// Basic format: something@something.something
|
||||
@@ -54,6 +54,62 @@ export function isValidLightningAddress(address: string): boolean {
|
||||
return regex.test(address);
|
||||
}
|
||||
|
||||
/**
|
||||
* LNURL-pay response structure
|
||||
*/
|
||||
interface LnurlPayResponse {
|
||||
status?: string;
|
||||
reason?: string;
|
||||
callback?: string;
|
||||
minSendable?: number;
|
||||
maxSendable?: number;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Lightning Address is actually valid by checking LNURL endpoint
|
||||
*/
|
||||
export async function verifyLightningAddress(address: string): Promise<{ valid: boolean; error?: string }> {
|
||||
// First check format
|
||||
if (!isValidLightningAddress(address)) {
|
||||
return { valid: false, error: 'Invalid format' };
|
||||
}
|
||||
|
||||
try {
|
||||
const [username, domain] = address.split('@');
|
||||
const url = `https://${domain}/.well-known/lnurlp/${username}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { valid: false, error: `Address not found (${response.status})` };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as LnurlPayResponse;
|
||||
|
||||
// Check for LNURL-pay response
|
||||
if (data.status === 'ERROR') {
|
||||
return { valid: false, error: data.reason || 'Invalid address' };
|
||||
}
|
||||
|
||||
// Valid LNURL-pay response should have callback and minSendable
|
||||
if (data.callback && data.minSendable !== undefined) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return { valid: false, error: 'Invalid LNURL response' };
|
||||
} catch (error: any) {
|
||||
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
|
||||
return { valid: false, error: 'Verification timed out' };
|
||||
}
|
||||
return { valid: false, error: 'Could not verify address' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape markdown special characters for Telegram MarkdownV2
|
||||
*/
|
||||
@@ -69,4 +125,20 @@ export function truncate(str: string, maxLength: number): string {
|
||||
return str.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate lightning address for privacy
|
||||
* "username@blink.sv" -> "us******@blink.sv"
|
||||
*/
|
||||
export function truncateLightningAddress(address: string): string {
|
||||
if (!address || !address.includes('@')) return address;
|
||||
|
||||
const [username, domain] = address.split('@');
|
||||
|
||||
// Show first 2 chars of username, then asterisks
|
||||
const visibleChars = Math.min(2, username.length);
|
||||
const truncatedUsername = username.substring(0, visibleChars) + '******';
|
||||
|
||||
return `${truncatedUsername}@${domain}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import TelegramBot, {
|
||||
InlineKeyboardMarkup,
|
||||
ReplyKeyboardMarkup,
|
||||
} from 'node-telegram-bot-api';
|
||||
import { NotificationPreferences } from '../types';
|
||||
|
||||
/**
|
||||
* Main menu reply keyboard
|
||||
@@ -9,9 +10,10 @@ import TelegramBot, {
|
||||
export function getMainMenuKeyboard(): ReplyKeyboardMarkup {
|
||||
return {
|
||||
keyboard: [
|
||||
[{ text: '🎰 Upcoming Jackpot' }],
|
||||
[{ text: '🎟 Buy Tickets' }, { text: '🧾 My Tickets' }],
|
||||
[{ text: '🏆 My Wins' }, { text: '⚡ Lightning Address' }],
|
||||
[{ text: 'ℹ️ Help' }],
|
||||
[{ text: '⚙️ Settings' }, { text: 'ℹ️ Help' }],
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: false,
|
||||
@@ -68,15 +70,13 @@ function isValidTelegramUrl(url: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* View ticket status button
|
||||
* View ticket status button (after payment confirmed)
|
||||
*/
|
||||
export function getViewTicketKeyboard(
|
||||
purchaseId: string,
|
||||
publicUrl?: string
|
||||
): InlineKeyboardMarkup {
|
||||
const buttons: TelegramBot.InlineKeyboardButton[][] = [
|
||||
[{ text: '🔄 Check Status', callback_data: `status_${purchaseId}` }],
|
||||
];
|
||||
const buttons: TelegramBot.InlineKeyboardButton[][] = [];
|
||||
|
||||
// Only add URL button if it's a valid Telegram URL (HTTPS, not localhost)
|
||||
if (publicUrl && isValidTelegramUrl(publicUrl)) {
|
||||
@@ -143,3 +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',
|
||||
}],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user