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,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;

View File

@@ -0,0 +1,106 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { logger, logUserAction } from '../services/logger';
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards';
import { isValidLightningAddress } from '../utils/format';
import { messages } from '../messages';
/**
* Handle /address command or "Lightning Address" button
*/
export async function handleAddressCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
return;
}
logUserAction(userId, 'Requested address update');
try {
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst);
return;
}
const message = user.lightningAddress
? messages.address.currentAddress(user.lightningAddress)
: messages.address.noAddressSet;
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
});
await stateManager.updateUserState(userId, 'updating_address');
} catch (error) {
logger.error('Error in handleAddressCommand', { error, userId });
await bot.sendMessage(chatId, messages.errors.generic);
}
}
/**
* Handle incoming Lightning Address from user
*/
export async function handleLightningAddressInput(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<boolean> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
const text = msg.text?.trim();
if (!userId || !text) return false;
try {
const user = await stateManager.getUser(userId);
if (!user) return false;
// Check if user is in a state expecting lightning address
if (user.state !== 'awaiting_lightning_address' && user.state !== 'updating_address') {
return false;
}
// Validate lightning address format
if (!isValidLightningAddress(text)) {
await bot.sendMessage(chatId, messages.address.invalidFormat, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
});
return true;
}
// Save the lightning address
await stateManager.updateLightningAddress(userId, text);
logUserAction(userId, 'Lightning address updated');
const responseMessage = user.state === 'awaiting_lightning_address'
? messages.address.firstTimeSuccess(text)
: messages.address.updateSuccess(text);
await bot.sendMessage(chatId, responseMessage, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
return true;
} catch (error) {
logger.error('Error in handleLightningAddressInput', { error, userId });
await bot.sendMessage(chatId, messages.address.saveFailed);
return true;
}
}
export default {
handleAddressCommand,
handleLightningAddressInput,
};

View File

