feat: Add Telegram bot with group support

- Full Telegram bot implementation for Lightning Jackpot
- Commands: /start, /buy, /tickets, /wins, /address, /jackpot, /help
- Lightning invoice generation with QR codes
- Payment polling and confirmation notifications
- User state management (Redis/in-memory fallback)
- Group support with admin settings panel
- Configurable draw announcements and reminders
- Centralized messages for easy i18n
- Docker configuration included
This commit is contained in:
Michilis
2025-11-27 23:10:25 +00:00
parent d3bf8080b6
commit f743a6749c
29 changed files with 7616 additions and 1 deletions

View File

@@ -0,0 +1,145 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
import config from '../config';
import { logger, logApiCall } from './logger';
import {
ApiResponse,
JackpotNextResponse,
BuyTicketsResponse,
TicketStatusResponse,
} from '../types';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: config.api.baseUrl,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Response interceptor for logging
this.client.interceptors.response.use(
(response) => {
logApiCall(
response.config.url || '',
response.config.method?.toUpperCase() || 'GET',
response.status
);
return response;
},
(error: AxiosError) => {
logApiCall(
error.config?.url || '',
error.config?.method?.toUpperCase() || 'GET',
error.response?.status,
error.message
);
throw error;
}
);
}
/**
* Get next jackpot cycle information
*/
async getNextJackpot(): Promise<JackpotNextResponse | null> {
try {
const response = await this.client.get<ApiResponse<JackpotNextResponse>>(
'/jackpot/next'
);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 503) {
// No active lottery or cycle
return null;
}
}
logger.error('Failed to get next jackpot', { error });
throw error;
}
}
/**
* Buy lottery tickets
*/
async buyTickets(
tickets: number,
lightningAddress: string,
telegramUserId: number
): Promise<BuyTicketsResponse> {
try {
const response = await this.client.post<ApiResponse<BuyTicketsResponse>>(
'/jackpot/buy',
{
tickets,
lightning_address: lightningAddress,
buyer_name: `TG:${telegramUserId}`,
}
);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const errorData = error.response?.data as ApiResponse<unknown>;
if (errorData?.error) {
throw new Error(errorData.message || errorData.error);
}
}
logger.error('Failed to buy tickets', { error });
throw error;
}
}
/**
* Get ticket purchase status
*/
async getTicketStatus(purchaseId: string): Promise<TicketStatusResponse | null> {
try {
const response = await this.client.get<ApiResponse<TicketStatusResponse>>(
`/tickets/${purchaseId}`
);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
logger.error('Failed to get ticket status', { error, purchaseId });
throw error;
}
}
/**
* Get user's ticket purchases by lightning address
* Note: This queries tickets by lightning address pattern matching
*/
async getUserTickets(
telegramUserId: number,
limit: number = 10,
offset: number = 0
): Promise<TicketStatusResponse[]> {
// Since the backend doesn't have Telegram-specific endpoints,
// we'll need to track purchases locally in state
// This is a placeholder for future backend integration
return [];
}
/**
* Health check
*/
async healthCheck(): Promise<boolean> {
try {
await this.client.get('/jackpot/next');
return true;
} catch (error) {
return false;
}
}
}
export const apiClient = new ApiClient();
export default apiClient;

View File

