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:
106
telegram_bot/src/handlers/address.ts
Normal file
106
telegram_bot/src/handlers/address.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { stateManager } from '../services/state';
|
||||
import { logger, logUserAction } from '../services/logger';
|
||||
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards';
|
||||
import { isValidLightningAddress } from '../utils/format';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
* Handle /address command or "Lightning Address" button
|
||||
*/
|
||||
export async function handleAddressCommand(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
|
||||
if (!userId) {
|
||||
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Requested address update');
|
||||
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
|
||||
if (!user) {
|
||||
await bot.sendMessage(chatId, messages.errors.startFirst);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = user.lightningAddress
|
||||
? messages.address.currentAddress(user.lightningAddress)
|
||||
: messages.address.noAddressSet;
|
||||
|
||||
await bot.sendMessage(chatId, message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
});
|
||||
|
||||
await stateManager.updateUserState(userId, 'updating_address');
|
||||
} catch (error) {
|
||||
logger.error('Error in handleAddressCommand', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.generic);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming Lightning Address from user
|
||||
*/
|
||||
export async function handleLightningAddressInput(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<boolean> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
const text = msg.text?.trim();
|
||||
|
||||
if (!userId || !text) return false;
|
||||
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
// Check if user is in a state expecting lightning address
|
||||
if (user.state !== 'awaiting_lightning_address' && user.state !== 'updating_address') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate lightning address format
|
||||
if (!isValidLightningAddress(text)) {
|
||||
await bot.sendMessage(chatId, messages.address.invalidFormat, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save the lightning address
|
||||
await stateManager.updateLightningAddress(userId, text);
|
||||
|
||||
logUserAction(userId, 'Lightning address updated');
|
||||
|
||||
const responseMessage = user.state === 'awaiting_lightning_address'
|
||||
? messages.address.firstTimeSuccess(text)
|
||||
: messages.address.updateSuccess(text);
|
||||
|
||||
await bot.sendMessage(chatId, responseMessage, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Error in handleLightningAddressInput', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.address.saveFailed);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
handleAddressCommand,
|
||||
handleLightningAddressInput,
|
||||
};
|
||||
426
telegram_bot/src/handlers/buy.ts
Normal file
426
telegram_bot/src/handlers/buy.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { stateManager } from '../services/state';
|
||||
import { apiClient } from '../services/api';
|
||||
import { generateQRCode } from '../services/qr';
|
||||
import { logger, logUserAction, logPaymentEvent } from '../services/logger';
|
||||
import config from '../config';
|
||||
import {
|
||||
getTicketAmountKeyboard,
|
||||
getConfirmationKeyboard,
|
||||
getViewTicketKeyboard,
|
||||
getMainMenuKeyboard,
|
||||
getCancelKeyboard,
|
||||
} from '../utils/keyboards';
|
||||
import { formatSats, formatDate, formatTimeUntil } from '../utils/format';
|
||||
import { PendingPurchaseData, AwaitingPaymentData } from '../types';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
* Handle /buy command or "Buy Tickets" button
|
||||
*/
|
||||
export async function handleBuyCommand(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
|
||||
if (!userId) {
|
||||
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Initiated ticket purchase');
|
||||
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
|
||||
if (!user) {
|
||||
await bot.sendMessage(chatId, messages.errors.startFirst);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if lightning address is set
|
||||
if (!user.lightningAddress) {
|
||||
await bot.sendMessage(chatId, messages.address.needAddressFirst, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
});
|
||||
await stateManager.updateUserState(userId, 'awaiting_lightning_address');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get next jackpot info
|
||||
const jackpot = await apiClient.getNextJackpot();
|
||||
|
||||
if (!jackpot) {
|
||||
await bot.sendMessage(chatId, messages.buy.noActiveJackpot, { parse_mode: 'Markdown' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Show jackpot info and ticket selection
|
||||
const drawTime = new Date(jackpot.cycle.scheduled_at);
|
||||
const message = messages.buy.jackpotInfo(
|
||||
formatSats(jackpot.cycle.pot_total_sats),
|
||||
formatSats(jackpot.lottery.ticket_price_sats),
|
||||
formatDate(drawTime),
|
||||
formatTimeUntil(drawTime)
|
||||
);
|
||||
|
||||
await bot.sendMessage(chatId, message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getTicketAmountKeyboard(),
|
||||
});
|
||||
|
||||
// Store jackpot info in state for later use
|
||||
await stateManager.updateUserState(userId, 'idle', {
|
||||
cycleId: jackpot.cycle.id,
|
||||
scheduledAt: jackpot.cycle.scheduled_at,
|
||||
ticketPrice: jackpot.lottery.ticket_price_sats,
|
||||
lotteryName: jackpot.lottery.name,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in handleBuyCommand', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.systemUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle ticket amount selection
|
||||
*/
|
||||
export async function handleTicketAmountSelection(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery,
|
||||
amount: number | 'custom'
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
const userId = query.from.id;
|
||||
const messageId = query.message?.message_id;
|
||||
|
||||
if (!chatId || !messageId) return;
|
||||
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
if (!user) {
|
||||
await bot.sendMessage(chatId, messages.errors.startFirst);
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount === 'custom') {
|
||||
// Ask for custom amount
|
||||
await bot.editMessageText(
|
||||
messages.buy.customAmountPrompt(config.bot.maxTicketsPerPurchase),
|
||||
{
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
}
|
||||
);
|
||||
|
||||
// Get fresh jackpot info
|
||||
const jackpot = await apiClient.getNextJackpot();
|
||||
if (!jackpot) {
|
||||
await bot.sendMessage(chatId, messages.buy.jackpotUnavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
await stateManager.updateUserState(userId, 'awaiting_ticket_amount', {
|
||||
cycleId: jackpot.cycle.id,
|
||||
scheduledAt: jackpot.cycle.scheduled_at,
|
||||
ticketPrice: jackpot.lottery.ticket_price_sats,
|
||||
lotteryName: jackpot.lottery.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process selected amount
|
||||
await processTicketSelection(bot, chatId, messageId, userId, amount);
|
||||
} catch (error) {
|
||||
logger.error('Error in handleTicketAmountSelection', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.generic);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle custom ticket amount input
|
||||
*/
|
||||
export async function handleCustomTicketAmount(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<boolean> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
const text = msg.text?.trim();
|
||||
|
||||
if (!userId || !text) return false;
|
||||
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
if (!user || user.state !== 'awaiting_ticket_amount') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const amount = parseInt(text, 10);
|
||||
|
||||
if (isNaN(amount) || amount < 1) {
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
messages.buy.invalidNumber(config.bot.maxTicketsPerPurchase),
|
||||
{ reply_markup: getCancelKeyboard() }
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (amount > config.bot.maxTicketsPerPurchase) {
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
messages.buy.tooManyTickets(config.bot.maxTicketsPerPurchase),
|
||||
{ reply_markup: getCancelKeyboard() }
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
await processTicketSelection(bot, chatId, undefined, userId, amount);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Error in handleCustomTicketAmount', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.generic);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process ticket selection and show confirmation
|
||||
*/
|
||||
async function processTicketSelection(
|
||||
bot: TelegramBot,
|
||||
chatId: number,
|
||||
messageId: number | undefined,
|
||||
userId: number,
|
||||
amount: number
|
||||
): Promise<void> {
|
||||
// Get fresh jackpot info
|
||||
const jackpot = await apiClient.getNextJackpot();
|
||||
if (!jackpot) {
|
||||
await bot.sendMessage(chatId, messages.buy.jackpotUnavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalAmount = amount * jackpot.lottery.ticket_price_sats;
|
||||
|
||||
const confirmMessage = messages.buy.confirmPurchase(
|
||||
amount,
|
||||
formatSats(jackpot.lottery.ticket_price_sats),
|
||||
formatSats(totalAmount),
|
||||
formatDate(jackpot.cycle.scheduled_at)
|
||||
);
|
||||
|
||||
const stateData: PendingPurchaseData = {
|
||||
ticketCount: amount,
|
||||
cycleId: jackpot.cycle.id,
|
||||
scheduledAt: jackpot.cycle.scheduled_at,
|
||||
ticketPrice: jackpot.lottery.ticket_price_sats,
|
||||
totalAmount,
|
||||
lotteryName: jackpot.lottery.name,
|
||||
};
|
||||
|
||||
await stateManager.updateUserState(userId, 'idle', stateData);
|
||||
|
||||
if (messageId) {
|
||||
await bot.editMessageText(confirmMessage, {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getConfirmationKeyboard(),
|
||||
});
|
||||
} else {
|
||||
await bot.sendMessage(chatId, confirmMessage, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getConfirmationKeyboard(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle purchase confirmation
|
||||
*/
|
||||
export async function handlePurchaseConfirmation(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
const userId = query.from.id;
|
||||
const messageId = query.message?.message_id;
|
||||
|
||||
if (!chatId || !messageId) return;
|
||||
|
||||
await bot.answerCallbackQuery(query.id, { text: messages.buy.creatingInvoice });
|
||||
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
if (!user || !user.lightningAddress) {
|
||||
await bot.sendMessage(chatId, messages.errors.setAddressFirst);
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingData = user.stateData as PendingPurchaseData | undefined;
|
||||
if (!pendingData?.ticketCount) {
|
||||
await bot.sendMessage(chatId, messages.errors.noPendingPurchase);
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Confirmed purchase', { tickets: pendingData.ticketCount });
|
||||
|
||||
// Create invoice
|
||||
const purchaseResult = await apiClient.buyTickets(
|
||||
pendingData.ticketCount,
|
||||
user.lightningAddress,
|
||||
userId
|
||||
);
|
||||
|
||||
logPaymentEvent(userId, purchaseResult.ticket_purchase_id, 'created', {
|
||||
tickets: pendingData.ticketCount,
|
||||
amount: pendingData.totalAmount,
|
||||
});
|
||||
|
||||
// Generate QR code
|
||||
const qrBuffer = await generateQRCode(purchaseResult.invoice.payment_request);
|
||||
|
||||
// Update message to show invoice
|
||||
await bot.editMessageText(messages.buy.invoiceCreated, {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
});
|
||||
|
||||
// Send QR code
|
||||
await bot.sendPhoto(chatId, qrBuffer, {
|
||||
caption: messages.buy.invoiceCaption(
|
||||
pendingData.ticketCount,
|
||||
formatSats(pendingData.totalAmount),
|
||||
purchaseResult.invoice.payment_request,
|
||||
config.bot.invoiceExpiryMinutes
|
||||
),
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getViewTicketKeyboard(
|
||||
purchaseResult.ticket_purchase_id,
|
||||
purchaseResult.public_url
|
||||
),
|
||||
});
|
||||
|
||||
// Store purchase and start polling
|
||||
const paymentData: AwaitingPaymentData = {
|
||||
...pendingData,
|
||||
purchaseId: purchaseResult.ticket_purchase_id,
|
||||
paymentRequest: purchaseResult.invoice.payment_request,
|
||||
publicUrl: purchaseResult.public_url,
|
||||
pollStartTime: Date.now(),
|
||||
};
|
||||
|
||||
await stateManager.storePurchase(userId, purchaseResult.ticket_purchase_id, paymentData);
|
||||
await stateManager.updateUserState(userId, 'awaiting_invoice_payment', paymentData);
|
||||
|
||||
// Start payment polling
|
||||
pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id);
|
||||
} catch (error) {
|
||||
logger.error('Error in handlePurchaseConfirmation', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.invoiceCreationFailed, {
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
await stateManager.clearUserStateData(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll payment status
|
||||
*/
|
||||
async function pollPaymentStatus(
|
||||
bot: TelegramBot,
|
||||
chatId: number,
|
||||
userId: number,
|
||||
purchaseId: string
|
||||
): Promise<void> {
|
||||
const pollInterval = config.bot.paymentPollIntervalMs;
|
||||
const timeout = config.bot.paymentPollTimeoutMs;
|
||||
const startTime = Date.now();
|
||||
|
||||
logPaymentEvent(userId, purchaseId, 'polling');
|
||||
|
||||
const checkPayment = async (): Promise<void> => {
|
||||
try {
|
||||
// Check if we've timed out
|
||||
if (Date.now() - startTime > timeout) {
|
||||
logPaymentEvent(userId, purchaseId, 'expired');
|
||||
await bot.sendMessage(chatId, messages.buy.invoiceExpired, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
await stateManager.clearUserStateData(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await apiClient.getTicketStatus(purchaseId);
|
||||
|
||||
if (!status) {
|
||||
// Purchase not found, stop polling
|
||||
logger.warn('Purchase not found during polling', { purchaseId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.purchase.invoice_status === 'paid') {
|
||||
logPaymentEvent(userId, purchaseId, 'confirmed', {
|
||||
tickets: status.tickets.length,
|
||||
});
|
||||
|
||||
// Payment received!
|
||||
const ticketNumbers = status.tickets
|
||||
.map((t) => `#${t.serial_number.toString().padStart(4, '0')}`)
|
||||
.join('\n');
|
||||
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
messages.buy.paymentReceived(ticketNumbers, formatDate(status.cycle.scheduled_at)),
|
||||
{
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getViewTicketKeyboard(
|
||||
purchaseId,
|
||||
config.frontend.baseUrl + '/tickets/' + purchaseId
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
await stateManager.clearUserStateData(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.purchase.invoice_status === 'expired') {
|
||||
logPaymentEvent(userId, purchaseId, 'expired');
|
||||
await bot.sendMessage(chatId, messages.buy.invoiceExpiredShort, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
await stateManager.clearUserStateData(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Still pending, continue polling
|
||||
setTimeout(checkPayment, pollInterval);
|
||||
} catch (error) {
|
||||
logger.error('Error polling payment status', { error, purchaseId });
|
||||
// Continue polling despite errors
|
||||
setTimeout(checkPayment, pollInterval);
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling after initial delay
|
||||
setTimeout(checkPayment, pollInterval);
|
||||
}
|
||||
|
||||
export default {
|
||||
handleBuyCommand,
|
||||
handleTicketAmountSelection,
|
||||
handleCustomTicketAmount,
|
||||
handlePurchaseConfirmation,
|
||||
};
|
||||
341
telegram_bot/src/handlers/groups.ts
Normal file
341
telegram_bot/src/handlers/groups.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { groupStateManager } from '../services/groupState';
|
||||
import { logger, logUserAction } from '../services/logger';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
* Check if a user is an admin in a group
|
||||
*/
|
||||
async function isGroupAdmin(
|
||||
bot: TelegramBot,
|
||||
chatId: number,
|
||||
userId: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const member = await bot.getChatMember(chatId, userId);
|
||||
return ['creator', 'administrator'].includes(member.status);
|
||||
} catch (error) {
|
||||
logger.error('Failed to check admin status', { error, chatId, userId });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle bot being added to a group
|
||||
*/
|
||||
export async function handleBotAddedToGroup(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const chatTitle = msg.chat.title || 'Unknown Group';
|
||||
const addedBy = msg.from?.id || 0;
|
||||
|
||||
logger.info('Bot added to group', { chatId, chatTitle, addedBy });
|
||||
|
||||
try {
|
||||
const settings = await groupStateManager.registerGroup(chatId, chatTitle, addedBy);
|
||||
|
||||
await bot.sendMessage(chatId, messages.groups.welcome(chatTitle), {
|
||||
parse_mode: 'Markdown',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to register group', { error, chatId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle bot being removed from a group
|
||||
*/
|
||||
export async function handleBotRemovedFromGroup(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
logger.info('Bot removed from group', { chatId });
|
||||
|
||||
try {
|
||||
await groupStateManager.removeGroup(chatId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove group', { error, chatId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /settings command (group admin only)
|
||||
*/
|
||||
export async function handleGroupSettings(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
|
||||
// Only works in groups
|
||||
if (msg.chat.type === 'private') {
|
||||
await bot.sendMessage(chatId, messages.groups.privateChat);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isGroupAdmin(bot, chatId, userId);
|
||||
if (!isAdmin) {
|
||||
await bot.sendMessage(chatId, messages.groups.adminOnly);
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Viewed group settings', { groupId: chatId });
|
||||
|
||||
try {
|
||||
const settings = await groupStateManager.getGroup(chatId);
|
||||
|
||||
if (!settings) {
|
||||
// Register group if not found
|
||||
await groupStateManager.registerGroup(
|
||||
chatId,
|
||||
msg.chat.title || 'Group',
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
const currentSettings = settings || await groupStateManager.getGroup(chatId);
|
||||
if (!currentSettings) {
|
||||
await bot.sendMessage(chatId, messages.errors.generic);
|
||||
return;
|
||||
}
|
||||
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
messages.groups.settingsOverview(currentSettings),
|
||||
{
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getGroupSettingsKeyboard(currentSettings),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error in handleGroupSettings', { error, chatId });
|
||||
await bot.sendMessage(chatId, messages.errors.generic);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle group settings toggle callback
|
||||
*/
|
||||
export async function handleGroupSettingsCallback(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery,
|
||||
action: string
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
const userId = query.from.id;
|
||||
const messageId = query.message?.message_id;
|
||||
|
||||
if (!chatId || !messageId) return;
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = await isGroupAdmin(bot, chatId, userId);
|
||||
if (!isAdmin) {
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: messages.groups.adminOnly,
|
||||
show_alert: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed';
|
||||
|
||||
switch (action) {
|
||||
case 'toggle_enabled':
|
||||
setting = 'enabled';
|
||||
break;
|
||||
case 'toggle_announcements':
|
||||
setting = 'drawAnnouncements';
|
||||
break;
|
||||
case 'toggle_reminders':
|
||||
setting = 'reminders';
|
||||
break;
|
||||
case 'toggle_purchases':
|
||||
setting = 'ticketPurchaseAllowed';
|
||||
break;
|
||||
default:
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSettings = await groupStateManager.getGroup(chatId);
|
||||
if (!currentSettings) {
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Group not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = !currentSettings[setting];
|
||||
const updatedSettings = await groupStateManager.updateSetting(chatId, setting, newValue);
|
||||
|
||||
if (!updatedSettings) {
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Failed to update' });
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Updated group setting', {
|
||||
groupId: chatId,
|
||||
setting,
|
||||
newValue,
|
||||
});
|
||||
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: `${setting} ${newValue ? 'enabled' : 'disabled'}`,
|
||||
});
|
||||
|
||||
// Update the message with new settings
|
||||
await bot.editMessageText(
|
||||
messages.groups.settingsOverview(updatedSettings),
|
||||
{
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getGroupSettingsKeyboard(updatedSettings),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error in handleGroupSettingsCallback', { error, chatId, action });
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Error updating settings' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate inline keyboard for group settings
|
||||
*/
|
||||
function getGroupSettingsKeyboard(settings: {
|
||||
enabled: boolean;
|
||||
drawAnnouncements: boolean;
|
||||
reminders: boolean;
|
||||
ticketPurchaseAllowed: boolean;
|
||||
}): TelegramBot.InlineKeyboardMarkup {
|
||||
const onOff = (val: boolean) => val ? '✅' : '❌';
|
||||
|
||||
return {
|
||||
inline_keyboard: [
|
||||
[{
|
||||
text: `${onOff(settings.enabled)} Bot Enabled`,
|
||||
callback_data: 'group_toggle_enabled',
|
||||
}],
|
||||
[{
|
||||
text: `${onOff(settings.drawAnnouncements)} Draw Announcements`,
|
||||
callback_data: 'group_toggle_announcements',
|
||||
}],
|
||||
[{
|
||||
text: `${onOff(settings.reminders)} Draw Reminders`,
|
||||
callback_data: 'group_toggle_reminders',
|
||||
}],
|
||||
[{
|
||||
text: `${onOff(settings.ticketPurchaseAllowed)} Allow Ticket Purchases`,
|
||||
callback_data: 'group_toggle_purchases',
|
||||
}],
|
||||
[{
|
||||
text: '🔄 Refresh',
|
||||
callback_data: 'group_refresh',
|
||||
}],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle refresh callback
|
||||
*/
|
||||
export async function handleGroupRefresh(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
const messageId = query.message?.message_id;
|
||||
|
||||
if (!chatId || !messageId) return;
|
||||
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Refreshed!' });
|
||||
|
||||
const settings = await groupStateManager.getGroup(chatId);
|
||||
if (!settings) return;
|
||||
|
||||
await bot.editMessageText(
|
||||
messages.groups.settingsOverview(settings),
|
||||
{
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getGroupSettingsKeyboard(settings),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send draw announcement to all enabled groups
|
||||
*/
|
||||
export async function broadcastDrawAnnouncement(
|
||||
bot: TelegramBot,
|
||||
announcement: string
|
||||
): Promise<number> {
|
||||
const groups = await groupStateManager.getGroupsWithFeature('drawAnnouncements');
|
||||
let sent = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await bot.sendMessage(group.groupId, announcement, { parse_mode: 'Markdown' });
|
||||
sent++;
|
||||
} catch (error) {
|
||||
logger.error('Failed to send announcement to group', {
|
||||
groupId: group.groupId,
|
||||
error,
|
||||
});
|
||||
// If bot was removed from group, clean up
|
||||
if ((error as any)?.response?.statusCode === 403) {
|
||||
await groupStateManager.removeGroup(group.groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Broadcast draw announcement', { sent, total: groups.length });
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send draw reminder to all enabled groups
|
||||
*/
|
||||
export async function broadcastDrawReminder(
|
||||
bot: TelegramBot,
|
||||
reminder: string
|
||||
): Promise<number> {
|
||||
const groups = await groupStateManager.getGroupsWithFeature('reminders');
|
||||
let sent = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await bot.sendMessage(group.groupId, reminder, { parse_mode: 'Markdown' });
|
||||
sent++;
|
||||
} catch (error) {
|
||||
logger.error('Failed to send reminder to group', {
|
||||
groupId: group.groupId,
|
||||
error,
|
||||
});
|
||||
if ((error as any)?.response?.statusCode === 403) {
|
||||
await groupStateManager.removeGroup(group.groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Broadcast draw reminder', { sent, total: groups.length });
|
||||
return sent;
|
||||
}
|
||||
|
||||
export default {
|
||||
handleBotAddedToGroup,
|
||||
handleBotRemovedFromGroup,
|
||||
handleGroupSettings,
|
||||
handleGroupSettingsCallback,
|
||||
handleGroupRefresh,
|
||||
broadcastDrawAnnouncement,
|
||||
broadcastDrawReminder,
|
||||
};
|
||||
|
||||
26
telegram_bot/src/handlers/help.ts
Normal file
26
telegram_bot/src/handlers/help.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { logUserAction } from '../services/logger';
|
||||
import { getMainMenuKeyboard } from '../utils/keyboards';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
* Handle /help command
|
||||
*/
|
||||
export async function handleHelpCommand(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
|
||||
if (userId) {
|
||||
logUserAction(userId, 'Viewed help');
|
||||
}
|
||||
|
||||
await bot.sendMessage(chatId, messages.help.message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
|
||||
export default handleHelpCommand;
|
||||
33
telegram_bot/src/handlers/index.ts
Normal file
33
telegram_bot/src/handlers/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export { handleStart } from './start';
|
||||
export {
|
||||
handleAddressCommand,
|
||||
handleLightningAddressInput,
|
||||
} from './address';
|
||||
export {
|
||||
handleBuyCommand,
|
||||
handleTicketAmountSelection,
|
||||
handleCustomTicketAmount,
|
||||
handlePurchaseConfirmation,
|
||||
} from './buy';
|
||||
export {
|
||||
handleTicketsCommand,
|
||||
handleViewTicket,
|
||||
handleStatusCheck,
|
||||
} from './tickets';
|
||||
export { handleWinsCommand } from './wins';
|
||||
export { handleHelpCommand } from './help';
|
||||
export {
|
||||
handleMenuCommand,
|
||||
handleCancel,
|
||||
handleMenuCallback,
|
||||
} from './menu';
|
||||
export {
|
||||
handleBotAddedToGroup,
|
||||
handleBotRemovedFromGroup,
|
||||
handleGroupSettings,
|
||||
handleGroupSettingsCallback,
|
||||
handleGroupRefresh,
|
||||
broadcastDrawAnnouncement,
|
||||
broadcastDrawReminder,
|
||||
} from './groups';
|
||||
|
||||
92
telegram_bot/src/handlers/menu.ts
Normal file
92
telegram_bot/src/handlers/menu.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { stateManager } from '../services/state';
|
||||
import { logUserAction } from '../services/logger';
|
||||
import { getMainMenuKeyboard } from '../utils/keyboards';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
* Handle /menu command
|
||||
*/
|
||||
export async function handleMenuCommand(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
|
||||
if (userId) {
|
||||
logUserAction(userId, 'Opened menu');
|
||||
|
||||
// Reset user state to idle
|
||||
const user = await stateManager.getUser(userId);
|
||||
if (user && user.lightningAddress) {
|
||||
await stateManager.updateUserState(userId, 'idle');
|
||||
}
|
||||
}
|
||||
|
||||
await bot.sendMessage(chatId, messages.menu.header, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cancel callback
|
||||
*/
|
||||
export async function handleCancel(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
const userId = query.from.id;
|
||||
const messageId = query.message?.message_id;
|
||||
|
||||
if (!chatId) return;
|
||||
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Cancelled' });
|
||||
|
||||
// Clear user state
|
||||
await stateManager.clearUserStateData(userId);
|
||||
|
||||
// Update message
|
||||
if (messageId) {
|
||||
await bot.editMessageText(messages.menu.cancelled, {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
|
||||
// Show menu
|
||||
await bot.sendMessage(chatId, messages.menu.whatToDo, {
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle menu callback (back to menu)
|
||||
*/
|
||||
export async function handleMenuCallback(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
const userId = query.from.id;
|
||||
|
||||
if (!chatId) return;
|
||||
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
|
||||
// Clear user state
|
||||
await stateManager.clearUserStateData(userId);
|
||||
|
||||
await bot.sendMessage(chatId, messages.menu.header, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
handleMenuCommand,
|
||||
handleCancel,
|
||||
handleMenuCallback,
|
||||
};
|
||||
68
telegram_bot/src/handlers/start.ts
Normal file
68
telegram_bot/src/handlers/start.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { stateManager } from '../services/state';
|
||||
import { logger, logUserAction } from '../services/logger';
|
||||
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
* Handle /start command
|
||||
*/
|
||||
export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
|
||||
if (!userId) {
|
||||
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Started bot', {
|
||||
username: msg.from?.username,
|
||||
firstName: msg.from?.first_name,
|
||||
});
|
||||
|
||||
try {
|
||||
// Check if user exists
|
||||
let user = await stateManager.getUser(userId);
|
||||
|
||||
if (!user) {
|
||||
// Create new user
|
||||
user = await stateManager.createUser(
|
||||
userId,
|
||||
msg.from?.username,
|
||||
msg.from?.first_name,
|
||||
msg.from?.last_name
|
||||
);
|
||||
}
|
||||
|
||||
// Welcome message
|
||||
await bot.sendMessage(chatId, messages.start.welcome, { parse_mode: 'Markdown' });
|
||||
|
||||
// Check if lightning address is set
|
||||
if (!user.lightningAddress) {
|
||||
await bot.sendMessage(chatId, messages.start.needAddress, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
});
|
||||
|
||||
await stateManager.updateUserState(userId, 'awaiting_lightning_address');
|
||||
} else {
|
||||
// Show main menu
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
messages.start.addressSet(user.lightningAddress),
|
||||
{
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
}
|
||||
);
|
||||
|
||||
await stateManager.updateUserState(userId, 'idle');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in handleStart', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.startAgain);
|
||||
}
|
||||
}
|
||||
|
||||
export default handleStart;
|
||||
259
telegram_bot/src/handlers/tickets.ts
Normal file
259
telegram_bot/src/handlers/tickets.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { stateManager } from '../services/state';
|
||||
import { apiClient } from '../services/api';
|
||||
import { logger, logUserAction } from '../services/logger';
|
||||
import config from '../config';
|
||||
import { getMainMenuKeyboard, getViewTicketKeyboard } from '../utils/keyboards';
|
||||
import { formatSats, formatDate } from '../utils/format';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
* Handle /tickets command or "My Tickets" button
|
||||
*/
|
||||
export async function handleTicketsCommand(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
|
||||
if (!userId) {
|
||||
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Viewed tickets');
|
||||
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
|
||||
if (!user) {
|
||||
await bot.sendMessage(chatId, messages.errors.startFirst);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user's purchase IDs from state
|
||||
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 10);
|
||||
|
||||
if (purchaseIds.length === 0) {
|
||||
await bot.sendMessage(chatId, messages.tickets.empty, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch status for each purchase
|
||||
const purchases: Array<{
|
||||
id: string;
|
||||
ticketCount: number;
|
||||
scheduledAt: string;
|
||||
invoiceStatus: string;
|
||||
isWinner: boolean;
|
||||
hasDrawn: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const purchaseId of purchaseIds) {
|
||||
try {
|
||||
const status = await apiClient.getTicketStatus(purchaseId);
|
||||
if (status) {
|
||||
purchases.push({
|
||||
id: status.purchase.id,
|
||||
ticketCount: status.purchase.number_of_tickets,
|
||||
scheduledAt: status.cycle.scheduled_at,
|
||||
invoiceStatus: status.purchase.invoice_status,
|
||||
isWinner: status.result.is_winner,
|
||||
hasDrawn: status.result.has_drawn,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip failed fetches
|
||||
logger.debug('Failed to fetch purchase', { purchaseId });
|
||||
}
|
||||
}
|
||||
|
||||
if (purchases.length === 0) {
|
||||
await bot.sendMessage(chatId, messages.tickets.notFound, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Format purchases list
|
||||
let message = messages.tickets.header;
|
||||
|
||||
for (let i = 0; i < purchases.length; i++) {
|
||||
const p = purchases[i];
|
||||
const drawDate = new Date(p.scheduledAt);
|
||||
|
||||
let statusEmoji: string;
|
||||
let statusText: string;
|
||||
|
||||
if (p.invoiceStatus === 'pending') {
|
||||
statusEmoji = '⏳';
|
||||
statusText = messages.tickets.statusPending;
|
||||
} else if (p.invoiceStatus === 'expired') {
|
||||
statusEmoji = '❌';
|
||||
statusText = messages.tickets.statusExpired;
|
||||
} else if (!p.hasDrawn) {
|
||||
statusEmoji = '🎟';
|
||||
statusText = messages.tickets.statusActive;
|
||||
} else if (p.isWinner) {
|
||||
statusEmoji = '🏆';
|
||||
statusText = messages.tickets.statusWon;
|
||||
} else {
|
||||
statusEmoji = '😔';
|
||||
statusText = messages.tickets.statusLost;
|
||||
}
|
||||
|
||||
message += `${i + 1}. ${statusEmoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} – ${formatDate(drawDate)} – ${statusText}\n`;
|
||||
}
|
||||
|
||||
message += messages.tickets.tapForDetails;
|
||||
|
||||
// Create inline buttons for each purchase
|
||||
const inlineKeyboard = purchases.map((p, i) => [{
|
||||
text: `${i + 1}. View Ticket #${p.id.substring(0, 8)}...`,
|
||||
callback_data: `view_ticket_${p.id}`,
|
||||
}]);
|
||||
|
||||
await bot.sendMessage(chatId, message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: { inline_keyboard: inlineKeyboard },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in handleTicketsCommand', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.fetchTicketsFailed, {
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle viewing a specific ticket
|
||||
*/
|
||||
export async function handleViewTicket(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery,
|
||||
purchaseId: string
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
|
||||
if (!chatId) return;
|
||||
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
|
||||
try {
|
||||
const status = await apiClient.getTicketStatus(purchaseId);
|
||||
|
||||
if (!status) {
|
||||
await bot.sendMessage(chatId, messages.errors.ticketNotFound);
|
||||
return;
|
||||
}
|
||||
|
||||
const drawDate = new Date(status.cycle.scheduled_at);
|
||||
const ticketNumbers = status.tickets
|
||||
.map((t) => {
|
||||
const winnerMark = t.is_winning_ticket ? ' 🏆' : '';
|
||||
return `#${t.serial_number.toString().padStart(4, '0')}${winnerMark}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
let statusSection: string;
|
||||
|
||||
if (status.purchase.invoice_status === 'pending') {
|
||||
statusSection = messages.tickets.detailAwaitingPayment;
|
||||
} else if (status.purchase.invoice_status === 'expired') {
|
||||
statusSection = messages.tickets.detailExpired;
|
||||
} else if (!status.result.has_drawn) {
|
||||
statusSection = messages.tickets.detailActive;
|
||||
} else if (status.result.is_winner) {
|
||||
const payoutStatus = status.result.payout?.status === 'paid' ? 'Paid ✅' : 'Pending';
|
||||
statusSection = messages.tickets.detailWinner(
|
||||
formatSats(status.result.payout?.amount_sats || 0),
|
||||
payoutStatus
|
||||
);
|
||||
} else {
|
||||
statusSection = messages.tickets.detailLost(
|
||||
status.cycle.winning_ticket_id?.substring(0, 8) || 'N/A'
|
||||
);
|
||||
}
|
||||
|
||||
const message = messages.tickets.detailFormat(
|
||||
status.purchase.id.substring(0, 8),
|
||||
status.purchase.number_of_tickets,
|
||||
ticketNumbers,
|
||||
formatSats(status.purchase.amount_sats),
|
||||
formatDate(drawDate),
|
||||
statusSection
|
||||
);
|
||||
|
||||
await bot.sendMessage(chatId, message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getViewTicketKeyboard(
|
||||
purchaseId,
|
||||
config.frontend.baseUrl + '/tickets/' + purchaseId
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in handleViewTicket', { error, purchaseId });
|
||||
await bot.sendMessage(chatId, messages.errors.fetchTicketDetailsFailed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle status check callback
|
||||
*/
|
||||
export async function handleStatusCheck(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery,
|
||||
purchaseId: string
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
|
||||
if (!chatId) return;
|
||||
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Checking status...' });
|
||||
|
||||
try {
|
||||
const status = await apiClient.getTicketStatus(purchaseId);
|
||||
|
||||
if (!status) {
|
||||
await bot.sendMessage(chatId, messages.errors.ticketNotFound);
|
||||
return;
|
||||
}
|
||||
|
||||
let statusMessage: string;
|
||||
|
||||
if (status.purchase.invoice_status === 'pending') {
|
||||
statusMessage = messages.tickets.checkStatusPending;
|
||||
} else if (status.purchase.invoice_status === 'paid' && status.purchase.ticket_issue_status === 'issued') {
|
||||
if (!status.result.has_drawn) {
|
||||
statusMessage = messages.tickets.checkStatusConfirmed;
|
||||
} else if (status.result.is_winner) {
|
||||
statusMessage = messages.tickets.checkStatusWon;
|
||||
} else {
|
||||
statusMessage = messages.tickets.checkStatusLost;
|
||||
}
|
||||
} else if (status.purchase.invoice_status === 'expired') {
|
||||
statusMessage = messages.tickets.checkStatusExpired;
|
||||
} else {
|
||||
statusMessage = messages.tickets.checkStatusProcessing;
|
||||
}
|
||||
|
||||
await bot.answerCallbackQuery(query.id, { text: statusMessage, show_alert: true });
|
||||
} catch (error) {
|
||||
logger.error('Error in handleStatusCheck', { error, purchaseId });
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: messages.errors.checkStatusFailed,
|
||||
show_alert: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
handleTicketsCommand,
|
||||
handleViewTicket,
|
||||
handleStatusCheck,
|
||||
};
|
||||
114
telegram_bot/src/handlers/wins.ts
Normal file
114
telegram_bot/src/handlers/wins.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { stateManager } from '../services/state';
|
||||
import { apiClient } from '../services/api';
|
||||
import { logger, logUserAction } from '../services/logger';
|
||||
import { getMainMenuKeyboard } from '../utils/keyboards';
|
||||
import { formatSats, formatDate } from '../utils/format';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
* Handle /wins command or "My Wins" button
|
||||
*/
|
||||
export async function handleWinsCommand(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
|
||||
if (!userId) {
|
||||
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Viewed wins');
|
||||
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
|
||||
if (!user) {
|
||||
await bot.sendMessage(chatId, messages.errors.startFirst);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user's purchase IDs and check for wins
|
||||
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 50);
|
||||
|
||||
if (purchaseIds.length === 0) {
|
||||
await bot.sendMessage(chatId, messages.wins.empty, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check each purchase for wins
|
||||
const wins: Array<{
|
||||
purchaseId: string;
|
||||
ticketId: string;
|
||||
serialNumber: number;
|
||||
amountSats: number;
|
||||
status: string;
|
||||
scheduledAt: string;
|
||||
}> = [];
|
||||
|
||||
for (const purchaseId of purchaseIds) {
|
||||
try {
|
||||
const status = await apiClient.getTicketStatus(purchaseId);
|
||||
if (status && status.result.is_winner && status.result.payout) {
|
||||
const winningTicket = status.tickets.find(t => t.is_winning_ticket);
|
||||
wins.push({
|
||||
purchaseId: status.purchase.id,
|
||||
ticketId: winningTicket?.id || '',
|
||||
serialNumber: winningTicket?.serial_number || 0,
|
||||
amountSats: status.result.payout.amount_sats,
|
||||
status: status.result.payout.status,
|
||||
scheduledAt: status.cycle.scheduled_at,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip failed fetches
|
||||
logger.debug('Failed to fetch purchase for wins', { purchaseId });
|
||||
}
|
||||
}
|
||||
|
||||
if (wins.length === 0) {
|
||||
await bot.sendMessage(chatId, messages.wins.noWinsYet, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const totalWinnings = wins.reduce((sum, w) => sum + w.amountSats, 0);
|
||||
const paidWinnings = wins.filter(w => w.status === 'paid').reduce((sum, w) => sum + w.amountSats, 0);
|
||||
|
||||
let message = messages.wins.header(formatSats(totalWinnings), formatSats(paidWinnings));
|
||||
|
||||
for (const win of wins) {
|
||||
const statusEmoji = win.status === 'paid' ? '✅' : '⏳';
|
||||
message += `• ${formatSats(win.amountSats)} sats — ${formatDate(win.scheduledAt)} — ${statusEmoji} ${win.status}\n`;
|
||||
}
|
||||
|
||||
// Create buttons for viewing wins
|
||||
const inlineKeyboard = wins.slice(0, 5).map((w) => [{
|
||||
text: `View Ticket #${w.serialNumber.toString().padStart(4, '0')}`,
|
||||
callback_data: `view_ticket_${w.purchaseId}`,
|
||||
}]);
|
||||
|
||||
await bot.sendMessage(chatId, message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: { inline_keyboard: inlineKeyboard },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in handleWinsCommand', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.fetchWinsFailed, {
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
handleWinsCommand,
|
||||
};
|
||||
Reference in New Issue
Block a user