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:
28
telegram_bot/.gitignore
vendored
Normal file
28
telegram_bot/.gitignore
vendored
Normal 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
telegram_bot/Dockerfile
Normal file
44
telegram_bot/Dockerfile
Normal 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
telegram_bot/README.md
Normal file
146
telegram_bot/README.md
Normal 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
telegram_bot/env.example
Normal file
22
telegram_bot/env.example
Normal 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
telegram_bot/package-lock.json
generated
Normal file
3753
telegram_bot/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
telegram_bot/package.json
Normal file
37
telegram_bot/package.json
Normal 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
telegram_bot/src/config/index.ts
Normal file
50
telegram_bot/src/config/index.ts
Normal 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
telegram_bot/src/handlers/address.ts
Normal file
106
telegram_bot/src/handlers/address.ts
Normal 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
telegram_bot/src/handlers/buy.ts
Normal file
426
telegram_bot/src/handlers/buy.ts
Normal 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
telegram_bot/src/handlers/groups.ts
Normal file
341
telegram_bot/src/handlers/groups.ts
Normal 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
telegram_bot/src/handlers/help.ts
Normal file
26
telegram_bot/src/handlers/help.ts
Normal 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
telegram_bot/src/handlers/index.ts
Normal file
33
telegram_bot/src/handlers/index.ts
Normal 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
telegram_bot/src/handlers/menu.ts
Normal file
92
telegram_bot/src/handlers/menu.ts
Normal 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
telegram_bot/src/handlers/start.ts
Normal file
68
telegram_bot/src/handlers/start.ts
Normal 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
telegram_bot/src/handlers/tickets.ts
Normal file
259
telegram_bot/src/handlers/tickets.ts
Normal 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
telegram_bot/src/handlers/wins.ts
Normal file
114
telegram_bot/src/handlers/wins.ts
Normal 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
telegram_bot/src/index.ts
Normal file
448
telegram_bot/src/index.ts
Normal 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
telegram_bot/src/messages/index.ts
Normal file
444
telegram_bot/src/messages/index.ts
Normal 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
telegram_bot/src/services/api.ts
Normal file
145
telegram_bot/src/services/api.ts
Normal 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
telegram_bot/src/services/groupState.ts
Normal file
224
telegram_bot/src/services/groupState.ts
Normal 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
telegram_bot/src/services/logger.ts
Normal file
82
telegram_bot/src/services/logger.ts
Normal 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
telegram_bot/src/services/qr.ts
Normal file
27
telegram_bot/src/services/qr.ts
Normal 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
telegram_bot/src/services/state.ts
Normal file
262
telegram_bot/src/services/state.ts
Normal 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
telegram_bot/src/types/groups.ts
Normal file
25
telegram_bot/src/types/groups.ts
Normal 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
telegram_bot/src/types/index.ts
Normal file
138
telegram_bot/src/types/index.ts
Normal 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
telegram_bot/src/utils/format.ts
Normal file
71
telegram_bot/src/utils/format.ts
Normal 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
telegram_bot/src/utils/keyboards.ts
Normal file
145
telegram_bot/src/utils/keyboards.ts
Normal 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
telegram_bot/tsconfig.json
Normal file
21
telegram_bot/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user