@@ -0,0 +1,224 @@
import Redis from 'ioredis';
import config from '../config';
import { logger } from './logger';
import { GroupSettings, DEFAULT_GROUP_SETTINGS } from '../types/groups';
const GROUP_PREFIX = 'tg_group:';
const GROUPS_LIST_KEY = 'tg_groups_list';
const STATE_TTL = 60 * 60 * 24 * 365; // 1 year
class GroupStateManager {
private redis: Redis | null = null;
private memoryStore: Map<string, string> = new Map();
private useRedis: boolean = false;
async init(redisUrl: string | null): Promise<void> {
if (redisUrl) {
try {
this.redis = new Redis(redisUrl);
await this.redis.ping();
this.useRedis = true;
logger.info('Group state manager initialized with Redis');
} catch (error) {
logger.warn('Failed to connect to Redis for groups, using in-memory store');
this.redis = null;
this.useRedis = false;
}
}
}
private async get(key: string): Promise<string | null> {
if (this.useRedis && this.redis) {
return await this.redis.get(key);
}
return this.memoryStore.get(key) || null;
}
private async set(key: string, value: string, ttl?: number): Promise<void> {
if (this.useRedis && this.redis) {
if (ttl) {
await this.redis.setex(key, ttl, value);
} else {
await this.redis.set(key, value);
}
} else {
this.memoryStore.set(key, value);
}
}
private async del(key: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.del(key);
} else {
this.memoryStore.delete(key);
}
}
private async sadd(key: string, value: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.sadd(key, value);
} else {
const existing = this.memoryStore.get(key);
const set = existing ? new Set(JSON.parse(existing)) : new Set();
set.add(value);
this.memoryStore.set(key, JSON.stringify([...set]));
}
}
private async srem(key: string, value: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.srem(key, value);
} else {
const existing = this.memoryStore.get(key);
if (existing) {
const set = new Set(JSON.parse(existing));
set.delete(value);
this.memoryStore.set(key, JSON.stringify([...set]));
}
}
}
private async smembers(key: string): Promise<string[]> {
if (this.useRedis && this.redis) {
return await this.redis.smembers(key);
}
const existing = this.memoryStore.get(key);
return existing ? JSON.parse(existing) : [];
}
/**
* Get group settings
*/
async getGroup(groupId: number): Promise<GroupSettings | null> {
const key = `${GROUP_PREFIX}${groupId}`;
const data = await this.get(key);
if (!data) return null;
try {
const settings = JSON.parse(data);
return {
...settings,
addedAt: new Date(settings.addedAt),
updatedAt: new Date(settings.updatedAt),
};
} catch (error) {
logger.error('Failed to parse group settings', { groupId, error });
return null;
}
}
/**
* Create or update group settings
*/
async saveGroup(settings: GroupSettings): Promise<void> {
const key = `${GROUP_PREFIX}${settings.groupId}`;
settings.updatedAt = new Date();
await this.set(key, JSON.stringify(settings), STATE_TTL);
await this.sadd(GROUPS_LIST_KEY, settings.groupId.toString());
logger.debug('Group settings saved', { groupId: settings.groupId });
}
/**
* Register a new group when bot is added
*/
async registerGroup(
groupId: number,
groupTitle: string,
addedBy: number
): Promise<GroupSettings> {
const existing = await this.getGroup(groupId);
if (existing) {
// Update title if changed
existing.groupTitle = groupTitle;
existing.updatedAt = new Date();
await this.saveGroup(existing);
return existing;
}
const settings: GroupSettings = {
groupId,
groupTitle,
...DEFAULT_GROUP_SETTINGS,
addedBy,
addedAt: new Date(),
updatedAt: new Date(),
};
await this.saveGroup(settings);
logger.info('New group registered', { groupId, groupTitle, addedBy });
return settings;
}
/**
* Remove group when bot is removed
*/
async removeGroup(groupId: number): Promise<void> {
const key = `${GROUP_PREFIX}${groupId}`;
await this.del(key);
await this.srem(GROUPS_LIST_KEY, groupId.toString());
logger.info('Group removed', { groupId });
}
/**
* Update a specific setting
*/
async updateSetting(
groupId: number,
setting: keyof Pick<GroupSettings, 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed'>,
value: boolean
): Promise<GroupSettings | null> {
const settings = await this.getGroup(groupId);
if (!settings) return null;
settings[setting] = value;
await this.saveGroup(settings);
return settings;
}
/**
* Get all groups with a specific feature enabled
*/
async getGroupsWithFeature(
feature: 'enabled' | 'drawAnnouncements' | 'reminders'
): Promise<GroupSettings[]> {
const groupIds = await this.smembers(GROUPS_LIST_KEY);
const groups: GroupSettings[] = [];
for (const id of groupIds) {
const settings = await this.getGroup(parseInt(id, 10));
if (settings && settings.enabled && settings[feature]) {
groups.push(settings);
}
}
return groups;
}
/**
* Get all registered groups
*/
async getAllGroups(): Promise<GroupSettings[]> {
const groupIds = await this.smembers(GROUPS_LIST_KEY);
const groups: GroupSettings[] = [];
for (const id of groupIds) {
const settings = await this.getGroup(parseInt(id, 10));
if (settings) {
groups.push(settings);
}
}
return groups;
}
async close(): Promise<void> {
if (this.redis) {
await this.redis.quit();
}
}
}
export const groupStateManager = new GroupStateManager();
export default groupStateManager;

View File

