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:
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;
|
||||
|
||||
Reference in New Issue
Block a user