@@ -0,0 +1,426 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { apiClient } from '../services/api';
import { generateQRCode } from '../services/qr';
import { logger, logUserAction, logPaymentEvent } from '../services/logger';
import config from '../config';
import {
getTicketAmountKeyboard,
getConfirmationKeyboard,
getViewTicketKeyboard,
getMainMenuKeyboard,
getCancelKeyboard,
} from '../utils/keyboards';
import { formatSats, formatDate, formatTimeUntil } from '../utils/format';
import { PendingPurchaseData, AwaitingPaymentData } from '../types';
import { messages } from '../messages';
/**
* Handle /buy command or "Buy Tickets" button
*/
export async function handleBuyCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
return;
}
logUserAction(userId, 'Initiated ticket purchase');
try {
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst);
return;
}
// Check if lightning address is set
if (!user.lightningAddress) {
await bot.sendMessage(chatId, messages.address.needAddressFirst, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
});
await stateManager.updateUserState(userId, 'awaiting_lightning_address');
return;
}
// Get next jackpot info
const jackpot = await apiClient.getNextJackpot();
if (!jackpot) {
await bot.sendMessage(chatId, messages.buy.noActiveJackpot, { parse_mode: 'Markdown' });
return;
}
// Show jackpot info and ticket selection
const drawTime = new Date(jackpot.cycle.scheduled_at);
const message = messages.buy.jackpotInfo(
formatSats(jackpot.cycle.pot_total_sats),
formatSats(jackpot.lottery.ticket_price_sats),
formatDate(drawTime),
formatTimeUntil(drawTime)
);
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: getTicketAmountKeyboard(),
});
// Store jackpot info in state for later use
await stateManager.updateUserState(userId, 'idle', {
cycleId: jackpot.cycle.id,
scheduledAt: jackpot.cycle.scheduled_at,
ticketPrice: jackpot.lottery.ticket_price_sats,
lotteryName: jackpot.lottery.name,
});
} catch (error) {
logger.error('Error in handleBuyCommand', { error, userId });
await bot.sendMessage(chatId, messages.errors.systemUnavailable);
}
}
/**
* Handle ticket amount selection
*/
export async function handleTicketAmountSelection(
bot: TelegramBot,
query: TelegramBot.CallbackQuery,
amount: number | 'custom'
): Promise<void> {
const chatId = query.message?.chat.id;
const userId = query.from.id;
const messageId = query.message?.message_id;
if (!chatId || !messageId) return;
await bot.answerCallbackQuery(query.id);
try {
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst);
return;
}
if (amount === 'custom') {
// Ask for custom amount
await bot.editMessageText(
messages.buy.customAmountPrompt(config.bot.maxTicketsPerPurchase),
{
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
}
);
// Get fresh jackpot info
const jackpot = await apiClient.getNextJackpot();
if (!jackpot) {
await bot.sendMessage(chatId, messages.buy.jackpotUnavailable);
return;
}
await stateManager.updateUserState(userId, 'awaiting_ticket_amount', {
cycleId: jackpot.cycle.id,
scheduledAt: jackpot.cycle.scheduled_at,
ticketPrice: jackpot.lottery.ticket_price_sats,
lotteryName: jackpot.lottery.name,
});
return;
}
// Process selected amount
await processTicketSelection(bot, chatId, messageId, userId, amount);
} catch (error) {
logger.error('Error in handleTicketAmountSelection', { error, userId });
await bot.sendMessage(chatId, messages.errors.generic);
}
}
/**
* Handle custom ticket amount input
*/
export async function handleCustomTicketAmount(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<boolean> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
const text = msg.text?.trim();
if (!userId || !text) return false;
try {
const user = await stateManager.getUser(userId);
if (!user || user.state !== 'awaiting_ticket_amount') {
return false;
}
const amount = parseInt(text, 10);
if (isNaN(amount) || amount < 1) {
await bot.sendMessage(
chatId,
messages.buy.invalidNumber(config.bot.maxTicketsPerPurchase),
{ reply_markup: getCancelKeyboard() }
);
return true;
}
if (amount > config.bot.maxTicketsPerPurchase) {
await bot.sendMessage(
chatId,
messages.buy.tooManyTickets(config.bot.maxTicketsPerPurchase),
{ reply_markup: getCancelKeyboard() }
);
return true;
}
await processTicketSelection(bot, chatId, undefined, userId, amount);
return true;
} catch (error) {
logger.error('Error in handleCustomTicketAmount', { error, userId });
await bot.sendMessage(chatId, messages.errors.generic);
return true;
}
}
/**
* Process ticket selection and show confirmation
*/
async function processTicketSelection(
bot: TelegramBot,
chatId: number,
messageId: number | undefined,
userId: number,
amount: number
): Promise<void> {
// Get fresh jackpot info
const jackpot = await apiClient.getNextJackpot();
if (!jackpot) {
await bot.sendMessage(chatId, messages.buy.jackpotUnavailable);
return;
}
const totalAmount = amount * jackpot.lottery.ticket_price_sats;
const confirmMessage = messages.buy.confirmPurchase(
amount,
formatSats(jackpot.lottery.ticket_price_sats),
formatSats(totalAmount),
formatDate(jackpot.cycle.scheduled_at)
);
const stateData: PendingPurchaseData = {
ticketCount: amount,
cycleId: jackpot.cycle.id,
scheduledAt: jackpot.cycle.scheduled_at,
ticketPrice: jackpot.lottery.ticket_price_sats,
totalAmount,
lotteryName: jackpot.lottery.name,
};
await stateManager.updateUserState(userId, 'idle', stateData);
if (messageId) {
await bot.editMessageText(confirmMessage, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: getConfirmationKeyboard(),
});
} else {
await bot.sendMessage(chatId, confirmMessage, {
parse_mode: 'Markdown',
reply_markup: getConfirmationKeyboard(),
});
}
}
/**
* Handle purchase confirmation
*/
export async function handlePurchaseConfirmation(
bot: TelegramBot,
query: TelegramBot.CallbackQuery
): Promise<void> {
const chatId = query.message?.chat.id;
const userId = query.from.id;
const messageId = query.message?.message_id;
if (!chatId || !messageId) return;
await bot.answerCallbackQuery(query.id, { text: messages.buy.creatingInvoice });
try {
const user = await stateManager.getUser(userId);
if (!user || !user.lightningAddress) {
await bot.sendMessage(chatId, messages.errors.setAddressFirst);
return;
}
const pendingData = user.stateData as PendingPurchaseData | undefined;
if (!pendingData?.ticketCount) {
await bot.sendMessage(chatId, messages.errors.noPendingPurchase);
return;
}
logUserAction(userId, 'Confirmed purchase', { tickets: pendingData.ticketCount });
// Create invoice
const purchaseResult = await apiClient.buyTickets(
pendingData.ticketCount,
user.lightningAddress,
userId
);
logPaymentEvent(userId, purchaseResult.ticket_purchase_id, 'created', {
tickets: pendingData.ticketCount,
amount: pendingData.totalAmount,
});
// Generate QR code
const qrBuffer = await generateQRCode(purchaseResult.invoice.payment_request);
// Update message to show invoice
await bot.editMessageText(messages.buy.invoiceCreated, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
});
// Send QR code
await bot.sendPhoto(chatId, qrBuffer, {
caption: messages.buy.invoiceCaption(
pendingData.ticketCount,
formatSats(pendingData.totalAmount),
purchaseResult.invoice.payment_request,
config.bot.invoiceExpiryMinutes
),
parse_mode: 'Markdown',
reply_markup: getViewTicketKeyboard(
purchaseResult.ticket_purchase_id,
purchaseResult.public_url
),
});
// Store purchase and start polling
const paymentData: AwaitingPaymentData = {
...pendingData,
purchaseId: purchaseResult.ticket_purchase_id,
paymentRequest: purchaseResult.invoice.payment_request,
publicUrl: purchaseResult.public_url,
pollStartTime: Date.now(),
};
await stateManager.storePurchase(userId, purchaseResult.ticket_purchase_id, paymentData);
await stateManager.updateUserState(userId, 'awaiting_invoice_payment', paymentData);
// Start payment polling
pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id);
} catch (error) {
logger.error('Error in handlePurchaseConfirmation', { error, userId });
await bot.sendMessage(chatId, messages.errors.invoiceCreationFailed, {
reply_markup: getMainMenuKeyboard(),
});
await stateManager.clearUserStateData(userId);
}
}
/**
* Poll payment status
*/
async function pollPaymentStatus(
bot: TelegramBot,
chatId: number,
userId: number,
purchaseId: string
): Promise<void> {
const pollInterval = config.bot.paymentPollIntervalMs;
const timeout = config.bot.paymentPollTimeoutMs;
const startTime = Date.now();
logPaymentEvent(userId, purchaseId, 'polling');
const checkPayment = async (): Promise<void> => {
try {
// Check if we've timed out
if (Date.now() - startTime > timeout) {
logPaymentEvent(userId, purchaseId, 'expired');
await bot.sendMessage(chatId, messages.buy.invoiceExpired, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
await stateManager.clearUserStateData(userId);
return;
}
const status = await apiClient.getTicketStatus(purchaseId);
if (!status) {
// Purchase not found, stop polling
logger.warn('Purchase not found during polling', { purchaseId });
return;
}
if (status.purchase.invoice_status === 'paid') {
logPaymentEvent(userId, purchaseId, 'confirmed', {
tickets: status.tickets.length,
});
// Payment received!
const ticketNumbers = status.tickets
.map((t) => `#${t.serial_number.toString().padStart(4, '0')}`)
.join('\n');
await bot.sendMessage(
chatId,
messages.buy.paymentReceived(ticketNumbers, formatDate(status.cycle.scheduled_at)),
{
parse_mode: 'Markdown',
reply_markup: getViewTicketKeyboard(
purchaseId,
config.frontend.baseUrl + '/tickets/' + purchaseId
),
}
);
await stateManager.clearUserStateData(userId);
return;
}
if (status.purchase.invoice_status === 'expired') {
logPaymentEvent(userId, purchaseId, 'expired');
await bot.sendMessage(chatId, messages.buy.invoiceExpiredShort, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
await stateManager.clearUserStateData(userId);
return;
}
// Still pending, continue polling
setTimeout(checkPayment, pollInterval);
} catch (error) {
logger.error('Error polling payment status', { error, purchaseId });
// Continue polling despite errors
setTimeout(checkPayment, pollInterval);
}
};
// Start polling after initial delay
setTimeout(checkPayment, pollInterval);
}
export default {
handleBuyCommand,
handleTicketAmountSelection,
handleCustomTicketAmount,
handlePurchaseConfirmation,
};

View File

@@ -0,0 +1,341 @@
import TelegramBot from 'node-telegram-bot-api';
import { groupStateManager } from '../services/groupState';
import { logger, logUserAction } from '../services/logger';
import { messages } from '../messages';
/**
* Check if a user is an admin in a group
*/
async function isGroupAdmin(
bot: TelegramBot,
chatId: number,
userId: number
): Promise<boolean> {
try {
const member = await bot.getChatMember(chatId, userId);
return ['creator', 'administrator'].includes(member.status);
} catch (error) {
logger.error('Failed to check admin status', { error, chatId, userId });
return false;
}
}
/**
* Handle bot being added to a group
*/
export async function handleBotAddedToGroup(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const chatTitle = msg.chat.title || 'Unknown Group';
const addedBy = msg.from?.id || 0;
logger.info('Bot added to group', { chatId, chatTitle, addedBy });
try {
const settings = await groupStateManager.registerGroup(chatId, chatTitle, addedBy);
await bot.sendMessage(chatId, messages.groups.welcome(chatTitle), {
parse_mode: 'Markdown',
});
} catch (error) {
logger.error('Failed to register group', { error, chatId });
}
}
/**
* Handle bot being removed from a group
*/
export async function handleBotRemovedFromGroup(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
logger.info('Bot removed from group', { chatId });
try {
await groupStateManager.removeGroup(chatId);
} catch (error) {
logger.error('Failed to remove group', { error, chatId });
}
}
/**
* Handle /settings command (group admin only)
*/
export async function handleGroupSettings(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
// Only works in groups
if (msg.chat.type === 'private') {
await bot.sendMessage(chatId, messages.groups.privateChat);
return;
}
if (!userId) return;
// Check if user is admin
const isAdmin = await isGroupAdmin(bot, chatId, userId);
if (!isAdmin) {
await bot.sendMessage(chatId, messages.groups.adminOnly);
return;
}
logUserAction(userId, 'Viewed group settings', { groupId: chatId });
try {
const settings = await groupStateManager.getGroup(chatId);
if (!settings) {
// Register group if not found
await groupStateManager.registerGroup(
chatId,
msg.chat.title || 'Group',
userId
);
}
const currentSettings = settings || await groupStateManager.getGroup(chatId);
if (!currentSettings) {
await bot.sendMessage(chatId, messages.errors.generic);
return;
}
await bot.sendMessage(
chatId,
messages.groups.settingsOverview(currentSettings),
{
parse_mode: 'Markdown',
reply_markup: getGroupSettingsKeyboard(currentSettings),
}
);
} catch (error) {
logger.error('Error in handleGroupSettings', { error, chatId });
await bot.sendMessage(chatId, messages.errors.generic);
}
}
/**
* Handle group settings toggle callback
*/
export async function handleGroupSettingsCallback(
bot: TelegramBot,
query: TelegramBot.CallbackQuery,
action: string
): Promise<void> {
const chatId = query.message?.chat.id;
const userId = query.from.id;
const messageId = query.message?.message_id;
if (!chatId || !messageId) return;
// Check if user is admin
const isAdmin = await isGroupAdmin(bot, chatId, userId);
if (!isAdmin) {
await bot.answerCallbackQuery(query.id, {
text: messages.groups.adminOnly,
show_alert: true,
});
return;
}
try {
let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed';
switch (action) {
case 'toggle_enabled':
setting = 'enabled';
break;
case 'toggle_announcements':
setting = 'drawAnnouncements';
break;
case 'toggle_reminders':
setting = 'reminders';
break;
case 'toggle_purchases':
setting = 'ticketPurchaseAllowed';
break;
default:
await bot.answerCallbackQuery(query.id);
return;
}
const currentSettings = await groupStateManager.getGroup(chatId);
if (!currentSettings) {
await bot.answerCallbackQuery(query.id, { text: 'Group not found' });
return;
}
const newValue = !currentSettings[setting];
const updatedSettings = await groupStateManager.updateSetting(chatId, setting, newValue);
if (!updatedSettings) {
await bot.answerCallbackQuery(query.id, { text: 'Failed to update' });
return;
}
logUserAction(userId, 'Updated group setting', {
groupId: chatId,
setting,
newValue,
});
await bot.answerCallbackQuery(query.id, {
text: `${setting} ${newValue ? 'enabled' : 'disabled'}`,
});
// Update the message with new settings
await bot.editMessageText(
messages.groups.settingsOverview(updatedSettings),
{
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: getGroupSettingsKeyboard(updatedSettings),
}
);
} catch (error) {
logger.error('Error in handleGroupSettingsCallback', { error, chatId, action });
await bot.answerCallbackQuery(query.id, { text: 'Error updating settings' });
}
}
/**
* Generate inline keyboard for group settings
*/
function getGroupSettingsKeyboard(settings: {
enabled: boolean;
drawAnnouncements: boolean;
reminders: boolean;
ticketPurchaseAllowed: boolean;
}): TelegramBot.InlineKeyboardMarkup {
const onOff = (val: boolean) => val ? '✅' : '❌';
return {
inline_keyboard: [
[{
text: `${onOff(settings.enabled)} Bot Enabled`,
callback_data: 'group_toggle_enabled',
}],
[{
text: `${onOff(settings.drawAnnouncements)} Draw Announcements`,
callback_data: 'group_toggle_announcements',
}],
[{
text: `${onOff(settings.reminders)} Draw Reminders`,
callback_data: 'group_toggle_reminders',
}],
[{
text: `${onOff(settings.ticketPurchaseAllowed)} Allow Ticket Purchases`,
callback_data: 'group_toggle_purchases',
}],
[{
text: '🔄 Refresh',
callback_data: 'group_refresh',
}],
],
};
}
/**
* Handle refresh callback
*/
export async function handleGroupRefresh(
bot: TelegramBot,
query: TelegramBot.CallbackQuery
): Promise<void> {
const chatId = query.message?.chat.id;
const messageId = query.message?.message_id;
if (!chatId || !messageId) return;
await bot.answerCallbackQuery(query.id, { text: 'Refreshed!' });
const settings = await groupStateManager.getGroup(chatId);
if (!settings) return;
await bot.editMessageText(
messages.groups.settingsOverview(settings),
{
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: getGroupSettingsKeyboard(settings),
}
);
}
/**
* Send draw announcement to all enabled groups
*/
export async function broadcastDrawAnnouncement(
bot: TelegramBot,
announcement: string
): Promise<number> {
const groups = await groupStateManager.getGroupsWithFeature('drawAnnouncements');
let sent = 0;
for (const group of groups) {
try {
await bot.sendMessage(group.groupId, announcement, { parse_mode: 'Markdown' });
sent++;
} catch (error) {
logger.error('Failed to send announcement to group', {
groupId: group.groupId,
error,
});
// If bot was removed from group, clean up
if ((error as any)?.response?.statusCode === 403) {
await groupStateManager.removeGroup(group.groupId);
}
}
}
logger.info('Broadcast draw announcement', { sent, total: groups.length });
return sent;
}
/**
* Send draw reminder to all enabled groups
*/
export async function broadcastDrawReminder(
bot: TelegramBot,
reminder: string
): Promise<number> {
const groups = await groupStateManager.getGroupsWithFeature('reminders');
let sent = 0;
for (const group of groups) {
try {
await bot.sendMessage(group.groupId, reminder, { parse_mode: 'Markdown' });
sent++;
} catch (error) {
logger.error('Failed to send reminder to group', {
groupId: group.groupId,
error,
});
if ((error as any)?.response?.statusCode === 403) {
await groupStateManager.removeGroup(group.groupId);
}
}
}
logger.info('Broadcast draw reminder', { sent, total: groups.length });
return sent;
}
export default {
handleBotAddedToGroup,
handleBotRemovedFromGroup,
handleGroupSettings,
handleGroupSettingsCallback,
handleGroupRefresh,
broadcastDrawAnnouncement,
broadcastDrawReminder,
};

View File

@@ -0,0 +1,26 @@
import TelegramBot from 'node-telegram-bot-api';
import { logUserAction } from '../services/logger';
import { getMainMenuKeyboard } from '../utils/keyboards';
import { messages } from '../messages';
/**
* Handle /help command
*/
export async function handleHelpCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (userId) {
logUserAction(userId, 'Viewed help');
}
await bot.sendMessage(chatId, messages.help.message, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
}
export default handleHelpCommand;

View File

@@ -0,0 +1,33 @@
export { handleStart } from './start';
export {
handleAddressCommand,
handleLightningAddressInput,
} from './address';
export {
handleBuyCommand,
handleTicketAmountSelection,
handleCustomTicketAmount,
handlePurchaseConfirmation,
} from './buy';
export {
handleTicketsCommand,
handleViewTicket,
handleStatusCheck,
} from './tickets';
export { handleWinsCommand } from './wins';
export { handleHelpCommand } from './help';
export {
handleMenuCommand,
handleCancel,
handleMenuCallback,
} from './menu';
export {
handleBotAddedToGroup,
handleBotRemovedFromGroup,
handleGroupSettings,
handleGroupSettingsCallback,
handleGroupRefresh,
broadcastDrawAnnouncement,
broadcastDrawReminder,
} from './groups';

View File

@@ -0,0 +1,92 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { logUserAction } from '../services/logger';
import { getMainMenuKeyboard } from '../utils/keyboards';
import { messages } from '../messages';
/**
* Handle /menu command
*/
export async function handleMenuCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (userId) {
logUserAction(userId, 'Opened menu');
// Reset user state to idle
const user = await stateManager.getUser(userId);
if (user && user.lightningAddress) {
await stateManager.updateUserState(userId, 'idle');
}
}
await bot.sendMessage(chatId, messages.menu.header, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
}
/**
* Handle cancel callback
*/
export async function handleCancel(
bot: TelegramBot,
query: TelegramBot.CallbackQuery
): Promise<void> {
const chatId = query.message?.chat.id;
const userId = query.from.id;
const messageId = query.message?.message_id;
if (!chatId) return;
await bot.answerCallbackQuery(query.id, { text: 'Cancelled' });
// Clear user state
await stateManager.clearUserStateData(userId);
// Update message
if (messageId) {
await bot.editMessageText(messages.menu.cancelled, {
chat_id: chatId,
message_id: messageId,
});
}
// Show menu
await bot.sendMessage(chatId, messages.menu.whatToDo, {
reply_markup: getMainMenuKeyboard(),
});
}
/**
* Handle menu callback (back to menu)
*/
export async function handleMenuCallback(
bot: TelegramBot,
query: TelegramBot.CallbackQuery
): Promise<void> {
const chatId = query.message?.chat.id;
const userId = query.from.id;
if (!chatId) return;
await bot.answerCallbackQuery(query.id);
// Clear user state
await stateManager.clearUserStateData(userId);
await bot.sendMessage(chatId, messages.menu.header, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
}
export default {
handleMenuCommand,
handleCancel,
handleMenuCallback,
};

View File

@@ -0,0 +1,68 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { logger, logUserAction } from '../services/logger';
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards';
import { messages } from '../messages';
/**
* Handle /start command
*/
export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
return;
}
logUserAction(userId, 'Started bot', {
username: msg.from?.username,
firstName: msg.from?.first_name,
});
try {
// Check if user exists
let user = await stateManager.getUser(userId);
if (!user) {
// Create new user
user = await stateManager.createUser(
userId,
msg.from?.username,
msg.from?.first_name,
msg.from?.last_name
);
}
// Welcome message
await bot.sendMessage(chatId, messages.start.welcome, { parse_mode: 'Markdown' });
// Check if lightning address is set
if (!user.lightningAddress) {
await bot.sendMessage(chatId, messages.start.needAddress, {
parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(),
});
await stateManager.updateUserState(userId, 'awaiting_lightning_address');
} else {
// Show main menu
await bot.sendMessage(
chatId,
messages.start.addressSet(user.lightningAddress),
{
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
}
);
await stateManager.updateUserState(userId, 'idle');
}
} catch (error) {
logger.error('Error in handleStart', { error, userId });
await bot.sendMessage(chatId, messages.errors.startAgain);
}
}
export default handleStart;

View File

@@ -0,0 +1,259 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { apiClient } from '../services/api';
import { logger, logUserAction } from '../services/logger';
import config from '../config';
import { getMainMenuKeyboard, getViewTicketKeyboard } from '../utils/keyboards';
import { formatSats, formatDate } from '../utils/format';
import { messages } from '../messages';
/**
* Handle /tickets command or "My Tickets" button
*/
export async function handleTicketsCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
return;
}
logUserAction(userId, 'Viewed tickets');
try {
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst);
return;
}
// Get user's purchase IDs from state
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 10);
if (purchaseIds.length === 0) {
await bot.sendMessage(chatId, messages.tickets.empty, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
return;
}
// Fetch status for each purchase
const purchases: Array<{
id: string;
ticketCount: number;
scheduledAt: string;
invoiceStatus: string;
isWinner: boolean;
hasDrawn: boolean;
}> = [];
for (const purchaseId of purchaseIds) {
try {
const status = await apiClient.getTicketStatus(purchaseId);
if (status) {
purchases.push({
id: status.purchase.id,
ticketCount: status.purchase.number_of_tickets,
scheduledAt: status.cycle.scheduled_at,
invoiceStatus: status.purchase.invoice_status,
isWinner: status.result.is_winner,
hasDrawn: status.result.has_drawn,
});
}
} catch (error) {
// Skip failed fetches
logger.debug('Failed to fetch purchase', { purchaseId });
}
}
if (purchases.length === 0) {
await bot.sendMessage(chatId, messages.tickets.notFound, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
return;
}
// Format purchases list
let message = messages.tickets.header;
for (let i = 0; i < purchases.length; i++) {
const p = purchases[i];
const drawDate = new Date(p.scheduledAt);
let statusEmoji: string;
let statusText: string;
if (p.invoiceStatus === 'pending') {
statusEmoji = '⏳';
statusText = messages.tickets.statusPending;
} else if (p.invoiceStatus === 'expired') {
statusEmoji = '❌';
statusText = messages.tickets.statusExpired;
} else if (!p.hasDrawn) {
statusEmoji = '🎟';
statusText = messages.tickets.statusActive;
} else if (p.isWinner) {
statusEmoji = '🏆';
statusText = messages.tickets.statusWon;
} else {
statusEmoji = '😔';
statusText = messages.tickets.statusLost;
}
message += `${i + 1}. ${statusEmoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} ${formatDate(drawDate)} ${statusText}\n`;
}
message += messages.tickets.tapForDetails;
// Create inline buttons for each purchase
const inlineKeyboard = purchases.map((p, i) => [{
text: `${i + 1}. View Ticket #${p.id.substring(0, 8)}...`,
callback_data: `view_ticket_${p.id}`,
}]);
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: inlineKeyboard },
});
} catch (error) {
logger.error('Error in handleTicketsCommand', { error, userId });
await bot.sendMessage(chatId, messages.errors.fetchTicketsFailed, {
reply_markup: getMainMenuKeyboard(),
});
}
}
/**
* Handle viewing a specific ticket
*/
export async function handleViewTicket(
bot: TelegramBot,
query: TelegramBot.CallbackQuery,
purchaseId: string
): Promise<void> {
const chatId = query.message?.chat.id;
if (!chatId) return;
await bot.answerCallbackQuery(query.id);
try {
const status = await apiClient.getTicketStatus(purchaseId);
if (!status) {
await bot.sendMessage(chatId, messages.errors.ticketNotFound);
return;
}
const drawDate = new Date(status.cycle.scheduled_at);
const ticketNumbers = status.tickets
.map((t) => {
const winnerMark = t.is_winning_ticket ? ' 🏆' : '';
return `#${t.serial_number.toString().padStart(4, '0')}${winnerMark}`;
})
.join(', ');
let statusSection: string;
if (status.purchase.invoice_status === 'pending') {
statusSection = messages.tickets.detailAwaitingPayment;
} else if (status.purchase.invoice_status === 'expired') {
statusSection = messages.tickets.detailExpired;
} else if (!status.result.has_drawn) {
statusSection = messages.tickets.detailActive;
} else if (status.result.is_winner) {
const payoutStatus = status.result.payout?.status === 'paid' ? 'Paid ✅' : 'Pending';
statusSection = messages.tickets.detailWinner(
formatSats(status.result.payout?.amount_sats || 0),
payoutStatus
);
} else {
statusSection = messages.tickets.detailLost(
status.cycle.winning_ticket_id?.substring(0, 8) || 'N/A'
);
}
const message = messages.tickets.detailFormat(
status.purchase.id.substring(0, 8),
status.purchase.number_of_tickets,
ticketNumbers,
formatSats(status.purchase.amount_sats),
formatDate(drawDate),
statusSection
);
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: getViewTicketKeyboard(
purchaseId,
config.frontend.baseUrl + '/tickets/' + purchaseId
),
});
} catch (error) {
logger.error('Error in handleViewTicket', { error, purchaseId });
await bot.sendMessage(chatId, messages.errors.fetchTicketDetailsFailed);
}
}
/**
* Handle status check callback
*/
export async function handleStatusCheck(
bot: TelegramBot,
query: TelegramBot.CallbackQuery,
purchaseId: string
): Promise<void> {
const chatId = query.message?.chat.id;
if (!chatId) return;
await bot.answerCallbackQuery(query.id, { text: 'Checking status...' });
try {
const status = await apiClient.getTicketStatus(purchaseId);
if (!status) {
await bot.sendMessage(chatId, messages.errors.ticketNotFound);
return;
}
let statusMessage: string;
if (status.purchase.invoice_status === 'pending') {
statusMessage = messages.tickets.checkStatusPending;
} else if (status.purchase.invoice_status === 'paid' && status.purchase.ticket_issue_status === 'issued') {
if (!status.result.has_drawn) {
statusMessage = messages.tickets.checkStatusConfirmed;
} else if (status.result.is_winner) {
statusMessage = messages.tickets.checkStatusWon;
} else {
statusMessage = messages.tickets.checkStatusLost;
}
} else if (status.purchase.invoice_status === 'expired') {
statusMessage = messages.tickets.checkStatusExpired;
} else {
statusMessage = messages.tickets.checkStatusProcessing;
}
await bot.answerCallbackQuery(query.id, { text: statusMessage, show_alert: true });
} catch (error) {
logger.error('Error in handleStatusCheck', { error, purchaseId });
await bot.answerCallbackQuery(query.id, {
text: messages.errors.checkStatusFailed,
show_alert: true,
});
}
}
export default {
handleTicketsCommand,
handleViewTicket,
handleStatusCheck,
};

