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