@@ -0,0 +1,82 @@
import winston from 'winston';
import config from '../config';
const { combine, timestamp, printf, colorize, errors } = winston.format;
const logFormat = printf(({ level, message, timestamp, stack, ...meta }) => {
let log = `${timestamp} [${level}]: ${message}`;
// Add metadata if present
const metaKeys = Object.keys(meta);
if (metaKeys.length > 0) {
// Filter out sensitive data
const safeMeta = { ...meta };
if (safeMeta.bolt11) {
const bolt11 = safeMeta.bolt11 as string;
safeMeta.bolt11 = `${bolt11.substring(0, 10)}...${bolt11.substring(bolt11.length - 10)}`;
}
if (safeMeta.lightningAddress && config.nodeEnv === 'production') {
safeMeta.lightningAddress = '[REDACTED]';
}
log += ` ${JSON.stringify(safeMeta)}`;
}
// Add stack trace for errors
if (stack) {
log += `\n${stack}`;
}
return log;
});
export const logger = winston.createLogger({
level: config.logging.level,
format: combine(
errors({ stack: true }),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),
transports: [
new winston.transports.Console({
format: combine(
colorize(),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),
}),
],
});
// Helper functions for common log scenarios
export const logUserAction = (
userId: number,
action: string,
details?: Record<string, any>
) => {
logger.info(action, { userId, ...details });
};
export const logApiCall = (
endpoint: string,
method: string,
statusCode?: number,
error?: string
) => {
if (error) {
logger.error(`API ${method} ${endpoint} failed`, { statusCode, error });
} else {
logger.debug(`API ${method} ${endpoint}`, { statusCode });
}
};
export const logPaymentEvent = (
userId: number,
purchaseId: string,
event: 'created' | 'confirmed' | 'expired' | 'polling',
details?: Record<string, any>
) => {
logger.info(`Payment ${event}`, { userId, purchaseId, ...details });
};
export default logger;

View File