View File

@@ -0,0 +1,114 @@
import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state';
import { apiClient } from '../services/api';
import { logger, logUserAction } from '../services/logger';
import { getMainMenuKeyboard } from '../utils/keyboards';
import { formatSats, formatDate } from '../utils/format';
import { messages } from '../messages';
/**
* Handle /wins command or "My Wins" button
*/
export async function handleWinsCommand(
bot: TelegramBot,
msg: TelegramBot.Message
): Promise<void> {
const chatId = msg.chat.id;
const userId = msg.from?.id;
if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
return;
}
logUserAction(userId, 'Viewed wins');
try {
const user = await stateManager.getUser(userId);
if (!user) {
await bot.sendMessage(chatId, messages.errors.startFirst);
return;
}
// Get user's purchase IDs and check for wins
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 50);
if (purchaseIds.length === 0) {
await bot.sendMessage(chatId, messages.wins.empty, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
return;
}
// Check each purchase for wins
const wins: Array<{
purchaseId: string;
ticketId: string;
serialNumber: number;
amountSats: number;
status: string;
scheduledAt: string;
}> = [];
for (const purchaseId of purchaseIds) {
try {
const status = await apiClient.getTicketStatus(purchaseId);
if (status && status.result.is_winner && status.result.payout) {
const winningTicket = status.tickets.find(t => t.is_winning_ticket);
wins.push({
purchaseId: status.purchase.id,
ticketId: winningTicket?.id || '',
serialNumber: winningTicket?.serial_number || 0,
amountSats: status.result.payout.amount_sats,
status: status.result.payout.status,
scheduledAt: status.cycle.scheduled_at,
});
}
} catch (error) {
// Skip failed fetches
logger.debug('Failed to fetch purchase for wins', { purchaseId });
}
}
if (wins.length === 0) {
await bot.sendMessage(chatId, messages.wins.noWinsYet, {
parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(),
});
return;
}
// Calculate totals
const totalWinnings = wins.reduce((sum, w) => sum + w.amountSats, 0);
const paidWinnings = wins.filter(w => w.status === 'paid').reduce((sum, w) => sum + w.amountSats, 0);
let message = messages.wins.header(formatSats(totalWinnings), formatSats(paidWinnings));
for (const win of wins) {
const statusEmoji = win.status === 'paid' ? '✅' : '⏳';
message += `${formatSats(win.amountSats)} sats — ${formatDate(win.scheduledAt)}${statusEmoji} ${win.status}\n`;
}
// Create buttons for viewing wins
const inlineKeyboard = wins.slice(0, 5).map((w) => [{
text: `View Ticket #${w.serialNumber.toString().padStart(4, '0')}`,
callback_data: `view_ticket_${w.purchaseId}`,
}]);
await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: inlineKeyboard },
});
} catch (error) {
logger.error('Error in handleWinsCommand', { error, userId });
await bot.sendMessage(chatId, messages.errors.fetchWinsFailed, {
reply_markup: getMainMenuKeyboard(),
});
}
}
export default {
handleWinsCommand,
};

