feat: Add Telegram bot with group support

- Full Telegram bot implementation for Lightning Jackpot
- Commands: /start, /buy, /tickets, /wins, /address, /jackpot, /help
- Lightning invoice generation with QR codes
- Payment polling and confirmation notifications
- User state management (Redis/in-memory fallback)
- Group support with admin settings panel
- Configurable draw announcements and reminders
- Centralized messages for easy i18n
- Docker configuration included
This commit is contained in:
Michilis
2025-11-27 23:10:25 +00:00
parent d3bf8080b6
commit f743a6749c
29 changed files with 7616 additions and 1 deletions
+39 -1
View File
@@ -21,6 +21,22 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
redis:
image: redis:7-alpine
container_name: lightning-lotto-redis
ports:
- "6379:6379"
networks:
- lightning-lotto-network
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
backend: backend:
build: build:
context: ./back_end context: ./back_end
@@ -66,10 +82,32 @@ services:
- lightning-lotto-network - lightning-lotto-network
restart: unless-stopped restart: unless-stopped
telegram-bot:
build:
context: ./telegram_bot
dockerfile: Dockerfile
container_name: lightning-lotto-telegram-bot
depends_on:
- backend
- redis
environment:
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
API_BASE_URL: http://backend:3000
FRONTEND_BASE_URL: ${FRONTEND_BASE_URL:-http://localhost:3001}
REDIS_URL: redis://redis:6379
MAX_TICKETS_PER_PURCHASE: ${MAX_TICKETS_PER_PURCHASE:-100}
PAYMENT_POLL_INTERVAL_MS: ${PAYMENT_POLL_INTERVAL_MS:-5000}
PAYMENT_POLL_TIMEOUT_MS: ${PAYMENT_POLL_TIMEOUT_MS:-900000}
LOG_LEVEL: ${LOG_LEVEL:-info}
NODE_ENV: production
networks:
- lightning-lotto-network
restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data:
redis_data:
networks: networks:
lightning-lotto-network: lightning-lotto-network:
driver: bridge driver: bridge
+28
View File
@@ -0,0 +1,28 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Debug
*.swp
*.swo
+44
View File
@@ -0,0 +1,44 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY tsconfig.json ./
COPY src/ ./src/
# Build TypeScript
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --only=production && npm cache clean --force
# Copy built files from builder stage
COPY --from=builder /app/dist ./dist
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
USER nodejs
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node -e "console.log('Bot running')" || exit 1
CMD ["node", "dist/index.js"]
+146
View File
@@ -0,0 +1,146 @@
# Lightning Jackpot Telegram Bot ⚡🎰
A Telegram bot for the Lightning Jackpot lottery system. Users can buy tickets, check their tickets and wins, and receive instant notifications - all through Telegram!
## Features
- 🎟 **Buy Tickets** - Quick buttons for 1, 2, 5, 10 tickets or custom amounts
- 💸 **Lightning Payments** - Generate and pay Lightning invoices directly in chat
- 📱 **QR Codes** - Visual QR codes for easy wallet scanning
- 🔔 **Real-time Updates** - Automatic payment confirmation notifications
- 🧾 **View Tickets** - See all your ticket purchases and their status
- 🏆 **Track Wins** - View your winning history and payouts
-**Lightning Address** - Manage your payout address
## Prerequisites
- Node.js 18+
- Telegram Bot Token (from [@BotFather](https://t.me/BotFather))
- Lightning Jackpot Backend API running
## Installation
1. Clone the repository and navigate to the bot directory:
```bash
cd telegram_bot
```
2. Install dependencies:
```bash
npm install
```
3. Create environment file:
```bash
cp env.example .env
```
4. Configure your `.env` file:
```env
TELEGRAM_BOT_TOKEN=your_bot_token_here
API_BASE_URL=http://localhost:3000
FRONTEND_BASE_URL=http://localhost:3001
```
## Development
Run the bot in development mode with auto-reload:
```bash
npm run dev:watch
```
Or run once:
```bash
npm run dev
```
## Production
Build and run:
```bash
npm run build
npm start
```
## Docker
Build the image:
```bash
docker build -t lightning-lotto-telegram-bot .
```
Run the container:
```bash
docker run -d \
--name telegram-bot \
-e TELEGRAM_BOT_TOKEN=your_token \
-e API_BASE_URL=http://backend:3000 \
-e FRONTEND_BASE_URL=http://frontend:3001 \
lightning-lotto-telegram-bot
```
## Commands
| Command | Description |
|---------|-------------|
| `/start` | Begin using the bot |
| `/menu` | Show main menu |
| `/buy` | Buy lottery tickets |
| `/tickets` | View your tickets |
| `/wins` | View your past wins |
| `/address` | Update Lightning Address |
| `/help` | Help & information |
## Architecture
```
src/
├── config/ # Configuration management
├── handlers/ # Command and callback handlers
│ ├── start.ts # /start command
│ ├── buy.ts # Ticket purchase flow
│ ├── tickets.ts # View tickets
│ ├── wins.ts # View wins
│ ├── address.ts # Lightning address management
│ ├── help.ts # Help command
│ └── menu.ts # Menu handling
├── services/
│ ├── api.ts # Backend API client
│ ├── state.ts # User state management (Redis/in-memory)
│ ├── qr.ts # QR code generation
│ └── logger.ts # Logging service
├── types/ # TypeScript type definitions
├── utils/
│ ├── format.ts # Formatting utilities
│ └── keyboards.ts # Telegram keyboard builders
└── index.ts # Main entry point
```
## State Management
The bot uses Redis for persistent state management when available. If Redis is not configured, it falls back to in-memory storage (not recommended for production).
User states:
- `idle` - Default state, browsing menu
- `awaiting_lightning_address` - Waiting for user to enter Lightning Address
- `awaiting_ticket_amount` - Waiting for custom ticket amount
- `awaiting_invoice_payment` - Polling for payment
- `updating_address` - Updating Lightning Address
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `TELEGRAM_BOT_TOKEN` | Telegram Bot API token | Required |
| `API_BASE_URL` | Backend API URL | `http://localhost:3000` |
| `FRONTEND_BASE_URL` | Frontend URL for ticket links | `http://localhost:3001` |
| `REDIS_URL` | Redis connection URL | Optional |
| `MAX_TICKETS_PER_PURCHASE` | Maximum tickets per purchase | `100` |
| `PAYMENT_POLL_INTERVAL_MS` | Payment polling interval | `5000` |
| `PAYMENT_POLL_TIMEOUT_MS` | Payment polling timeout | `900000` |
| `LOG_LEVEL` | Logging level | `info` |
## License
MIT
+22
View File
@@ -0,0 +1,22 @@
# Telegram Bot Configuration
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
# Backend API Configuration
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
# Bot Configuration
MAX_TICKETS_PER_PURCHASE=100
PAYMENT_POLL_INTERVAL_MS=5000
PAYMENT_POLL_TIMEOUT_MS=900000
INVOICE_EXPIRY_MINUTES=15
# Logging
LOG_LEVEL=info
NODE_ENV=development
+3753
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "lightning-lotto-telegram-bot",
"version": "1.0.0",
"description": "Telegram bot for Lightning Lottery - Buy tickets, check wins, and receive payouts via Lightning",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"dev:watch": "nodemon --exec ts-node src/index.ts"
},
"keywords": [
"telegram",
"bot",
"lightning",
"lottery",
"bitcoin"
],
"license": "MIT",
"dependencies": {
"axios": "^1.6.2",
"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/node": "^20.10.4",
"@types/node-telegram-bot-api": "^0.64.2",
"@types/qrcode": "^1.5.5",
"nodemon": "^3.0.2",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}
+50
View File
@@ -0,0 +1,50 @@
import dotenv from 'dotenv';
dotenv.config();
function required(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function optional(name: string, defaultValue: string): string {
return process.env[name] || defaultValue;
}
function optionalInt(name: string, defaultValue: number): number {
const value = process.env[name];
if (!value) return defaultValue;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? defaultValue : parsed;
}
export const config = {
telegram: {
botToken: required('TELEGRAM_BOT_TOKEN'),
},
api: {
baseUrl: optional('API_BASE_URL', 'http://localhost:3000'),
},
frontend: {
baseUrl: optional('FRONTEND_BASE_URL', 'http://localhost:3001'),
},
redis: {
url: process.env.REDIS_URL || null,
},
bot: {
maxTicketsPerPurchase: optionalInt('MAX_TICKETS_PER_PURCHASE', 100),
paymentPollIntervalMs: optionalInt('PAYMENT_POLL_INTERVAL_MS', 5000),
paymentPollTimeoutMs: optionalInt('PAYMENT_POLL_TIMEOUT_MS', 900000), // 15 minutes
invoiceExpiryMinutes: optionalInt('INVOICE_EXPIRY_MINUTES', 15),
},
logging: {
level: optional('LOG_LEVEL', 'info'),
},
nodeEnv: optional('NODE_ENV', 'development'),
};
export default config;
+106
View File
@@ -0,0 +1,106 @@
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 { messages } from '../messages';
/**
* Handle /address command or "Lightning Address" button
*/
export async function handleAddressCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
return;
}
logUserAction(userId, 'Requested address update');
try {
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst);
return;
}
const message = user.lightningAddress
? messages.address.currentAddress(user.lightningAddress)
: messages.address.noAddressSet;
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
});
await stateManager.updateUserState(userId, 'updating_address');
} catch (error) {
logger.error('Error in handleAddressCommand', { error, userId });
await bot.sendMessage(chatId, messages.errors.generic);
}
}
/**
* Handle incoming Lightning Address from user
*/
export async function handleLightningAddressInput(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<boolean> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
const text = msg.text?.trim();
if (!userId || !text) return false;
try {
const user = await stateManager.getUser(userId);
if (!user) return false;
// Check if user is in a state expecting lightning address
if (user.state !== 'awaiting_lightning_address' && user.state !== 'updating_address') {
return false;
}
// Validate lightning address format
if (!isValidLightningAddress(text)) {
await bot.sendMessage(chatId, messages.address.invalidFormat, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
});
return true;
}
// Save the lightning address
await stateManager.updateLightningAddress(userId, text);
logUserAction(userId, 'Lightning address updated');
const responseMessage = user.state === 'awaiting_lightning_address'
? messages.address.firstTimeSuccess(text)
: messages.address.updateSuccess(text);
await bot.sendMessage(chatId, responseMessage, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
return true;
} catch (error) {
logger.error('Error in handleLightningAddressInput', { error, userId });
await bot.sendMessage(chatId, messages.address.saveFailed);
return true;
}
}
export default {
handleAddressCommand,
handleLightningAddressInput,
};
+426
View File
@@ -0,0 +1,426 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { apiClient } from '../services/api';
import { generateQRCode } from '../services/qr';
import { logger, logUserAction, logPaymentEvent } from '../services/logger';
import config from '../config';
import {
getTicketAmountKeyboard,
getConfirmationKeyboard,
getViewTicketKeyboard,
getMainMenuKeyboard,
getCancelKeyboard,
} from '../utils/keyboards';
import { formatSats, formatDate, formatTimeUntil } from '../utils/format';
import { PendingPurchaseData, AwaitingPaymentData } from '../types';
import { messages } from '../messages';
/**
* Handle /buy command or "Buy Tickets" button
*/
export async function handleBuyCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
return;
}
logUserAction(userId, 'Initiated ticket purchase');
try {
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst);
return;
}
// Check if lightning address is set
if (!user.lightningAddress) {
await bot.sendMessage(chatId, messages.address.needAddressFirst, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
});
await stateManager.updateUserState(userId, 'awaiting_lightning_address');
return;
}
// Get next jackpot info
const jackpot = await apiClient.getNextJackpot();
if (!jackpot) {
await bot.sendMessage(chatId, messages.buy.noActiveJackpot, { parse_mode: 'Markdown' });
return;
}
// Show jackpot info and ticket selection
const drawTime = new Date(jackpot.cycle.scheduled_at);
const message = messages.buy.jackpotInfo(
formatSats(jackpot.cycle.pot_total_sats),
formatSats(jackpot.lottery.ticket_price_sats),
formatDate(drawTime),
formatTimeUntil(drawTime)
);
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: getTicketAmountKeyboard(),
});
// Store jackpot info in state for later use
await stateManager.updateUserState(userId, 'idle', {
cycleId: jackpot.cycle.id,
scheduledAt: jackpot.cycle.scheduled_at,
ticketPrice: jackpot.lottery.ticket_price_sats,
lotteryName: jackpot.lottery.name,
});
} catch (error) {
logger.error('Error in handleBuyCommand', { error, userId });
await bot.sendMessage(chatId, messages.errors.systemUnavailable);
}
}
/**
* Handle ticket amount selection
*/
export async function handleTicketAmountSelection(
bot: TelegramBot,
query: TelegramBot.CallbackQuery,
amount: number | 'custom'
): 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);
try {
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst);
return;
}
if (amount === 'custom') {
// Ask for custom amount
await bot.editMessageText(
messages.buy.customAmountPrompt(config.bot.maxTicketsPerPurchase),
{
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
}
);
// Get fresh jackpot info
const jackpot = await apiClient.getNextJackpot();
if (!jackpot) {
await bot.sendMessage(chatId, messages.buy.jackpotUnavailable);
return;
}
await stateManager.updateUserState(userId, 'awaiting_ticket_amount', {
cycleId: jackpot.cycle.id,
scheduledAt: jackpot.cycle.scheduled_at,
ticketPrice: jackpot.lottery.ticket_price_sats,
lotteryName: jackpot.lottery.name,
});
return;
}
// Process selected amount
await processTicketSelection(bot, chatId, messageId, userId, amount);
} catch (error) {
logger.error('Error in handleTicketAmountSelection', { error, userId });
await bot.sendMessage(chatId, messages.errors.generic);
}
}
/**
* Handle custom ticket amount input
*/
export async function handleCustomTicketAmount(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<boolean> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
const text = msg.text?.trim();
if (!userId || !text) return false;
try {
const user = await stateManager.getUser(userId);
if (!user || user.state !== 'awaiting_ticket_amount') {
return false;
}
const amount = parseInt(text, 10);
if (isNaN(amount) || amount < 1) {
await bot.sendMessage(
chatId,
messages.buy.invalidNumber(config.bot.maxTicketsPerPurchase),
{ reply_markup: getCancelKeyboard() }
);
return true;
}
if (amount > config.bot.maxTicketsPerPurchase) {
await bot.sendMessage(
chatId,
messages.buy.tooManyTickets(config.bot.maxTicketsPerPurchase),
{ reply_markup: getCancelKeyboard() }
);
return true;
}
await processTicketSelection(bot, chatId, undefined, userId, amount);
return true;
} catch (error) {
logger.error('Error in handleCustomTicketAmount', { error, userId });
await bot.sendMessage(chatId, messages.errors.generic);
return true;
}
}
/**
* Process ticket selection and show confirmation
*/
async function processTicketSelection(
bot: TelegramBot,
chatId: number,
messageId: number | undefined,
userId: number,
amount: number
): Promise<void> {
// Get fresh jackpot info
const jackpot = await apiClient.getNextJackpot();
if (!jackpot) {
await bot.sendMessage(chatId, messages.buy.jackpotUnavailable);
return;
}
const totalAmount = amount * jackpot.lottery.ticket_price_sats;
const confirmMessage = messages.buy.confirmPurchase(
amount,
formatSats(jackpot.lottery.ticket_price_sats),
formatSats(totalAmount),
formatDate(jackpot.cycle.scheduled_at)
);
const stateData: PendingPurchaseData = {
ticketCount: amount,
cycleId: jackpot.cycle.id,
scheduledAt: jackpot.cycle.scheduled_at,
ticketPrice: jackpot.lottery.ticket_price_sats,
totalAmount,
lotteryName: jackpot.lottery.name,
};
await stateManager.updateUserState(userId, 'idle', stateData);
if (messageId) {
await bot.editMessageText(confirmMessage, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: getConfirmationKeyboard(),
});
} else {
await bot.sendMessage(chatId, confirmMessage, {
parse_mode: 'Markdown',
reply_markup: getConfirmationKeyboard(),
});
}
}
/**
* Handle purchase confirmation
*/
export async function handlePurchaseConfirmation(
bot: TelegramBot,
query: TelegramBot.CallbackQuery
): 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, { text: messages.buy.creatingInvoice });
try {
const user = await stateManager.getUser(userId);
if (!user || !user.lightningAddress) {
await bot.sendMessage(chatId, messages.errors.setAddressFirst);
return;
}
const pendingData = user.stateData as PendingPurchaseData | undefined;
if (!pendingData?.ticketCount) {
await bot.sendMessage(chatId, messages.errors.noPendingPurchase);
return;
}
logUserAction(userId, 'Confirmed purchase', { tickets: pendingData.ticketCount });
// Create invoice
const purchaseResult = await apiClient.buyTickets(
pendingData.ticketCount,
user.lightningAddress,
userId
);
logPaymentEvent(userId, purchaseResult.ticket_purchase_id, 'created', {
tickets: pendingData.ticketCount,
amount: pendingData.totalAmount,
});
// Generate QR code
const qrBuffer = await generateQRCode(purchaseResult.invoice.payment_request);
// Update message to show invoice
await bot.editMessageText(messages.buy.invoiceCreated, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
});
// Send QR code
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
),
});
// Store purchase and start polling
const paymentData: AwaitingPaymentData = {
...pendingData,
purchaseId: purchaseResult.ticket_purchase_id,
paymentRequest: purchaseResult.invoice.payment_request,
publicUrl: purchaseResult.public_url,
pollStartTime: Date.now(),
};
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);
} catch (error) {
logger.error('Error in handlePurchaseConfirmation', { error, userId });
await bot.sendMessage(chatId, messages.errors.invoiceCreationFailed, {
reply_markup: getMainMenuKeyboard(),
});
await stateManager.clearUserStateData(userId);
}
}
/**
* Poll payment status
*/
async function pollPaymentStatus(
bot: TelegramBot,
chatId: number,
userId: number,
purchaseId: string
): Promise<void> {
const pollInterval = config.bot.paymentPollIntervalMs;
const timeout = config.bot.paymentPollTimeoutMs;
const startTime = Date.now();
logPaymentEvent(userId, purchaseId, 'polling');
const checkPayment = async (): Promise<void> => {
try {
// Check if we've timed out
if (Date.now() - startTime > timeout) {
logPaymentEvent(userId, purchaseId, 'expired');
await bot.sendMessage(chatId, messages.buy.invoiceExpired, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
await stateManager.clearUserStateData(userId);
return;
}
const status = await apiClient.getTicketStatus(purchaseId);
if (!status) {
// Purchase not found, stop polling
logger.warn('Purchase not found during polling', { purchaseId });
return;
}
if (status.purchase.invoice_status === 'paid') {
logPaymentEvent(userId, purchaseId, 'confirmed', {
tickets: status.tickets.length,
});
// Payment received!
const ticketNumbers = status.tickets
.map((t) => `#${t.serial_number.toString().padStart(4, '0')}`)
.join('\n');
await bot.sendMessage(
chatId,
messages.buy.paymentReceived(ticketNumbers, formatDate(status.cycle.scheduled_at)),
{
parse_mode: 'Markdown',
reply_markup: getViewTicketKeyboard(
purchaseId,
config.frontend.baseUrl + '/tickets/' + purchaseId
),
}
);
await stateManager.clearUserStateData(userId);
return;
}
if (status.purchase.invoice_status === 'expired') {
logPaymentEvent(userId, purchaseId, 'expired');
await bot.sendMessage(chatId, messages.buy.invoiceExpiredShort, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
await stateManager.clearUserStateData(userId);
return;
}
// Still pending, continue polling
setTimeout(checkPayment, pollInterval);
} catch (error) {
logger.error('Error polling payment status', { error, purchaseId });
// Continue polling despite errors
setTimeout(checkPayment, pollInterval);
}
};
// Start polling after initial delay
setTimeout(checkPayment, pollInterval);
}
export default {
handleBuyCommand,
handleTicketAmountSelection,
handleCustomTicketAmount,
handlePurchaseConfirmation,
};
+341
View File
@@ -0,0 +1,341 @@
import TelegramBot from 'node-telegram-bot-api';
import { groupStateManager } from '../services/groupState';
import { logger, logUserAction } from '../services/logger';
import { messages } from '../messages';
/**
* Check if a user is an admin in a group
*/
async function isGroupAdmin(
bot: TelegramBot,
chatId: number,
userId: number
): Promise<boolean> {
try {
const member = await bot.getChatMember(chatId, userId);
return ['creator', 'administrator'].includes(member.status);
} catch (error) {
logger.error('Failed to check admin status', { error, chatId, userId });
return false;
}
}
/**
* Handle bot being added to a group
*/
export async function handleBotAddedToGroup(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const chatTitle = msg.chat.title || 'Unknown Group';
const addedBy = msg.from?.id || 0;
logger.info('Bot added to group', { chatId, chatTitle, addedBy });
try {
const settings = await groupStateManager.registerGroup(chatId, chatTitle, addedBy);
await bot.sendMessage(chatId, messages.groups.welcome(chatTitle), {
parse_mode: 'Markdown',
});
} catch (error) {
logger.error('Failed to register group', { error, chatId });
}
}
/**
* Handle bot being removed from a group
*/
export async function handleBotRemovedFromGroup(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
logger.info('Bot removed from group', { chatId });
try {
await groupStateManager.removeGroup(chatId);
} catch (error) {
logger.error('Failed to remove group', { error, chatId });
}
}
/**
* Handle /settings command (group admin only)
*/
export async function handleGroupSettings(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
// Only works in groups
if (msg.chat.type === 'private') {
await bot.sendMessage(chatId, messages.groups.privateChat);
return;
}
if (!userId) return;
// Check if user is admin
const isAdmin = await isGroupAdmin(bot, chatId, userId);
if (!isAdmin) {
await bot.sendMessage(chatId, messages.groups.adminOnly);
return;
}
logUserAction(userId, 'Viewed group settings', { groupId: chatId });
try {
const settings = await groupStateManager.getGroup(chatId);
if (!settings) {
// Register group if not found
await groupStateManager.registerGroup(
chatId,
msg.chat.title || 'Group',
userId
);
}
const currentSettings = settings || await groupStateManager.getGroup(chatId);
if (!currentSettings) {
await bot.sendMessage(chatId, messages.errors.generic);
return;
}
await bot.sendMessage(
chatId,
messages.groups.settingsOverview(currentSettings),
{
parse_mode: 'Markdown',
reply_markup: getGroupSettingsKeyboard(currentSettings),
}
);
} catch (error) {
logger.error('Error in handleGroupSettings', { error, chatId });
await bot.sendMessage(chatId, messages.errors.generic);
}
}
/**
* Handle group settings toggle callback
*/
export async function handleGroupSettingsCallback(
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;
// Check if user is admin
const isAdmin = await isGroupAdmin(bot, chatId, userId);
if (!isAdmin) {
await bot.answerCallbackQuery(query.id, {
text: messages.groups.adminOnly,
show_alert: true,
});
return;
}
try {
let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed';
switch (action) {
case 'toggle_enabled':
setting = 'enabled';
break;
case 'toggle_announcements':
setting = 'drawAnnouncements';
break;
case 'toggle_reminders':
setting = 'reminders';
break;
case 'toggle_purchases':
setting = 'ticketPurchaseAllowed';
break;
default:
await bot.answerCallbackQuery(query.id);
return;
}
const currentSettings = await groupStateManager.getGroup(chatId);
if (!currentSettings) {
await bot.answerCallbackQuery(query.id, { text: 'Group not found' });
return;
}
const newValue = !currentSettings[setting];
const updatedSettings = await groupStateManager.updateSetting(chatId, setting, newValue);
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
await bot.editMessageText(
messages.groups.settingsOverview(updatedSettings),
{
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: getGroupSettingsKeyboard(updatedSettings),
}
);
} catch (error) {
logger.error('Error in handleGroupSettingsCallback', { error, chatId, action });
await bot.answerCallbackQuery(query.id, { text: 'Error updating settings' });
}
}
/**
* Generate inline keyboard for group settings
*/
function getGroupSettingsKeyboard(settings: {
enabled: boolean;
drawAnnouncements: boolean;
reminders: boolean;
ticketPurchaseAllowed: boolean;
}): TelegramBot.InlineKeyboardMarkup {
const onOff = (val: boolean) => val ? '✅' : '❌';
return {
inline_keyboard: [
[{
text: `${onOff(settings.enabled)} Bot Enabled`,
callback_data: 'group_toggle_enabled',
}],
[{
text: `${onOff(settings.drawAnnouncements)} Draw Announcements`,
callback_data: 'group_toggle_announcements',
}],
[{
text: `${onOff(settings.reminders)} Draw Reminders`,
callback_data: 'group_toggle_reminders',
}],
[{
text: `${onOff(settings.ticketPurchaseAllowed)} Allow Ticket Purchases`,
callback_data: 'group_toggle_purchases',
}],
[{
text: '🔄 Refresh',
callback_data: 'group_refresh',
}],
],
};
}
/**
* Handle refresh callback
*/
export async function handleGroupRefresh(
bot: TelegramBot,
query: TelegramBot.CallbackQuery
): Promise<void> {
const chatId = query.message?.chat.id;
const messageId = query.message?.message_id;
if (!chatId || !messageId) return;
await bot.answerCallbackQuery(query.id, { text: 'Refreshed!' });
const settings = await groupStateManager.getGroup(chatId);
if (!settings) return;
await bot.editMessageText(
messages.groups.settingsOverview(settings),
{
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: getGroupSettingsKeyboard(settings),
}
);
}
/**
* Send draw announcement to all enabled groups
*/
export async function broadcastDrawAnnouncement(
bot: TelegramBot,
announcement: string
): Promise<number> {
const groups = await groupStateManager.getGroupsWithFeature('drawAnnouncements');
let sent = 0;
for (const group of groups) {
try {
await bot.sendMessage(group.groupId, announcement, { parse_mode: 'Markdown' });
sent++;
} catch (error) {
logger.error('Failed to send announcement to group', {
groupId: group.groupId,
error,
});
// If bot was removed from group, clean up
if ((error as any)?.response?.statusCode === 403) {
await groupStateManager.removeGroup(group.groupId);
}
}
}
logger.info('Broadcast draw announcement', { sent, total: groups.length });
return sent;
}
/**
* Send draw reminder to all enabled groups
*/
export async function broadcastDrawReminder(
bot: TelegramBot,
reminder: string
): Promise<number> {
const groups = await groupStateManager.getGroupsWithFeature('reminders');
let sent = 0;
for (const group of groups) {
try {
await bot.sendMessage(group.groupId, reminder, { parse_mode: 'Markdown' });
sent++;
} catch (error) {
logger.error('Failed to send reminder to group', {
groupId: group.groupId,
error,
});
if ((error as any)?.response?.statusCode === 403) {
await groupStateManager.removeGroup(group.groupId);
}
}
}
logger.info('Broadcast draw reminder', { sent, total: groups.length });
return sent;
}
export default {
handleBotAddedToGroup,
handleBotRemovedFromGroup,
handleGroupSettings,
handleGroupSettingsCallback,
handleGroupRefresh,
broadcastDrawAnnouncement,
broadcastDrawReminder,
};
+26
View File
@@ -0,0 +1,26 @@
import TelegramBot from 'node-telegram-bot-api';
import { logUserAction } from '../services/logger';
import { getMainMenuKeyboard } from '../utils/keyboards';
import { messages } from '../messages';
/**
* Handle /help command
*/
export async function handleHelpCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (userId) {
logUserAction(userId, 'Viewed help');
}
await bot.sendMessage(chatId, messages.help.message, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
}
export default handleHelpCommand;
+33
View File
@@ -0,0 +1,33 @@
export { handleStart } from './start';
export {
handleAddressCommand,
handleLightningAddressInput,
} from './address';
export {
handleBuyCommand,
handleTicketAmountSelection,
handleCustomTicketAmount,
handlePurchaseConfirmation,
} from './buy';
export {
handleTicketsCommand,
handleViewTicket,
handleStatusCheck,
} from './tickets';
export { handleWinsCommand } from './wins';
export { handleHelpCommand } from './help';
export {
handleMenuCommand,
handleCancel,
handleMenuCallback,
} from './menu';
export {
handleBotAddedToGroup,
handleBotRemovedFromGroup,
handleGroupSettings,
handleGroupSettingsCallback,
handleGroupRefresh,
broadcastDrawAnnouncement,
broadcastDrawReminder,
} from './groups';
+92
View File
@@ -0,0 +1,92 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { logUserAction } from '../services/logger';
import { getMainMenuKeyboard } from '../utils/keyboards';
import { messages } from '../messages';
/**
* Handle /menu command
*/
export async function handleMenuCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (userId) {
logUserAction(userId, 'Opened menu');
// Reset user state to idle
const user = await stateManager.getUser(userId);
if (user && user.lightningAddress) {
await stateManager.updateUserState(userId, 'idle');
}
}
await bot.sendMessage(chatId, messages.menu.header, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
}
/**
* Handle cancel callback
*/
export async function handleCancel(
bot: TelegramBot,
query: TelegramBot.CallbackQuery
): Promise<void> {
const chatId = query.message?.chat.id;
const userId = query.from.id;
const messageId = query.message?.message_id;
if (!chatId) return;
await bot.answerCallbackQuery(query.id, { text: 'Cancelled' });
// Clear user state
await stateManager.clearUserStateData(userId);
// Update message
if (messageId) {
await bot.editMessageText(messages.menu.cancelled, {
chat_id: chatId,
message_id: messageId,
});
}
// Show menu
await bot.sendMessage(chatId, messages.menu.whatToDo, {
reply_markup: getMainMenuKeyboard(),
});
}
/**
* Handle menu callback (back to menu)
*/
export async function handleMenuCallback(
bot: TelegramBot,
query: TelegramBot.CallbackQuery
): Promise<void> {
const chatId = query.message?.chat.id;
const userId = query.from.id;
if (!chatId) return;
await bot.answerCallbackQuery(query.id);
// Clear user state
await stateManager.clearUserStateData(userId);
await bot.sendMessage(chatId, messages.menu.header, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
}
export default {
handleMenuCommand,
handleCancel,
handleMenuCallback,
};
+68
View File
@@ -0,0 +1,68 @@
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 { messages } from '../messages';
/**
* Handle /start command
*/
export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
return;
}
logUserAction(userId, 'Started bot', {
username: msg.from?.username,
firstName: msg.from?.first_name,
});
try {
// Check if user exists
let user = await stateManager.getUser(userId);
if (!user) {
// Create new user
user = await stateManager.createUser(
userId,
msg.from?.username,
msg.from?.first_name,
msg.from?.last_name
);
}
// Welcome message
await bot.sendMessage(chatId, messages.start.welcome, { parse_mode: 'Markdown' });
// Check if lightning address is set
if (!user.lightningAddress) {
await bot.sendMessage(chatId, messages.start.needAddress, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
});
await stateManager.updateUserState(userId, 'awaiting_lightning_address');
} else {
// Show main menu
await bot.sendMessage(
chatId,
messages.start.addressSet(user.lightningAddress),
{
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
}
);
await stateManager.updateUserState(userId, 'idle');
}
} catch (error) {
logger.error('Error in handleStart', { error, userId });
await bot.sendMessage(chatId, messages.errors.startAgain);
}
}
export default handleStart;
+259
View File
@@ -0,0 +1,259 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { apiClient } from '../services/api';
import { logger, logUserAction } from '../services/logger';
import config from '../config';
import { getMainMenuKeyboard, getViewTicketKeyboard } from '../utils/keyboards';
import { formatSats, formatDate } from '../utils/format';
import { messages } from '../messages';
/**
* Handle /tickets command or "My Tickets" button
*/
export async function handleTicketsCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
return;
}
logUserAction(userId, 'Viewed tickets');
try {
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst);
return;
}
// Get user's purchase IDs from state
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 10);
if (purchaseIds.length === 0) {
await bot.sendMessage(chatId, messages.tickets.empty, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
return;
}
// Fetch status for each purchase
const purchases: Array<{
id: string;
ticketCount: number;
scheduledAt: string;
invoiceStatus: string;
isWinner: boolean;
hasDrawn: boolean;
}> = [];
for (const purchaseId of purchaseIds) {
try {
const status = await apiClient.getTicketStatus(purchaseId);
if (status) {
purchases.push({
id: status.purchase.id,
ticketCount: status.purchase.number_of_tickets,
scheduledAt: status.cycle.scheduled_at,
invoiceStatus: status.purchase.invoice_status,
isWinner: status.result.is_winner,
hasDrawn: status.result.has_drawn,
});
}
} catch (error) {
// Skip failed fetches
logger.debug('Failed to fetch purchase', { purchaseId });
}
}
if (purchases.length === 0) {
await bot.sendMessage(chatId, messages.tickets.notFound, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
return;
}
// Format purchases list
let message = messages.tickets.header;
for (let i = 0; i < purchases.length; i++) {
const p = purchases[i];
const drawDate = new Date(p.scheduledAt);
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)}...`,
callback_data: `view_ticket_${p.id}`,
}]);
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: inlineKeyboard },
});
} catch (error) {
logger.error('Error in handleTicketsCommand', { error, userId });
await bot.sendMessage(chatId, messages.errors.fetchTicketsFailed, {
reply_markup: getMainMenuKeyboard(),
});
}
}
/**
* Handle viewing a specific ticket
*/
export async function handleViewTicket(
bot: TelegramBot,
query: TelegramBot.CallbackQuery,
purchaseId: string
): Promise<void> {
const chatId = query.message?.chat.id;
if (!chatId) return;
await bot.answerCallbackQuery(query.id);
try {
const status = await apiClient.getTicketStatus(purchaseId);
if (!status) {
await bot.sendMessage(chatId, messages.errors.ticketNotFound);
return;
}
const drawDate = new Date(status.cycle.scheduled_at);
const ticketNumbers = status.tickets
.map((t) => {
const winnerMark = t.is_winning_ticket ? ' 🏆' : '';
return `#${t.serial_number.toString().padStart(4, '0')}${winnerMark}`;
})
.join(', ');
let statusSection: string;
if (status.purchase.invoice_status === 'pending') {
statusSection = messages.tickets.detailAwaitingPayment;
} else if (status.purchase.invoice_status === 'expired') {
statusSection = messages.tickets.detailExpired;
} else if (!status.result.has_drawn) {
statusSection = messages.tickets.detailActive;
} else if (status.result.is_winner) {
const payoutStatus = status.result.payout?.status === 'paid' ? 'Paid ✅' : 'Pending';
statusSection = messages.tickets.detailWinner(
formatSats(status.result.payout?.amount_sats || 0),
payoutStatus
);
} else {
statusSection = messages.tickets.detailLost(
status.cycle.winning_ticket_id?.substring(0, 8) || 'N/A'
);
}
const message = messages.tickets.detailFormat(
status.purchase.id.substring(0, 8),
status.purchase.number_of_tickets,
ticketNumbers,
formatSats(status.purchase.amount_sats),
formatDate(drawDate),
statusSection
);
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: getViewTicketKeyboard(
purchaseId,
config.frontend.baseUrl + '/tickets/' + purchaseId
),
});
} catch (error) {
logger.error('Error in handleViewTicket', { error, purchaseId });
await bot.sendMessage(chatId, messages.errors.fetchTicketDetailsFailed);
}
}
/**
* Handle status check callback
*/
export async function handleStatusCheck(
bot: TelegramBot,
query: TelegramBot.CallbackQuery,
purchaseId: string
): Promise<void> {
const chatId = query.message?.chat.id;
if (!chatId) return;
await bot.answerCallbackQuery(query.id, { text: 'Checking status...' });
try {
const status = await apiClient.getTicketStatus(purchaseId);
if (!status) {
await bot.sendMessage(chatId, messages.errors.ticketNotFound);
return;
}
let statusMessage: string;
if (status.purchase.invoice_status === 'pending') {
statusMessage = messages.tickets.checkStatusPending;
} else if (status.purchase.invoice_status === 'paid' && status.purchase.ticket_issue_status === 'issued') {
if (!status.result.has_drawn) {
statusMessage = messages.tickets.checkStatusConfirmed;
} else if (status.result.is_winner) {
statusMessage = messages.tickets.checkStatusWon;
} else {
statusMessage = messages.tickets.checkStatusLost;
}
} else if (status.purchase.invoice_status === 'expired') {
statusMessage = messages.tickets.checkStatusExpired;
} else {
statusMessage = messages.tickets.checkStatusProcessing;
}
await bot.answerCallbackQuery(query.id, { text: statusMessage, show_alert: true });
} catch (error) {
logger.error('Error in handleStatusCheck', { error, purchaseId });
await bot.answerCallbackQuery(query.id, {
text: messages.errors.checkStatusFailed,
show_alert: true,
});
}
}
export default {
handleTicketsCommand,
handleViewTicket,
handleStatusCheck,
};
+114
View File
@@ -0,0 +1,114 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { apiClient } from '../services/api';
import { logger, logUserAction } from '../services/logger';
import { getMainMenuKeyboard } from '../utils/keyboards';
import { formatSats, formatDate } from '../utils/format';
import { messages } from '../messages';
/**
* Handle /wins command or "My Wins" button
*/
export async function handleWinsCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
return;
}
logUserAction(userId, 'Viewed wins');
try {
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst);
return;
}
// Get user's purchase IDs and check for wins
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 50);
if (purchaseIds.length === 0) {
await bot.sendMessage(chatId, messages.wins.empty, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
return;
}
// Check each purchase for wins
const wins: Array<{
purchaseId: string;
ticketId: string;
serialNumber: number;
amountSats: number;
status: string;
scheduledAt: string;
}> = [];
for (const purchaseId of purchaseIds) {
try {
const status = await apiClient.getTicketStatus(purchaseId);
if (status && status.result.is_winner && status.result.payout) {
const winningTicket = status.tickets.find(t => t.is_winning_ticket);
wins.push({
purchaseId: status.purchase.id,
ticketId: winningTicket?.id || '',
serialNumber: winningTicket?.serial_number || 0,
amountSats: status.result.payout.amount_sats,
status: status.result.payout.status,
scheduledAt: status.cycle.scheduled_at,
});
}
} catch (error) {
// Skip failed fetches
logger.debug('Failed to fetch purchase for wins', { purchaseId });
}
}
if (wins.length === 0) {
await bot.sendMessage(chatId, messages.wins.noWinsYet, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
return;
}
// Calculate totals
const totalWinnings = wins.reduce((sum, w) => sum + w.amountSats, 0);
const paidWinnings = wins.filter(w => w.status === 'paid').reduce((sum, w) => sum + w.amountSats, 0);
let message = messages.wins.header(formatSats(totalWinnings), formatSats(paidWinnings));
for (const win of wins) {
const statusEmoji = win.status === 'paid' ? '✅' : '⏳';
message += `${formatSats(win.amountSats)} sats — ${formatDate(win.scheduledAt)}${statusEmoji} ${win.status}\n`;
}
// Create buttons for viewing wins
const inlineKeyboard = wins.slice(0, 5).map((w) => [{
text: `View Ticket #${w.serialNumber.toString().padStart(4, '0')}`,
callback_data: `view_ticket_${w.purchaseId}`,
}]);
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: inlineKeyboard },
});
} catch (error) {
logger.error('Error in handleWinsCommand', { error, userId });
await bot.sendMessage(chatId, messages.errors.fetchWinsFailed, {
reply_markup: getMainMenuKeyboard(),
});
}
}
export default {
handleWinsCommand,
};
+448
View File
@@ -0,0 +1,448 @@
import TelegramBot from 'node-telegram-bot-api';
import config from './config';
import { stateManager } from './services/state';
import { groupStateManager } from './services/groupState';
import { apiClient } from './services/api';
import { logger, logUserAction } from './services/logger';
import {
handleStart,
handleAddressCommand,
handleLightningAddressInput,
handleBuyCommand,
handleTicketAmountSelection,
handleCustomTicketAmount,
handlePurchaseConfirmation,
handleTicketsCommand,
handleViewTicket,
handleStatusCheck,
handleWinsCommand,
handleHelpCommand,
handleMenuCommand,
handleCancel,
handleMenuCallback,
handleBotAddedToGroup,
handleBotRemovedFromGroup,
handleGroupSettings,
handleGroupSettingsCallback,
handleGroupRefresh,
} from './handlers';
import { getMainMenuKeyboard } from './utils/keyboards';
import { messages } from './messages';
import { formatSats, formatDate, formatTimeUntil } from './utils/format';
// Create bot instance
const bot = new TelegramBot(config.telegram.botToken, { polling: true });
// Track processed message IDs to prevent duplicate handling
const processedMessages = new Set<number>();
const MESSAGE_CACHE_TTL = 60000; // 1 minute
function shouldProcessMessage(messageId: number): boolean {
if (processedMessages.has(messageId)) {
return false;
}
processedMessages.add(messageId);
// Clean up old entries
setTimeout(() => processedMessages.delete(messageId), MESSAGE_CACHE_TTL);
return true;
}
/**
* Check if message is from a group
*/
function isGroupChat(msg: TelegramBot.Message): boolean {
return msg.chat.type === 'group' || msg.chat.type === 'supergroup';
}
// ═══════════════════════════════════════════════════════════════════════════
// GROUP EVENTS
// ═══════════════════════════════════════════════════════════════════════════
// Handle bot being added/removed from groups
bot.on('message', async (msg) => {
// Handle new chat members (bot added to group)
if (msg.new_chat_members) {
const botInfo = await bot.getMe();
const botAdded = msg.new_chat_members.some(m => m.id === botInfo.id);
if (botAdded) {
await handleBotAddedToGroup(bot, msg);
}
}
// Handle chat member left (bot removed from group)
if (msg.left_chat_member) {
const botInfo = await bot.getMe();
if (msg.left_chat_member.id === botInfo.id) {
await handleBotRemovedFromGroup(bot, msg);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// COMMANDS
// ═══════════════════════════════════════════════════════════════════════════
// Handle /start command
bot.onText(/\/start/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// In groups, just show a welcome message
if (isGroupChat(msg)) {
await bot.sendMessage(
msg.chat.id,
`⚡ *Lightning Jackpot Bot*\n\nTo buy tickets and manage your account, message me directly!\n\nUse /jackpot to see current jackpot info.\nAdmins: Use /settings to configure the bot.`,
{ parse_mode: 'Markdown' }
);
return;
}
await handleStart(bot, msg);
});
// Handle /buy command
bot.onText(/\/buy/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Check if in group
if (isGroupChat(msg)) {
const settings = await groupStateManager.getGroup(msg.chat.id);
if (settings && !settings.ticketPurchaseAllowed) {
await bot.sendMessage(msg.chat.id, messages.groups.purchasesDisabled, {
parse_mode: 'Markdown',
});
return;
}
}
await handleBuyCommand(bot, msg);
});
// Handle /tickets command
bot.onText(/\/tickets/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '🧾 To view your tickets, message me directly!');
return;
}
await handleTicketsCommand(bot, msg);
});
// Handle /wins command
bot.onText(/\/wins/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '🏆 To view your wins, message me directly!');
return;
}
await handleWinsCommand(bot, msg);
});
// Handle /address command
bot.onText(/\/address/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '⚡ To update your Lightning Address, message me directly!');
return;
}
await handleAddressCommand(bot, msg);
});
// Handle /menu command
bot.onText(/\/menu/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '📱 To access the full menu, message me directly!');
return;
}
await handleMenuCommand(bot, msg);
});
// Handle /help command
bot.onText(/\/help/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
await handleHelpCommand(bot, msg);
});
// Handle /jackpot command (works in groups and DMs)
bot.onText(/\/jackpot/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
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 message = `🎰 *Current 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 /buy to get your tickets! 🍀`;
await bot.sendMessage(msg.chat.id, message, { parse_mode: 'Markdown' });
} catch (error) {
logger.error('Error in /jackpot command', { error });
await bot.sendMessage(msg.chat.id, messages.errors.systemUnavailable);
}
});
// Handle /settings command (groups only, admin only)
bot.onText(/\/settings/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
await handleGroupSettings(bot, msg);
});
// ═══════════════════════════════════════════════════════════════════════════
// TEXT MESSAGES
// ═══════════════════════════════════════════════════════════════════════════
// Handle keyboard button presses (text messages)
bot.on('message', async (msg) => {
if (!msg.text || msg.text.startsWith('/')) return;
if (!shouldProcessMessage(msg.message_id)) return;
// Ignore group messages for button handling
if (isGroupChat(msg)) return;
const text = msg.text.trim();
const userId = msg.from?.id;
if (!userId) return;
// Handle menu button presses
switch (text) {
case '🎟 Buy Tickets':
await handleBuyCommand(bot, msg);
return;
case '🧾 My Tickets':
await handleTicketsCommand(bot, msg);
return;
case '🏆 My Wins':
await handleWinsCommand(bot, msg);
return;
case '⚡ Lightning Address':
await handleAddressCommand(bot, msg);
return;
case '️ Help':
await handleHelpCommand(bot, msg);
return;
}
// Handle state-dependent text input
try {
const user = await stateManager.getUser(userId);
if (!user) {
// Unknown user, prompt to start
await bot.sendMessage(msg.chat.id, messages.start.welcomeUnknown);
return;
}
// Handle lightning address input
if (user.state === 'awaiting_lightning_address' || user.state === 'updating_address') {
const handled = await handleLightningAddressInput(bot, msg);
if (handled) return;
}
// Handle custom ticket amount input
if (user.state === 'awaiting_ticket_amount') {
const handled = await handleCustomTicketAmount(bot, msg);
if (handled) return;
}
// Unhandled message - show menu prompt
if (user.state === 'idle') {
await bot.sendMessage(msg.chat.id, messages.menu.didNotUnderstand, {
reply_markup: getMainMenuKeyboard(),
});
}
} catch (error) {
logger.error('Error handling message', { error, userId, text });
}
});
// ═══════════════════════════════════════════════════════════════════════════
// CALLBACK QUERIES
// ═══════════════════════════════════════════════════════════════════════════
// Handle callback queries (inline button presses)
bot.on('callback_query', async (query) => {
const data = query.data;
if (!data) {
await bot.answerCallbackQuery(query.id);
return;
}
logUserAction(query.from.id, 'Callback', { data });
try {
// Handle group settings toggles
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);
return;
}
// Handle buy amount selection
if (data.startsWith('buy_')) {
const amountStr = data.replace('buy_', '');
if (amountStr === 'custom') {
await handleTicketAmountSelection(bot, query, 'custom');
} else {
const amount = parseInt(amountStr, 10);
if (!isNaN(amount)) {
await handleTicketAmountSelection(bot, query, amount);
}
}
return;
}
// Handle purchase confirmation
if (data === 'confirm_purchase') {
await handlePurchaseConfirmation(bot, query);
return;
}
// Handle cancel
if (data === 'cancel') {
await handleCancel(bot, query);
return;
}
// Handle menu
if (data === 'menu') {
await handleMenuCallback(bot, query);
return;
}
// Handle view ticket
if (data.startsWith('view_ticket_')) {
const purchaseId = data.replace('view_ticket_', '');
await handleViewTicket(bot, query, purchaseId);
return;
}
// Handle status check
if (data.startsWith('status_')) {
const purchaseId = data.replace('status_', '');
await handleStatusCheck(bot, query, purchaseId);
return;
}
// Handle ticket pagination
if (data.startsWith('tickets_page_')) {
// TODO: Implement pagination
await bot.answerCallbackQuery(query.id, { text: 'Pagination coming soon!' });
return;
}
// Unknown callback
await bot.answerCallbackQuery(query.id);
} catch (error) {
logger.error('Error handling callback query', { error, data });
await bot.answerCallbackQuery(query.id, {
text: messages.errors.generic,
show_alert: true,
});
}
});
// ═══════════════════════════════════════════════════════════════════════════
// ERROR HANDLING & LIFECYCLE
// ═══════════════════════════════════════════════════════════════════════════
// Handle polling errors
bot.on('polling_error', (error) => {
logger.error('Polling error', { error: error.message });
});
// Graceful shutdown
async function shutdown(): Promise<void> {
logger.info('Shutting down...');
bot.stopPolling();
await stateManager.close();
await groupStateManager.close();
logger.info('Shutdown complete');
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Start bot
async function start(): Promise<void> {
try {
// Initialize state managers
await stateManager.init();
await groupStateManager.init(config.redis.url);
// 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: 'tickets', description: 'View your tickets' },
{ command: 'wins', description: 'View your past wins' },
{ command: 'address', description: 'Update Lightning Address' },
{ command: 'jackpot', description: 'View current jackpot info' },
{ command: 'help', 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' },
],
{ scope: { type: 'all_group_chats' } }
);
const botInfo = await bot.getMe();
logger.info(`🤖 Bot started: @${botInfo.username}`, {
id: botInfo.id,
username: botInfo.username,
});
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');
} catch (error) {
logger.error('Failed to start bot', { error });
process.exit(1);
}
}
start();
// Export bot instance and broadcast functions for external use
export { bot };
export { broadcastDrawAnnouncement, broadcastDrawReminder } from './handlers';
+444
View File
@@ -0,0 +1,444 @@
/**
* All Telegram bot messages centralized for easy management and future i18n support
*/
export const messages = {
// ═══════════════════════════════════════════════════════════════════════════
// ERRORS
// ═══════════════════════════════════════════════════════════════════════════
errors: {
userNotIdentified: '❌ Could not identify user.',
startFirst: '❌ Please start the bot first with /start',
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.',
invoiceCreationFailed: '❌ Failed to create invoice. Please try again.',
fetchTicketsFailed: '❌ Failed to fetch tickets. Please try again.',
fetchWinsFailed: '❌ Failed to fetch wins. Please try again.',
ticketNotFound: '❌ Ticket not found.',
fetchTicketDetailsFailed: '❌ Failed to fetch ticket details.',
checkStatusFailed: '❌ Failed to check status',
noPendingPurchase: '❌ No pending purchase. Please start again with /buy',
setAddressFirst: '❌ Please set your Lightning Address first.',
},
// ═══════════════════════════════════════════════════════════════════════════
// START / ONBOARDING
// ═══════════════════════════════════════════════════════════════════════════
start: {
welcome: `⚡🎉 *Welcome to Lightning Jackpot!* 🎉⚡
You can buy Bitcoin Lightning lottery tickets, and if you win, your prize is paid instantly to your Lightning Address!
🎯 *How it works:*
1 Set your Lightning Address (where you'll receive winnings)
2 Buy tickets with Lightning
3 Wait for the draw
4 If you win, prize is sent instantly!`,
needAddress: `Before you can play, I need your Lightning Address to send any winnings.
*Example:* \`yourname@getalby.com\`
Please send your Lightning Address now:`,
addressSet: (address: string) =>
`✅ Your payout address is set to: \`${address}\`
Use the menu below to get started! Good luck! 🍀`,
welcomeUnknown: '👋 Welcome! Please use /start to begin.',
},
// ═══════════════════════════════════════════════════════════════════════════
// LIGHTNING ADDRESS
// ═══════════════════════════════════════════════════════════════════════════
address: {
currentAddress: (address: string) =>
`⚡ *Your Current Payout Address:*
\`${address}\`
Send me your new Lightning Address to update it:`,
noAddressSet: `⚡ You don't have a Lightning Address set yet.
Send me your Lightning Address now:
*Example:* \`yourname@getalby.com\``,
invalidFormat: `❌ That doesn't look like a valid Lightning Address.
*Format:* \`username@domain.com\`
*Example:* \`satoshi@getalby.com\`
Please try again:`,
firstTimeSuccess: (address: string) =>
`✅ *Perfect!* I'll use \`${address}\` to send any winnings.
You're all set! Use the menu below to buy tickets and check your results. Good luck! 🍀`,
updateSuccess: (address: string) =>
`✅ *Lightning Address updated!*
New address: \`${address}\`
*Note:* Previous ticket purchases will still use their original addresses.`,
saveFailed: '❌ An error occurred saving your address. Please try again.',
needAddressFirst: `❌ I don't have your Lightning Address yet!
Please send your Lightning Address first:
*Example:* \`yourname@getalby.com\``,
},
// ═══════════════════════════════════════════════════════════════════════════
// BUY TICKETS
// ═══════════════════════════════════════════════════════════════════════════
buy: {
noActiveJackpot: `😔 *No Active Jackpot*
There's no lottery cycle available right now. Please check back soon!`,
jackpotInfo: (
potSats: string,
ticketPrice: string,
drawTime: string,
timeLeft: string
) =>
`🎰 *Next Jackpot Info*
💰 *Prize Pool:* ${potSats} sats
🎟 *Ticket Price:* ${ticketPrice} sats each
*Draw at:* ${drawTime}
*Time left:* ${timeLeft}
How many tickets do you want to buy?`,
customAmountPrompt: (maxTickets: number) =>
`🔢 *Custom Amount*
Enter the number of tickets you want to buy (1-${maxTickets}):`,
invalidNumber: (maxTickets: number) =>
`❌ Please enter a valid number (1-${maxTickets}):`,
tooManyTickets: (maxTickets: number) =>
`❌ Maximum ${maxTickets} tickets per purchase.
Please enter a smaller number:`,
confirmPurchase: (
ticketCount: number,
ticketPrice: string,
totalAmount: string,
drawTime: string
) =>
`📋 *Confirm Purchase*
🎟 *Tickets:* ${ticketCount}
💰 *Price:* ${ticketPrice} sats each
💵 *Total:* ${totalAmount} sats
*Draw:* ${drawTime}
Confirm this purchase?`,
creatingInvoice: 'Creating invoice...',
invoiceCreated: '💸 *Pay this Lightning invoice to complete your purchase:*',
invoiceCaption: (
ticketCount: number,
totalAmount: string,
paymentRequest: string,
expiryMinutes: number
) =>
`🎟 *${ticketCount} ticket${ticketCount > 1 ? 's' : ''}*
💰 *Amount:* ${totalAmount} sats
\`${paymentRequest}\`
This invoice expires in ${expiryMinutes} minutes.
I'll notify you when payment is received!`,
paymentReceived: (ticketNumbers: string, drawTime: string) =>
`🎉 *Payment Received!*
Your tickets have been issued!
*Your Ticket Numbers:*
${ticketNumbers}
*Draw Time:* ${drawTime}
Good luck! 🍀 I'll notify you after the draw!`,
invoiceExpired: `❌ *Invoice Expired*
No payment was received in time. No tickets were issued.
Use /buy to try again.`,
invoiceExpiredShort: `❌ *Invoice Expired*
This invoice has expired. No tickets were issued.
Use /buy to try again.`,
jackpotUnavailable: '❌ Jackpot is no longer available.',
},
// ═══════════════════════════════════════════════════════════════════════════
// TICKETS
// ═══════════════════════════════════════════════════════════════════════════
tickets: {
header: `🧾 *Your Recent Purchases*\n\n`,
empty: `🧾 *Your Tickets*
You haven't purchased any tickets yet!
Use /buy to get started! 🎟`,
notFound: `🧾 *Your Tickets*
No ticket purchases found. Purchase history may have expired.
Use /buy to get new tickets! 🎟`,
tapForDetails: `\nTap a ticket below for details:`,
statusPending: 'Pending',
statusExpired: 'Expired',
statusActive: 'Active',
statusWon: 'Won!',
statusLost: 'Lost',
// Ticket detail view
detailHeader: '🎫 *Ticket Details*',
detailAwaitingPayment: '⏳ *Status:* Awaiting Payment',
detailExpired: '❌ *Status:* Invoice Expired (No tickets issued)',
detailActive: '🎟 *Status:* Active - Draw Pending',
detailWinner: (prizeSats: string, payoutStatus: string) =>
`🏆 *Status:* WINNER!
💰 *Prize:* ${prizeSats} sats
📤 *Payout:* ${payoutStatus}`,
detailLost: (winningTicketId: string) =>
`😔 *Status:* Did not win this round
🎯 *Winning Ticket:* #${winningTicketId}`,
detailFormat: (
purchaseId: string,
ticketCount: number,
ticketNumbers: string,
amountSats: string,
drawDate: string,
statusSection: string
) =>
`🎫 *Ticket Details*
📋 *Purchase ID:* \`${purchaseId}...\`
🎟 *Tickets:* ${ticketCount}
🔢 *Numbers:* ${ticketNumbers}
💰 *Amount Paid:* ${amountSats} sats
📅 *Draw:* ${drawDate}
${statusSection}`,
// Status check responses
checkStatusPending: '⏳ Still waiting for payment...',
checkStatusConfirmed: '✅ Payment confirmed! Tickets issued. Waiting for draw...',
checkStatusWon: '🏆 YOU WON! Check your Lightning wallet!',
checkStatusLost: '😔 Draw completed. Better luck next time!',
checkStatusExpired: '❌ Invoice expired. No tickets were issued.',
checkStatusProcessing: '⏳ Processing...',
},
// ═══════════════════════════════════════════════════════════════════════════
// WINS
// ═══════════════════════════════════════════════════════════════════════════
wins: {
empty: `🏆 *Your Wins*
You haven't purchased any tickets yet, so no wins to show!
Use /buy to get started! 🎟`,
noWinsYet: `🏆 *Your Wins*
You haven't won any jackpots yet. Keep playing!
Use /buy to get more tickets! 🎟🍀`,
header: (totalWinnings: string, paidWinnings: string) =>
`🏆 *Your Wins*
💰 *Total Winnings:* ${totalWinnings} sats
*Paid:* ${paidWinnings} sats
*Win History:*
`,
},
// ═══════════════════════════════════════════════════════════════════════════
// HELP
// ═══════════════════════════════════════════════════════════════════════════
help: {
message: `⚡🎰 *Lightning Jackpot Bot* 🎰⚡
This is the Lightning Jackpot lottery bot! Buy tickets with Bitcoin Lightning, and if you win, your prize is paid instantly!
*How It Works:*
1 Set your Lightning Address (where winnings go)
2 Buy tickets using the menu
3 Pay the Lightning invoice
4 Wait for the draw
5 If you win, sats are sent to your address instantly!
*Commands:*
/buy Buy lottery tickets
/tickets View your tickets
/wins View your past wins
/address Update Lightning Address
/menu Show main menu
/help Show this help
*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
Good luck! 🍀`,
},
// ═══════════════════════════════════════════════════════════════════════════
// MENU
// ═══════════════════════════════════════════════════════════════════════════
menu: {
header: `🎰 *Lightning Jackpot Menu*
What would you like to do?`,
cancelled: '❌ Cancelled.',
whatToDo: 'What would you like to do?',
didNotUnderstand:
"I didn't understand that. Use the menu below or type /help for available commands.",
},
// ═══════════════════════════════════════════════════════════════════════════
// DRAW NOTIFICATIONS
// ═══════════════════════════════════════════════════════════════════════════
notifications: {
winner: (
prizeSats: string,
winningTicket: string,
payoutStatus: string
) =>
`🎉 *YOU WON THE LIGHTNING JACKPOT!* 🎉
💰 *Prize:* ${prizeSats} sats
🎟 *Winning Ticket:* #${winningTicket}
📤 *Payout Status:* ${payoutStatus}
Congratulations! 🥳`,
loser: (winningTicket: string, prizeSats: string) =>
`The draw has finished!
Your tickets did not win this time.
🎟 *Winning Ticket:* #${winningTicket}
💰 *Prize:* ${prizeSats} sats
Good luck next round! 🍀`,
drawAnnouncement: (
winnerName: string,
winningTicket: string,
prizeSats: string,
totalTickets: number
) =>
`🎰 *JACKPOT DRAW COMPLETE!* 🎰
🏆 *Winner:* ${winnerName}
🎟 *Winning Ticket:* #${winningTicket}
💰 *Prize:* ${prizeSats} sats
📊 *Total Tickets:* ${totalTickets}
Congratulations to the winner!
Use /buy to enter the next draw! 🍀`,
drawReminder: (potSats: string, drawTime: string, timeLeft: string) =>
`⏰ *Draw Reminder!*
🎰 The next Lightning Jackpot draw is coming up!
💰 *Current Prize Pool:* ${potSats} sats
🕐 *Draw Time:* ${drawTime}
*Time Left:* ${timeLeft}
Don't miss your chance to win! Use /buy to get your tickets! 🎟`,
},
// ═══════════════════════════════════════════════════════════════════════════
// GROUPS
// ═══════════════════════════════════════════════════════════════════════════
groups: {
welcome: (groupName: string) =>
`⚡🎰 *Lightning Jackpot Bot Added!* 🎰⚡
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
*User Commands:*
/buy Buy lottery tickets (in DM)
/jackpot View current jackpot info
/help Get help
To buy tickets, message me directly @LightningLottoBot! 🎟`,
privateChat: '❌ This command only works in groups. Use /menu to see available commands.',
adminOnly: '⚠️ Only group administrators can change these settings.',
settingsOverview: (settings: {
groupTitle: string;
enabled: boolean;
drawAnnouncements: boolean;
reminders: boolean;
ticketPurchaseAllowed: boolean;
}) =>
`⚙️ *Group Settings*
📍 *Group:* ${settings.groupTitle}
*Current Configuration:*
${settings.enabled ? '✅' : '❌'} Bot Enabled
${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements
${settings.reminders ? '✅' : '❌'} Draw Reminders
${settings.ticketPurchaseAllowed ? '✅' : '❌'} Ticket Purchases in Group
Tap a button below to toggle settings:`,
settingUpdated: (setting: string, enabled: boolean) =>
`✅ *${setting}* has been ${enabled ? 'enabled' : 'disabled'}.`,
botDisabled: 'The Lightning Jackpot bot is currently disabled for this group.',
purchasesDisabled: `🎟 Ticket purchases are disabled in this group for privacy.
Please message me directly to buy tickets: @LightningLottoBot`,
},
};
export default messages;
+145
View File
@@ -0,0 +1,145 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
import config from '../config';
import { logger, logApiCall } from './logger';
import {
ApiResponse,
JackpotNextResponse,
BuyTicketsResponse,
TicketStatusResponse,
} from '../types';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: config.api.baseUrl,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Response interceptor for logging
this.client.interceptors.response.use(
(response) => {
logApiCall(
response.config.url || '',
response.config.method?.toUpperCase() || 'GET',
response.status
);
return response;
},
(error: AxiosError) => {
logApiCall(
error.config?.url || '',
error.config?.method?.toUpperCase() || 'GET',
error.response?.status,
error.message
);
throw error;
}
);
}
/**
* Get next jackpot cycle information
*/
async getNextJackpot(): Promise<JackpotNextResponse | null> {
try {
const response = await this.client.get<ApiResponse<JackpotNextResponse>>(
'/jackpot/next'
);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 503) {
// No active lottery or cycle
return null;
}
}
logger.error('Failed to get next jackpot', { error });
throw error;
}
}
/**
* Buy lottery tickets
*/
async buyTickets(
tickets: number,
lightningAddress: string,
telegramUserId: number
): Promise<BuyTicketsResponse> {
try {
const response = await this.client.post<ApiResponse<BuyTicketsResponse>>(
'/jackpot/buy',
{
tickets,
lightning_address: lightningAddress,
buyer_name: `TG:${telegramUserId}`,
}
);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const errorData = error.response?.data as ApiResponse<unknown>;
if (errorData?.error) {
throw new Error(errorData.message || errorData.error);
}
}
logger.error('Failed to buy tickets', { error });
throw error;
}
}
/**
* Get ticket purchase status
*/
async getTicketStatus(purchaseId: string): Promise<TicketStatusResponse | null> {
try {
const response = await this.client.get<ApiResponse<TicketStatusResponse>>(
`/tickets/${purchaseId}`
);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
logger.error('Failed to get ticket status', { error, purchaseId });
throw error;
}
}
/**
* Get user's ticket purchases by lightning address
* Note: This queries tickets by lightning address pattern matching
*/
async getUserTickets(
telegramUserId: number,
limit: number = 10,
offset: number = 0
): Promise<TicketStatusResponse[]> {
// Since the backend doesn't have Telegram-specific endpoints,
// we'll need to track purchases locally in state
// This is a placeholder for future backend integration
return [];
}
/**
* Health check
*/
async healthCheck(): Promise<boolean> {
try {
await this.client.get('/jackpot/next');
return true;
} catch (error) {
return false;
}
}
}
export const apiClient = new ApiClient();
export default apiClient;
+224
View File
@@ -0,0 +1,224 @@
import Redis from 'ioredis';
import config from '../config';
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
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) : [];
}
/**
* 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;
}
}
/**
* 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
*/
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;
}
/**
* Remove group when bot is removed
*/
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 });
}
/**
* Update a specific setting
*/
async updateSetting(
groupId: number,
setting: keyof Pick<GroupSettings, 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed'>,
value: boolean
): Promise<GroupSettings | null> {
const settings = await this.getGroup(groupId);
if (!settings) return null;
settings[setting] = value;
await this.saveGroup(settings);
return settings;
}
/**
* Get all groups with a specific feature enabled
*/
async getGroupsWithFeature(
feature: 'enabled' | 'drawAnnouncements' | 'reminders'
): 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);
}
}
return groups;
}
/**
* Get all registered groups
*/
async getAllGroups(): 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) {
groups.push(settings);
}
}
return groups;
}
async close(): Promise<void> {
if (this.redis) {
await this.redis.quit();
}
}
}
export const groupStateManager = new GroupStateManager();
export default groupStateManager;
+82
View File
@@ -0,0 +1,82 @@
import winston from 'winston';
import config from '../config';
const { combine, timestamp, printf, colorize, errors } = winston.format;
const logFormat = printf(({ level, message, timestamp, stack, ...meta }) => {
let log = `${timestamp} [${level}]: ${message}`;
// Add metadata if present
const metaKeys = Object.keys(meta);
if (metaKeys.length > 0) {
// Filter out sensitive data
const safeMeta = { ...meta };
if (safeMeta.bolt11) {
const bolt11 = safeMeta.bolt11 as string;
safeMeta.bolt11 = `${bolt11.substring(0, 10)}...${bolt11.substring(bolt11.length - 10)}`;
}
if (safeMeta.lightningAddress && config.nodeEnv === 'production') {
safeMeta.lightningAddress = '[REDACTED]';
}
log += ` ${JSON.stringify(safeMeta)}`;
}
// Add stack trace for errors
if (stack) {
log += `\n${stack}`;
}
return log;
});
export const logger = winston.createLogger({
level: config.logging.level,
format: combine(
errors({ stack: true }),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),
transports: [
new winston.transports.Console({
format: combine(
colorize(),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),
}),
],
});
// Helper functions for common log scenarios
export const logUserAction = (
userId: number,
action: string,
details?: Record<string, any>
) => {
logger.info(action, { userId, ...details });
};
export const logApiCall = (
endpoint: string,
method: string,
statusCode?: number,
error?: string
) => {
if (error) {
logger.error(`API ${method} ${endpoint} failed`, { statusCode, error });
} else {
logger.debug(`API ${method} ${endpoint}`, { statusCode });
}
};
export const logPaymentEvent = (
userId: number,
purchaseId: string,
event: 'created' | 'confirmed' | 'expired' | 'polling',
details?: Record<string, any>
) => {
logger.info(`Payment ${event}`, { userId, purchaseId, ...details });
};
export default logger;
+27
View File
@@ -0,0 +1,27 @@
import QRCode from 'qrcode';
import { logger } from './logger';
/**
* Generate a QR code as a buffer from a Lightning invoice
*/
export async function generateQRCode(data: string): Promise<Buffer> {
try {
const buffer = await QRCode.toBuffer(data.toUpperCase(), {
errorCorrectionLevel: 'M',
type: 'png',
margin: 2,
width: 300,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
return buffer;
} catch (error) {
logger.error('Failed to generate QR code', { error });
throw error;
}
}
export default { generateQRCode };
+262
View File
@@ -0,0 +1,262 @@
import Redis from 'ioredis';
import config from '../config';
import { logger } from './logger';
import {
TelegramUser,
UserState,
AwaitingPaymentData,
} 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);
}
/**
* Get or create user
*/
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;
}
}
/**
* Create or update 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);
logger.debug('User saved', { telegramId: user.telegramId, state: user.state });
}
/**
* Create new user
*/
async createUser(
telegramId: number,
username?: string,
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;
}
/**
* Update user state
*/
async updateUserState(
telegramId: number,
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);
}
/**
* Update user's lightning address
*/
async updateLightningAddress(
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;
}
user.lightningAddress = lightningAddress;
user.state = 'idle';
user.stateData = undefined;
await this.saveUser(user);
}
/**
* Store a ticket purchase for a user
*/
async storePurchase(
telegramId: number,
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);
}
/**
* 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;
try {
return JSON.parse(data);
} catch (error) {
logger.error('Failed to parse purchase data', { purchaseId, error });
return null;
}
}
/**
* Get user's recent purchase IDs
*/
async getUserPurchaseIds(
telegramId: number,
limit: number = 10
): Promise<string[]> {
const key = `${USER_PURCHASES_PREFIX}${telegramId}`;
return await this.lrange(key, 0, limit - 1);
}
/**
* 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);
}
/**
* Shutdown
*/
async close(): Promise<void> {
if (this.redis) {
await this.redis.quit();
logger.info('Redis connection closed');
}
}
}
export const stateManager = new StateManager();
export default stateManager;
+25
View File
@@ -0,0 +1,25 @@
/**
* Group settings for lottery features
*/
export interface GroupSettings {
groupId: number;
groupTitle: string;
enabled: boolean;
drawAnnouncements: boolean;
reminders: boolean;
ticketPurchaseAllowed: boolean;
addedBy: number;
addedAt: Date;
updatedAt: Date;
}
/**
* Default group settings
*/
export const DEFAULT_GROUP_SETTINGS: Omit<GroupSettings, 'groupId' | 'groupTitle' | 'addedBy' | 'addedAt' | 'updatedAt'> = {
enabled: true,
drawAnnouncements: true,
reminders: true,
ticketPurchaseAllowed: false, // Disabled by default for privacy - users should buy in DM
};
+138
View File
@@ -0,0 +1,138 @@
// User state for conversation flow
export type UserState =
| 'idle'
| 'awaiting_lightning_address'
| 'awaiting_ticket_amount'
| 'awaiting_invoice_payment'
| 'updating_address';
// Telegram user data stored in state
export interface TelegramUser {
telegramId: number;
username?: string;
firstName?: string;
lastName?: string;
lightningAddress?: string;
state: UserState;
stateData?: Record<string, any>;
createdAt: Date;
updatedAt: Date;
}
// API Response Types
export interface ApiResponse<T> {
version: string;
data: T;
error?: string;
message?: string;
}
export interface JackpotNextResponse {
lottery: {
id: string;
name: string;
ticket_price_sats: number;
};
cycle: {
id: string;
cycle_type: string;
scheduled_at: string;
sales_open_at: string;
sales_close_at: string;
status: string;
pot_total_sats: number;
};
}
export interface BuyTicketsResponse {
ticket_purchase_id: string;
public_url: string;
invoice: {
payment_request: string;
amount_sats: number;
};
}
export interface TicketStatusResponse {
purchase: {
id: string;
lottery_id: string;
cycle_id: string;
lightning_address: string;
buyer_name: string;
number_of_tickets: number;
ticket_price_sats: number;
amount_sats: number;
invoice_status: 'pending' | 'paid' | 'expired' | 'cancelled';
ticket_issue_status: 'not_issued' | 'issued';
created_at: string;
};
tickets: Array<{
id: string;
serial_number: number;
is_winning_ticket: boolean;
}>;
cycle: {
id: string;
cycle_type: string;
scheduled_at: string;
status: string;
pot_total_sats: number;
pot_after_fee_sats: number | null;
winning_ticket_id: string | null;
};
result: {
has_drawn: boolean;
is_winner: boolean;
payout: {
status: string;
amount_sats: number;
} | null;
};
}
// Ticket purchase for user's ticket list
export interface UserTicketPurchase {
id: string;
cycle_id: string;
scheduled_at: string;
cycle_status: string;
number_of_tickets: number;
amount_sats: number;
invoice_status: string;
ticket_issue_status: string;
created_at: string;
}
// User win entry
export interface UserWin {
id: string;
cycle_id: string;
ticket_id: string;
amount_sats: number;
status: string;
scheduled_at: string;
created_at: string;
}
// State data for pending purchase
export interface PendingPurchaseData {
ticketCount: number;
cycleId: string;
scheduledAt: string;
ticketPrice: number;
totalAmount: number;
lotteryName: string;
}
// State data for awaiting payment
export interface AwaitingPaymentData extends PendingPurchaseData {
purchaseId: string;
paymentRequest: string;
publicUrl: string;
pollStartTime: number;
}
// Re-export group types
export * from './groups';
+71
View File
@@ -0,0 +1,71 @@
/**
* Format sats amount with thousand separators
*/
export function formatSats(sats: number): string {
return new Intl.NumberFormat('en-US').format(sats);
}
/**
* Format date for display
*/
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short',
});
}
/**
* Format relative time until draw
*/
export function formatTimeUntil(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const diff = d.getTime() - now.getTime();
if (diff < 0) {
return 'now';
}
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${days}d ${hours % 24}h`;
}
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
return `${minutes}m`;
}
/**
* Validate Lightning Address format
*/
export function isValidLightningAddress(address: string): boolean {
// Basic format: something@something.something
const regex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return regex.test(address);
}
/**
* Escape markdown special characters for Telegram MarkdownV2
*/
export function escapeMarkdown(text: string): string {
return text.replace(/[_*\[\]()~`>#+=|{}.!-]/g, '\\$&');
}
/**
* Truncate string with ellipsis
*/
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.substring(0, maxLength - 3) + '...';
}
+145
View File
@@ -0,0 +1,145 @@
import TelegramBot, {
InlineKeyboardMarkup,
ReplyKeyboardMarkup,
} from 'node-telegram-bot-api';
/**
* Main menu reply keyboard
*/
export function getMainMenuKeyboard(): ReplyKeyboardMarkup {
return {
keyboard: [
[{ text: '🎟 Buy Tickets' }, { text: '🧾 My Tickets' }],
[{ text: '🏆 My Wins' }, { text: '⚡ Lightning Address' }],
[{ text: '️ Help' }],
],
resize_keyboard: true,
one_time_keyboard: false,
};
}
/**
* Quick ticket amount selection
*/
export function getTicketAmountKeyboard(): InlineKeyboardMarkup {
return {
inline_keyboard: [
[
{ text: '1 ticket', callback_data: 'buy_1' },
{ text: '2 tickets', callback_data: 'buy_2' },
{ text: '5 tickets', callback_data: 'buy_5' },
{ text: '10 tickets', callback_data: 'buy_10' },
],
[{ text: '🔢 Custom Amount', callback_data: 'buy_custom' }],
[{ text: '❌ Cancel', callback_data: 'cancel' }],
],
};
}
/**
* Confirmation keyboard
*/
export function getConfirmationKeyboard(): InlineKeyboardMarkup {
return {
inline_keyboard: [
[
{ text: '✅ Confirm', callback_data: 'confirm_purchase' },
{ text: '❌ Cancel', callback_data: 'cancel' },
],
],
};
}
/**
* Check if URL is valid for Telegram inline buttons (must be HTTPS, not localhost)
*/
function isValidTelegramUrl(url: string): boolean {
try {
const parsed = new URL(url);
// Telegram requires HTTPS and doesn't accept localhost/127.0.0.1
return (
parsed.protocol === 'https:' &&
!parsed.hostname.includes('localhost') &&
!parsed.hostname.includes('127.0.0.1')
);
} catch {
return false;
}
}
/**
* View ticket status button
*/
export function getViewTicketKeyboard(
purchaseId: string,
publicUrl?: string
): InlineKeyboardMarkup {
const buttons: TelegramBot.InlineKeyboardButton[][] = [
[{ text: '🔄 Check Status', callback_data: `status_${purchaseId}` }],
];
// Only add URL button if it's a valid Telegram URL (HTTPS, not localhost)
if (publicUrl && isValidTelegramUrl(publicUrl)) {
buttons.push([{ text: '🌐 View on Web', url: publicUrl }]);
}
return {
inline_keyboard: buttons,
};
}
/**
* Ticket list navigation
*/
export function getTicketListKeyboard(
tickets: Array<{ id: string; label: string }>,
currentPage: number,
hasMore: boolean
): InlineKeyboardMarkup {
const buttons: TelegramBot.InlineKeyboardButton[][] = [];
// Add ticket buttons
for (const ticket of tickets) {
buttons.push([{
text: ticket.label,
callback_data: `view_ticket_${ticket.id}`,
}]);
}
// Add pagination
const navButtons: TelegramBot.InlineKeyboardButton[] = [];
if (currentPage > 0) {
navButtons.push({ text: '⬅️ Previous', callback_data: `tickets_page_${currentPage - 1}` });
}
if (hasMore) {
navButtons.push({ text: '➡️ Next', callback_data: `tickets_page_${currentPage + 1}` });
}
if (navButtons.length > 0) {
buttons.push(navButtons);
}
return { inline_keyboard: buttons };
}
/**
* Back to menu button
*/
export function getBackToMenuKeyboard(): InlineKeyboardMarkup {
return {
inline_keyboard: [
[{ text: '🏠 Back to Menu', callback_data: 'menu' }],
],
};
}
/**
* Cancel operation button
*/
export function getCancelKeyboard(): InlineKeyboardMarkup {
return {
inline_keyboard: [
[{ text: '❌ Cancel', callback_data: 'cancel' }],
],
};
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}