@@ -0,0 +1,27 @@
import QRCode from 'qrcode';
import { logger } from './logger';
/**
* Generate a QR code as a buffer from a Lightning invoice
*/
export async function generateQRCode(data: string): Promise<Buffer> {
try {
const buffer = await QRCode.toBuffer(data.toUpperCase(), {
errorCorrectionLevel: 'M',
type: 'png',
margin: 2,
width: 300,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
return buffer;
} catch (error) {
logger.error('Failed to generate QR code', { error });
throw error;
}
}
export default { generateQRCode };

View File

@@ -0,0 +1,262 @@
import Redis from 'ioredis';
import config from '../config';
import { logger } from './logger';
import {
TelegramUser,
UserState,
AwaitingPaymentData,
} from '../types';
const STATE_PREFIX = 'tg_user:';
const PURCHASE_PREFIX = 'tg_purchase:';
const USER_PURCHASES_PREFIX = 'tg_user_purchases:';
const STATE_TTL = 60 * 60 * 24 * 30; // 30 days
class StateManager {
private redis: Redis | null = null;
private memoryStore: Map<string, string> = new Map();
private useRedis: boolean = false;
async init(): Promise<void> {
if (config.redis.url) {
try {
this.redis = new Redis(config.redis.url);
this.redis.on('error', (error) => {
logger.error('Redis connection error', { error: error.message });
});
this.redis.on('connect', () => {
logger.info('Connected to Redis');
});
// Test connection
await this.redis.ping();
this.useRedis = true;
logger.info('State manager initialized with Redis');
} catch (error) {
logger.warn('Failed to connect to Redis, falling back to in-memory store', {
error: (error as Error).message,
});
this.redis = null;
this.useRedis = false;
}
} else {
logger.info('State manager initialized with in-memory store');
this.useRedis = false;
}
}
private async get(key: string): Promise<string | null> {
if (this.useRedis && this.redis) {
return await this.redis.get(key);
}
return this.memoryStore.get(key) || null;
}
private async set(key: string, value: string, ttl?: number): Promise<void> {
if (this.useRedis && this.redis) {
if (ttl) {
await this.redis.setex(key, ttl, value);
} else {
await this.redis.set(key, value);
}
} else {
this.memoryStore.set(key, value);
}
}
private async del(key: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.del(key);
} else {
this.memoryStore.delete(key);
}
}
private async lpush(key: string, value: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.lpush(key, value);
await this.redis.ltrim(key, 0, 99); // Keep last 100 purchases
} else {
const existing = this.memoryStore.get(key);
const list = existing ? JSON.parse(existing) : [];
list.unshift(value);
if (list.length > 100) list.pop();
this.memoryStore.set(key, JSON.stringify(list));
}
}
private async lrange(key: string, start: number, stop: number): Promise<string[]> {
if (this.useRedis && this.redis) {
return await this.redis.lrange(key, start, stop);
}
const existing = this.memoryStore.get(key);
if (!existing) return [];
const list = JSON.parse(existing);
return list.slice(start, stop + 1);
}
/**
* Get or create user
*/
async getUser(telegramId: number): Promise<TelegramUser | null> {
const key = `${STATE_PREFIX}${telegramId}`;
const data = await this.get(key);
if (!data) return null;
try {
const user = JSON.parse(data);
return {
...user,
createdAt: new Date(user.createdAt),
updatedAt: new Date(user.updatedAt),
};
} catch (error) {
logger.error('Failed to parse user data', { telegramId, error });
return null;
}
}
/**
* Create or update user
*/
async saveUser(user: TelegramUser): Promise<void> {
const key = `${STATE_PREFIX}${user.telegramId}`;
user.updatedAt = new Date();
await this.set(key, JSON.stringify(user), STATE_TTL);
logger.debug('User saved', { telegramId: user.telegramId, state: user.state });
}
/**
* Create new user
*/
async createUser(
telegramId: number,
username?: string,
firstName?: string,
lastName?: string
): Promise<TelegramUser> {
const user: TelegramUser = {
telegramId,
username,
firstName,
lastName,
state: 'awaiting_lightning_address',
createdAt: new Date(),
updatedAt: new Date(),
};
await this.saveUser(user);
logger.info('New user created', { telegramId, username });
return user;
}
/**
* Update user state
*/
async updateUserState(
telegramId: number,
state: UserState,
stateData?: Record<string, any>
): Promise<void> {
const user = await this.getUser(telegramId);
if (!user) {
logger.warn('Attempted to update state for non-existent user', { telegramId });
return;
}
user.state = state;
user.stateData = stateData;
await this.saveUser(user);
}
/**
* Update user's lightning address
*/
async updateLightningAddress(
telegramId: number,
lightningAddress: string
): Promise<void> {
const user = await this.getUser(telegramId);
if (!user) {
logger.warn('Attempted to update address for non-existent user', { telegramId });
return;
}
user.lightningAddress = lightningAddress;
user.state = 'idle';
user.stateData = undefined;
await this.saveUser(user);
}
/**
* Store a ticket purchase for a user
*/
async storePurchase(
telegramId: number,
purchaseId: string,
data: AwaitingPaymentData
): Promise<void> {
// Store purchase data
const purchaseKey = `${PURCHASE_PREFIX}${purchaseId}`;
await this.set(purchaseKey, JSON.stringify({
telegramId,
...data,
createdAt: new Date().toISOString(),
}), STATE_TTL);
// Add to user's purchase list
const userPurchasesKey = `${USER_PURCHASES_PREFIX}${telegramId}`;
await this.lpush(userPurchasesKey, purchaseId);
}
/**
* Get purchase data
*/
async getPurchase(purchaseId: string): Promise<(AwaitingPaymentData & { telegramId: number }) | null> {
const key = `${PURCHASE_PREFIX}${purchaseId}`;
const data = await this.get(key);
if (!data) return null;
try {
return JSON.parse(data);
} catch (error) {
logger.error('Failed to parse purchase data', { purchaseId, error });
return null;
}
}
/**
* Get user's recent purchase IDs
*/
async getUserPurchaseIds(
telegramId: number,
limit: number = 10
): Promise<string[]> {
const key = `${USER_PURCHASES_PREFIX}${telegramId}`;
return await this.lrange(key, 0, limit - 1);
}
/**
* Clear user state data (keeping lightning address)
*/
async clearUserStateData(telegramId: number): Promise<void> {
const user = await this.getUser(telegramId);
if (!user) return;
user.state = 'idle';
user.stateData = undefined;
await this.saveUser(user);
}
/**
* Shutdown
*/
async close(): Promise<void> {
if (this.redis) {
await this.redis.quit();
logger.info('Redis connection closed');
}
}
}
export const stateManager = new StateManager();
export default stateManager;