448
telegram_bot/src/index.ts Normal file
View File

@@ -0,0 +1,448 @@
import TelegramBot from 'node-telegram-bot-api';
import config from './config';
import { stateManager } from './services/state';
import { groupStateManager } from './services/groupState';
import { apiClient } from './services/api';
import { logger, logUserAction } from './services/logger';
import {
handleStart,
handleAddressCommand,
handleLightningAddressInput,
handleBuyCommand,
handleTicketAmountSelection,
handleCustomTicketAmount,
handlePurchaseConfirmation,
handleTicketsCommand,
handleViewTicket,
handleStatusCheck,
handleWinsCommand,
handleHelpCommand,
handleMenuCommand,
handleCancel,
handleMenuCallback,
handleBotAddedToGroup,
handleBotRemovedFromGroup,
handleGroupSettings,
handleGroupSettingsCallback,
handleGroupRefresh,
} from './handlers';
import { getMainMenuKeyboard } from './utils/keyboards';
import { messages } from './messages';
import { formatSats, formatDate, formatTimeUntil } from './utils/format';
// Create bot instance
const bot = new TelegramBot(config.telegram.botToken, { polling: true });
// Track processed message IDs to prevent duplicate handling
const processedMessages = new Set<number>();
const MESSAGE_CACHE_TTL = 60000; // 1 minute
function shouldProcessMessage(messageId: number): boolean {
if (processedMessages.has(messageId)) {
return false;
}
processedMessages.add(messageId);
// Clean up old entries
setTimeout(() => processedMessages.delete(messageId), MESSAGE_CACHE_TTL);
return true;
}
/**
* Check if message is from a group
*/
function isGroupChat(msg: TelegramBot.Message): boolean {
return msg.chat.type === 'group' || msg.chat.type === 'supergroup';
}
// ═══════════════════════════════════════════════════════════════════════════
// GROUP EVENTS
// ═══════════════════════════════════════════════════════════════════════════
// Handle bot being added/removed from groups
bot.on('message', async (msg) => {
// Handle new chat members (bot added to group)
if (msg.new_chat_members) {
const botInfo = await bot.getMe();
const botAdded = msg.new_chat_members.some(m => m.id === botInfo.id);
if (botAdded) {
await handleBotAddedToGroup(bot, msg);
}
}
// Handle chat member left (bot removed from group)
if (msg.left_chat_member) {
const botInfo = await bot.getMe();
if (msg.left_chat_member.id === botInfo.id) {
await handleBotRemovedFromGroup(bot, msg);
}
}
});
// ═══════════════════════════════════════════════════════════════════════════
// COMMANDS
// ═══════════════════════════════════════════════════════════════════════════
// Handle /start command
bot.onText(/\/start/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// In groups, just show a welcome message
if (isGroupChat(msg)) {
await bot.sendMessage(
msg.chat.id,
`⚡ *Lightning Jackpot Bot*\n\nTo buy tickets and manage your account, message me directly!\n\nUse /jackpot to see current jackpot info.\nAdmins: Use /settings to configure the bot.`,
{ parse_mode: 'Markdown' }
);
return;
}
await handleStart(bot, msg);
});
// Handle /buy command
bot.onText(/\/buy/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Check if in group
if (isGroupChat(msg)) {
const settings = await groupStateManager.getGroup(msg.chat.id);
if (settings && !settings.ticketPurchaseAllowed) {
await bot.sendMessage(msg.chat.id, messages.groups.purchasesDisabled, {
parse_mode: 'Markdown',
});
return;
}
}
await handleBuyCommand(bot, msg);
});
// Handle /tickets command
bot.onText(/\/tickets/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '🧾 To view your tickets, message me directly!');
return;
}
await handleTicketsCommand(bot, msg);
});
// Handle /wins command
bot.onText(/\/wins/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '🏆 To view your wins, message me directly!');
return;
}
await handleWinsCommand(bot, msg);
});
// Handle /address command
bot.onText(/\/address/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '⚡ To update your Lightning Address, message me directly!');
return;
}
await handleAddressCommand(bot, msg);
});
// Handle /menu command
bot.onText(/\/menu/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat
if (isGroupChat(msg)) {
await bot.sendMessage(msg.chat.id, '📱 To access the full menu, message me directly!');
return;
}
await handleMenuCommand(bot, msg);
});
// Handle /help command
bot.onText(/\/help/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
await handleHelpCommand(bot, msg);
});
// Handle /jackpot command (works in groups and DMs)
bot.onText(/\/jackpot/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
try {
const jackpot = await apiClient.getNextJackpot();
if (!jackpot) {
await bot.sendMessage(msg.chat.id, messages.buy.noActiveJackpot, {
parse_mode: 'Markdown',
});
return;
}
const drawTime = new Date(jackpot.cycle.scheduled_at);
const message = `🎰 *Current Jackpot*
💰 *Prize Pool:* ${formatSats(jackpot.cycle.pot_total_sats)} sats
🎟 *Ticket Price:* ${formatSats(jackpot.lottery.ticket_price_sats)} sats
⏰ *Draw at:* ${formatDate(drawTime)}
⏳ *Time left:* ${formatTimeUntil(drawTime)}
Use /buy to get your tickets! 🍀`;
await bot.sendMessage(msg.chat.id, message, { parse_mode: 'Markdown' });
} catch (error) {
logger.error('Error in /jackpot command', { error });
await bot.sendMessage(msg.chat.id, messages.errors.systemUnavailable);
}
});
// Handle /settings command (groups only, admin only)
bot.onText(/\/settings/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return;
await handleGroupSettings(bot, msg);
});
// ═══════════════════════════════════════════════════════════════════════════
// TEXT MESSAGES
// ═══════════════════════════════════════════════════════════════════════════
// Handle keyboard button presses (text messages)
bot.on('message', async (msg) => {
if (!msg.text || msg.text.startsWith('/')) return;
if (!shouldProcessMessage(msg.message_id)) return;
// Ignore group messages for button handling
if (isGroupChat(msg)) return;
const text = msg.text.trim();
const userId = msg.from?.id;
if (!userId) return;
// Handle menu button presses
switch (text) {
case '🎟 Buy Tickets':
await handleBuyCommand(bot, msg);
return;
case '🧾 My Tickets':
await handleTicketsCommand(bot, msg);
return;
case '🏆 My Wins':
await handleWinsCommand(bot, msg);
return;
case '⚡ Lightning Address':
await handleAddressCommand(bot, msg);
return;
case ' Help':
await handleHelpCommand(bot, msg);
return;
}
// Handle state-dependent text input
try {
const user = await stateManager.getUser(userId);
if (!user) {
// Unknown user, prompt to start
await bot.sendMessage(msg.chat.id, messages.start.welcomeUnknown);
return;
}
// Handle lightning address input
if (user.state === 'awaiting_lightning_address' || user.state === 'updating_address') {
const handled = await handleLightningAddressInput(bot, msg);
if (handled) return;
}
// Handle custom ticket amount input
if (user.state === 'awaiting_ticket_amount') {
const handled = await handleCustomTicketAmount(bot, msg);
if (handled) return;
}
// Unhandled message - show menu prompt
if (user.state === 'idle') {
await bot.sendMessage(msg.chat.id, messages.menu.didNotUnderstand, {
reply_markup: getMainMenuKeyboard(),
});
}
} catch (error) {
logger.error('Error handling message', { error, userId, text });
}
});
// ═══════════════════════════════════════════════════════════════════════════
// CALLBACK QUERIES
// ═══════════════════════════════════════════════════════════════════════════
// Handle callback queries (inline button presses)
bot.on('callback_query', async (query) => {
const data = query.data;
if (!data) {
await bot.answerCallbackQuery(query.id);
return;
}
logUserAction(query.from.id, 'Callback', { data });
try {
// Handle group settings toggles
if (data.startsWith('group_toggle_')) {
const action = data.replace('group_', '');
await handleGroupSettingsCallback(bot, query, action);
return;
}
// Handle group refresh
if (data === 'group_refresh') {
await handleGroupRefresh(bot, query);
return;
}
// Handle buy amount selection
if (data.startsWith('buy_')) {
const amountStr = data.replace('buy_', '');
if (amountStr === 'custom') {
await handleTicketAmountSelection(bot, query, 'custom');
} else {
const amount = parseInt(amountStr, 10);
if (!isNaN(amount)) {
await handleTicketAmountSelection(bot, query, amount);
}
}
return;
}
// Handle purchase confirmation
if (data === 'confirm_purchase') {
await handlePurchaseConfirmation(bot, query);
return;
}
// Handle cancel
if (data === 'cancel') {
await handleCancel(bot, query);
return;
}
// Handle menu
if (data === 'menu') {
await handleMenuCallback(bot, query);
return;
}
// Handle view ticket
if (data.startsWith('view_ticket_')) {
const purchaseId = data.replace('view_ticket_', '');
await handleViewTicket(bot, query, purchaseId);
return;
}
// Handle status check
if (data.startsWith('status_')) {
const purchaseId = data.replace('status_', '');
await handleStatusCheck(bot, query, purchaseId);
return;
}
// Handle ticket pagination
if (data.startsWith('tickets_page_')) {
// TODO: Implement pagination
await bot.answerCallbackQuery(query.id, { text: 'Pagination coming soon!' });
return;
}
// Unknown callback
await bot.answerCallbackQuery(query.id);
} catch (error) {
logger.error('Error handling callback query', { error, data });
await bot.answerCallbackQuery(query.id, {
text: messages.errors.generic,
show_alert: true,
});
}
});
// ═══════════════════════════════════════════════════════════════════════════
// ERROR HANDLING & LIFECYCLE
// ═══════════════════════════════════════════════════════════════════════════
// Handle polling errors
bot.on('polling_error', (error) => {
logger.error('Polling error', { error: error.message });
});
// Graceful shutdown
async function shutdown(): Promise<void> {
logger.info('Shutting down...');
bot.stopPolling();
await stateManager.close();
await groupStateManager.close();
logger.info('Shutdown complete');
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Start bot
async function start(): Promise<void> {
try {
// Initialize state managers
await stateManager.init();
await groupStateManager.init(config.redis.url);
// Set bot commands for private chats
await bot.setMyCommands([
{ command: 'start', description: 'Start the bot' },
{ command: 'menu', description: 'Show main menu' },
{ command: 'buy', description: 'Buy lottery tickets' },
{ command: 'tickets', description: 'View your tickets' },
{ command: 'wins', description: 'View your past wins' },
{ command: 'address', description: 'Update Lightning Address' },
{ command: 'jackpot', description: 'View current jackpot info' },
{ command: 'help', description: 'Help & information' },
]);
// Set bot commands for groups (different scope)
await bot.setMyCommands(
[
{ command: 'jackpot', description: 'View current jackpot info' },
{ command: 'settings', description: 'Group settings (admin only)' },
{ command: 'help', description: 'Help & information' },
],
{ scope: { type: 'all_group_chats' } }
);
const botInfo = await bot.getMe();
logger.info(`🤖 Bot started: @${botInfo.username}`, {
id: botInfo.id,
username: botInfo.username,
});
logger.info('⚡ Lightning Jackpot Telegram Bot is running!');
logger.info(`📡 API URL: ${config.api.baseUrl}`);
logger.info(`🌐 Frontend URL: ${config.frontend.baseUrl}`);
logger.info('👥 Group support enabled');
} catch (error) {
logger.error('Failed to start bot', { error });
process.exit(1);
}
}
start();
// Export bot instance and broadcast functions for external use
export { bot };
export { broadcastDrawAnnouncement, broadcastDrawReminder } from './handlers';

View File

@@ -0,0 +1,444 @@
/**
* All Telegram bot messages centralized for easy management and future i18n support
*/
export const messages = {
// ═══════════════════════════════════════════════════════════════════════════
// ERRORS
// ═══════════════════════════════════════════════════════════════════════════
errors: {
userNotIdentified: '❌ Could not identify user.',
startFirst: '❌ Please start the bot first with /start',
generic: '❌ An error occurred. Please try again.',
startAgain: '❌ An error occurred. Please try again with /start',
systemUnavailable: '❌ The lottery system is temporarily unavailable. Please try again soon.',
invoiceCreationFailed: '❌ Failed to create invoice. Please try again.',
fetchTicketsFailed: '❌ Failed to fetch tickets. Please try again.',
fetchWinsFailed: '❌ Failed to fetch wins. Please try again.',
ticketNotFound: '❌ Ticket not found.',
fetchTicketDetailsFailed: '❌ Failed to fetch ticket details.',
checkStatusFailed: '❌ Failed to check status',
noPendingPurchase: '❌ No pending purchase. Please start again with /buy',
setAddressFirst: '❌ Please set your Lightning Address first.',
},
// ═══════════════════════════════════════════════════════════════════════════
// START / ONBOARDING
// ═══════════════════════════════════════════════════════════════════════════
start: {
welcome: `⚡🎉 *Welcome to Lightning Jackpot!* 🎉⚡
You can buy Bitcoin Lightning lottery tickets, and if you win, your prize is paid instantly to your Lightning Address!
🎯 *How it works:*
1⃣ Set your Lightning Address (where you'll receive winnings)
2⃣ Buy tickets with Lightning
3⃣ Wait for the draw
4⃣ If you win, prize is sent instantly!`,
needAddress: `Before you can play, I need your Lightning Address to send any winnings.
*Example:* \`yourname@getalby.com\`
Please send your Lightning Address now:`,
addressSet: (address: string) =>
`✅ Your payout address is set to: \`${address}\`
Use the menu below to get started! Good luck! 🍀`,
welcomeUnknown: '👋 Welcome! Please use /start to begin.',
},
// ═══════════════════════════════════════════════════════════════════════════
// LIGHTNING ADDRESS
// ═══════════════════════════════════════════════════════════════════════════
address: {
currentAddress: (address: string) =>
`⚡ *Your Current Payout Address:*
\`${address}\`
Send me your new Lightning Address to update it:`,
noAddressSet: `⚡ You don't have a Lightning Address set yet.
Send me your Lightning Address now:
*Example:* \`yourname@getalby.com\``,
invalidFormat: `❌ That doesn't look like a valid Lightning Address.
*Format:* \`username@domain.com\`
*Example:* \`satoshi@getalby.com\`
Please try again:`,
firstTimeSuccess: (address: string) =>
`✅ *Perfect!* I'll use \`${address}\` to send any winnings.
You're all set! Use the menu below to buy tickets and check your results. Good luck! 🍀`,
updateSuccess: (address: string) =>
`✅ *Lightning Address updated!*
New address: \`${address}\`
⚠️ *Note:* Previous ticket purchases will still use their original addresses.`,
saveFailed: '❌ An error occurred saving your address. Please try again.',
needAddressFirst: `❌ I don't have your Lightning Address yet!
Please send your Lightning Address first:
*Example:* \`yourname@getalby.com\``,
},
// ═══════════════════════════════════════════════════════════════════════════
// BUY TICKETS
// ═══════════════════════════════════════════════════════════════════════════
buy: {
noActiveJackpot: `😔 *No Active Jackpot*
There's no lottery cycle available right now. Please check back soon!`,
jackpotInfo: (
potSats: string,
ticketPrice: string,
drawTime: string,
timeLeft: string
) =>
`🎰 *Next Jackpot Info*
💰 *Prize Pool:* ${potSats} sats
🎟 *Ticket Price:* ${ticketPrice} sats each
⏰ *Draw at:* ${drawTime}
⏳ *Time left:* ${timeLeft}
How many tickets do you want to buy?`,
customAmountPrompt: (maxTickets: number) =>
`🔢 *Custom Amount*
Enter the number of tickets you want to buy (1-${maxTickets}):`,
invalidNumber: (maxTickets: number) =>
`❌ Please enter a valid number (1-${maxTickets}):`,
tooManyTickets: (maxTickets: number) =>
`❌ Maximum ${maxTickets} tickets per purchase.
Please enter a smaller number:`,
confirmPurchase: (
ticketCount: number,
ticketPrice: string,
totalAmount: string,
drawTime: string
) =>
`📋 *Confirm Purchase*
🎟 *Tickets:* ${ticketCount}
💰 *Price:* ${ticketPrice} sats each
💵 *Total:* ${totalAmount} sats
⏰ *Draw:* ${drawTime}
Confirm this purchase?`,
creatingInvoice: 'Creating invoice...',
invoiceCreated: '💸 *Pay this Lightning invoice to complete your purchase:*',
invoiceCaption: (
ticketCount: number,
totalAmount: string,
paymentRequest: string,
expiryMinutes: number
) =>
`🎟 *${ticketCount} ticket${ticketCount > 1 ? 's' : ''}*
💰 *Amount:* ${totalAmount} sats
\`${paymentRequest}\`
⏳ This invoice expires in ${expiryMinutes} minutes.
I'll notify you when payment is received!`,
paymentReceived: (ticketNumbers: string, drawTime: string) =>
`🎉 *Payment Received!*
Your tickets have been issued!
*Your Ticket Numbers:*
${ticketNumbers}
*Draw Time:* ${drawTime}
Good luck! 🍀 I'll notify you after the draw!`,
invoiceExpired: `❌ *Invoice Expired*
No payment was received in time. No tickets were issued.
Use /buy to try again.`,
invoiceExpiredShort: `❌ *Invoice Expired*
This invoice has expired. No tickets were issued.
Use /buy to try again.`,
jackpotUnavailable: '❌ Jackpot is no longer available.',
},
// ═══════════════════════════════════════════════════════════════════════════
// TICKETS
// ═══════════════════════════════════════════════════════════════════════════
tickets: {
header: `🧾 *Your Recent Purchases*\n\n`,
empty: `🧾 *Your Tickets*
You haven't purchased any tickets yet!
Use /buy to get started! 🎟`,
notFound: `🧾 *Your Tickets*
No ticket purchases found. Purchase history may have expired.
Use /buy to get new tickets! 🎟`,
tapForDetails: `\nTap a ticket below for details:`,
statusPending: 'Pending',
statusExpired: 'Expired',
statusActive: 'Active',
statusWon: 'Won!',
statusLost: 'Lost',
// Ticket detail view
detailHeader: '🎫 *Ticket Details*',
detailAwaitingPayment: '⏳ *Status:* Awaiting Payment',
detailExpired: '❌ *Status:* Invoice Expired (No tickets issued)',
detailActive: '🎟 *Status:* Active - Draw Pending',
detailWinner: (prizeSats: string, payoutStatus: string) =>
`🏆 *Status:* WINNER!
💰 *Prize:* ${prizeSats} sats
📤 *Payout:* ${payoutStatus}`,
detailLost: (winningTicketId: string) =>
`😔 *Status:* Did not win this round
🎯 *Winning Ticket:* #${winningTicketId}`,
detailFormat: (
purchaseId: string,
ticketCount: number,
ticketNumbers: string,
amountSats: string,
drawDate: string,
statusSection: string
) =>
`🎫 *Ticket Details*
📋 *Purchase ID:* \`${purchaseId}...\`
🎟 *Tickets:* ${ticketCount}
🔢 *Numbers:* ${ticketNumbers}
💰 *Amount Paid:* ${amountSats} sats
📅 *Draw:* ${drawDate}
${statusSection}`,
// Status check responses
checkStatusPending: '⏳ Still waiting for payment...',
checkStatusConfirmed: '✅ Payment confirmed! Tickets issued. Waiting for draw...',
checkStatusWon: '🏆 YOU WON! Check your Lightning wallet!',
checkStatusLost: '😔 Draw completed. Better luck next time!',
checkStatusExpired: '❌ Invoice expired. No tickets were issued.',
checkStatusProcessing: '⏳ Processing...',
},
// ═══════════════════════════════════════════════════════════════════════════
// WINS
// ═══════════════════════════════════════════════════════════════════════════
wins: {
empty: `🏆 *Your Wins*
You haven't purchased any tickets yet, so no wins to show!
Use /buy to get started! 🎟`,
noWinsYet: `🏆 *Your Wins*
You haven't won any jackpots yet. Keep playing!
Use /buy to get more tickets! 🎟🍀`,
header: (totalWinnings: string, paidWinnings: string) =>
`🏆 *Your Wins*
💰 *Total Winnings:* ${totalWinnings} sats
✅ *Paid:* ${paidWinnings} sats
*Win History:*
`,
},
// ═══════════════════════════════════════════════════════════════════════════
// HELP
// ═══════════════════════════════════════════════════════════════════════════
help: {
message: `⚡🎰 *Lightning Jackpot Bot* 🎰⚡
This is the Lightning Jackpot lottery bot! Buy tickets with Bitcoin Lightning, and if you win, your prize is paid instantly!
*How It Works:*
1⃣ Set your Lightning Address (where winnings go)
2⃣ Buy tickets using the menu
3⃣ Pay the Lightning invoice
4⃣ Wait for the draw
5⃣ If you win, sats are sent to your address instantly!
*Commands:*
• /buy — Buy lottery tickets
• /tickets — View your tickets
• /wins — View your past wins
• /address — Update Lightning Address
• /menu — Show main menu
• /help — Show this help
*Tips:*
🎟 Each ticket is one chance to win
💰 Prize pool grows with each ticket sold
⚡ Winnings are paid instantly via Lightning
🔔 You'll be notified after every draw
Good luck! 🍀`,
},
// ═══════════════════════════════════════════════════════════════════════════
// MENU
// ═══════════════════════════════════════════════════════════════════════════
menu: {
header: `🎰 *Lightning Jackpot Menu*
What would you like to do?`,
cancelled: '❌ Cancelled.',
whatToDo: 'What would you like to do?',
didNotUnderstand:
"I didn't understand that. Use the menu below or type /help for available commands.",
},
// ═══════════════════════════════════════════════════════════════════════════
// DRAW NOTIFICATIONS
// ═══════════════════════════════════════════════════════════════════════════
notifications: {
winner: (
prizeSats: string,
winningTicket: string,
payoutStatus: string
) =>
`🎉 *YOU WON THE LIGHTNING JACKPOT!* 🎉
💰 *Prize:* ${prizeSats} sats
🎟 *Winning Ticket:* #${winningTicket}
📤 *Payout Status:* ${payoutStatus}
Congratulations! 🥳`,
loser: (winningTicket: string, prizeSats: string) =>
`The draw has finished!
Your tickets did not win this time.
🎟 *Winning Ticket:* #${winningTicket}
💰 *Prize:* ${prizeSats} sats
Good luck next round! 🍀`,
drawAnnouncement: (
winnerName: string,
winningTicket: string,
prizeSats: string,
totalTickets: number
) =>
`🎰 *JACKPOT DRAW COMPLETE!* 🎰
🏆 *Winner:* ${winnerName}
🎟 *Winning Ticket:* #${winningTicket}
💰 *Prize:* ${prizeSats} sats
📊 *Total Tickets:* ${totalTickets}
Congratulations to the winner! ⚡
Use /buy to enter the next draw! 🍀`,
drawReminder: (potSats: string, drawTime: string, timeLeft: string) =>
`⏰ *Draw Reminder!*
🎰 The next Lightning Jackpot draw is coming up!
💰 *Current Prize Pool:* ${potSats} sats
🕐 *Draw Time:* ${drawTime}
⏳ *Time Left:* ${timeLeft}
Don't miss your chance to win! Use /buy to get your tickets! 🎟`,
},
// ═══════════════════════════════════════════════════════════════════════════
// GROUPS
// ═══════════════════════════════════════════════════════════════════════════
groups: {
welcome: (groupName: string) =>
`⚡🎰 *Lightning Jackpot Bot Added!* 🎰⚡
Hello *${groupName}*! I'm the Lightning Jackpot lottery bot.
I can announce lottery draws and remind you when jackpots are coming up!
*Group Admin Commands:*
• /settings — Configure bot settings for this group
*User Commands:*
• /buy — Buy lottery tickets (in DM)
• /jackpot — View current jackpot info
• /help — Get help
To buy tickets, message me directly @LightningLottoBot! 🎟`,
privateChat: '❌ This command only works in groups. Use /menu to see available commands.',
adminOnly: '⚠️ Only group administrators can change these settings.',
settingsOverview: (settings: {
groupTitle: string;
enabled: boolean;
drawAnnouncements: boolean;
reminders: boolean;
ticketPurchaseAllowed: boolean;
}) =>
`⚙️ *Group Settings*
📍 *Group:* ${settings.groupTitle}
*Current Configuration:*
${settings.enabled ? '✅' : '❌'} Bot Enabled
${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements
${settings.reminders ? '✅' : '❌'} Draw Reminders
${settings.ticketPurchaseAllowed ? '✅' : '❌'} Ticket Purchases in Group
Tap a button below to toggle settings:`,
settingUpdated: (setting: string, enabled: boolean) =>
`✅ *${setting}* has been ${enabled ? 'enabled' : 'disabled'}.`,
botDisabled: 'The Lightning Jackpot bot is currently disabled for this group.',
purchasesDisabled: `🎟 Ticket purchases are disabled in this group for privacy.
Please message me directly to buy tickets: @LightningLottoBot`,
},
};
export default messages;

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;

View File

@@ -0,0 +1,25 @@
/**
* Group settings for lottery features
*/
export interface GroupSettings {
groupId: number;
groupTitle: string;
enabled: boolean;
drawAnnouncements: boolean;
reminders: boolean;
ticketPurchaseAllowed: boolean;
addedBy: number;
addedAt: Date;
updatedAt: Date;
}
/**
* Default group settings
*/
export const DEFAULT_GROUP_SETTINGS: Omit<GroupSettings, 'groupId' | 'groupTitle' | 'addedBy' | 'addedAt' | 'updatedAt'> = {
enabled: true,
drawAnnouncements: true,
reminders: true,
ticketPurchaseAllowed: false, // Disabled by default for privacy - users should buy in DM
};

View File

@@ -0,0 +1,138 @@
// User state for conversation flow
export type UserState =
| 'idle'
| 'awaiting_lightning_address'
| 'awaiting_ticket_amount'
| 'awaiting_invoice_payment'
| 'updating_address';
// Telegram user data stored in state
export interface TelegramUser {
telegramId: number;
username?: string;
firstName?: string;
lastName?: string;
lightningAddress?: string;
state: UserState;
stateData?: Record<string, any>;
createdAt: Date;
updatedAt: Date;
}
// API Response Types
export interface ApiResponse<T> {
version: string;
data: T;
error?: string;
message?: string;
}
export interface JackpotNextResponse {
lottery: {
id: string;
name: string;
ticket_price_sats: number;
};
cycle: {
id: string;
cycle_type: string;
scheduled_at: string;
sales_open_at: string;
sales_close_at: string;
status: string;
pot_total_sats: number;
};
}
export interface BuyTicketsResponse {
ticket_purchase_id: string;
public_url: string;
invoice: {
payment_request: string;
amount_sats: number;
};
}
export interface TicketStatusResponse {
purchase: {
id: string;
lottery_id: string;
cycle_id: string;
lightning_address: string;
buyer_name: string;
number_of_tickets: number;
ticket_price_sats: number;
amount_sats: number;
invoice_status: 'pending' | 'paid' | 'expired' | 'cancelled';
ticket_issue_status: 'not_issued' | 'issued';
created_at: string;
};
tickets: Array<{
id: string;
serial_number: number;
is_winning_ticket: boolean;
}>;
cycle: {
id: string;
cycle_type: string;
scheduled_at: string;
status: string;
pot_total_sats: number;
pot_after_fee_sats: number | null;
winning_ticket_id: string | null;
};
result: {
has_drawn: boolean;
is_winner: boolean;
payout: {
status: string;
amount_sats: number;
} | null;
};
}
// Ticket purchase for user's ticket list
export interface UserTicketPurchase {
id: string;
cycle_id: string;
scheduled_at: string;
cycle_status: string;
number_of_tickets: number;
amount_sats: number;
invoice_status: string;
ticket_issue_status: string;
created_at: string;
}
// User win entry
export interface UserWin {
id: string;
cycle_id: string;
ticket_id: string;
amount_sats: number;
status: string;
scheduled_at: string;
created_at: string;
}
// State data for pending purchase
export interface PendingPurchaseData {
ticketCount: number;
cycleId: string;
scheduledAt: string;
ticketPrice: number;
totalAmount: number;
lotteryName: string;
}
// State data for awaiting payment
export interface AwaitingPaymentData extends PendingPurchaseData {
purchaseId: string;
paymentRequest: string;
publicUrl: string;
pollStartTime: number;
}
// Re-export group types
export * from './groups';

View File

@@ -0,0 +1,71 @@
/**
* Format sats amount with thousand separators
*/
export function formatSats(sats: number): string {
return new Intl.NumberFormat('en-US').format(sats);
}
/**
* Format date for display
*/
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short',
});
}
/**
* Format relative time until draw
*/
export function formatTimeUntil(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const diff = d.getTime() - now.getTime();
if (diff < 0) {
return 'now';
}
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${days}d ${hours % 24}h`;
}
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
return `${minutes}m`;
}
/**
* Validate Lightning Address format
*/
export function isValidLightningAddress(address: string): boolean {
// Basic format: something@something.something
const regex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return regex.test(address);
}
/**
* Escape markdown special characters for Telegram MarkdownV2
*/
export function escapeMarkdown(text: string): string {
return text.replace(/[_*\[\]()~`>#+=|{}.!-]/g, '\\$&');
}
/**
* Truncate string with ellipsis
*/
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.substring(0, maxLength - 3) + '...';
}

View File

@@ -0,0 +1,145 @@
import TelegramBot, {
InlineKeyboardMarkup,
ReplyKeyboardMarkup,
} from 'node-telegram-bot-api';
/**
* Main menu reply keyboard
*/
export function getMainMenuKeyboard(): ReplyKeyboardMarkup {
return {
keyboard: [
[{ text: '🎟 Buy Tickets' }, { text: '🧾 My Tickets' }],
[{ text: '🏆 My Wins' }, { text: '⚡ Lightning Address' }],
[{ text: ' Help' }],
],
resize_keyboard: true,
one_time_keyboard: false,
};
}
/**
* Quick ticket amount selection
*/
export function getTicketAmountKeyboard(): InlineKeyboardMarkup {
return {
inline_keyboard: [
[
{ text: '1 ticket', callback_data: 'buy_1' },
{ text: '2 tickets', callback_data: 'buy_2' },
{ text: '5 tickets', callback_data: 'buy_5' },
{ text: '10 tickets', callback_data: 'buy_10' },
],
[{ text: '🔢 Custom Amount', callback_data: 'buy_custom' }],
[{ text: '❌ Cancel', callback_data: 'cancel' }],
],
};
}
/**
* Confirmation keyboard
*/
export function getConfirmationKeyboard(): InlineKeyboardMarkup {
return {
inline_keyboard: [
[
{ text: '✅ Confirm', callback_data: 'confirm_purchase' },
{ text: '❌ Cancel', callback_data: 'cancel' },
],
],
};
}
/**
* Check if URL is valid for Telegram inline buttons (must be HTTPS, not localhost)
*/
function isValidTelegramUrl(url: string): boolean {
try {
const parsed = new URL(url);
// Telegram requires HTTPS and doesn't accept localhost/127.0.0.1
return (
parsed.protocol === 'https:' &&
!parsed.hostname.includes('localhost') &&
!parsed.hostname.includes('127.0.0.1')
);
} catch {
return false;
}
}
/**
* View ticket status button
*/
export function getViewTicketKeyboard(
purchaseId: string,
publicUrl?: string
): InlineKeyboardMarkup {
const buttons: TelegramBot.InlineKeyboardButton[][] = [
[{ text: '🔄 Check Status', callback_data: `status_${purchaseId}` }],
];
// Only add URL button if it's a valid Telegram URL (HTTPS, not localhost)
if (publicUrl && isValidTelegramUrl(publicUrl)) {
buttons.push([{ text: '🌐 View on Web', url: publicUrl }]);
}
return {
inline_keyboard: buttons,
};
}
/**
* Ticket list navigation
*/
export function getTicketListKeyboard(
tickets: Array<{ id: string; label: string }>,
currentPage: number,
hasMore: boolean
): InlineKeyboardMarkup {
const buttons: TelegramBot.InlineKeyboardButton[][] = [];
// Add ticket buttons
for (const ticket of tickets) {
buttons.push([{
text: ticket.label,
callback_data: `view_ticket_${ticket.id}`,
}]);
}
// Add pagination
const navButtons: TelegramBot.InlineKeyboardButton[] = [];
if (currentPage > 0) {
navButtons.push({ text: '⬅️ Previous', callback_data: `tickets_page_${currentPage - 1}` });
}
if (hasMore) {
navButtons.push({ text: '➡️ Next', callback_data: `tickets_page_${currentPage + 1}` });
}
if (navButtons.length > 0) {
buttons.push(navButtons);
}
return { inline_keyboard: buttons };
}
/**
* Back to menu button
*/
export function getBackToMenuKeyboard(): InlineKeyboardMarkup {
return {
inline_keyboard: [
[{ text: '🏠 Back to Menu', callback_data: 'menu' }],
],
};
}
/**
* Cancel operation button
*/
export function getCancelKeyboard(): InlineKeyboardMarkup {
return {
inline_keyboard: [
[{ text: '❌ Cancel', callback_data: 'cancel' }],
],
};
}