Add SQLite database for Telegram bot user/group settings
- Replace Redis/in-memory storage with SQLite for persistence - Add database.ts service with tables for users, groups, purchases, participants - Update state.ts and groupState.ts to use SQLite backend - Fix buyer_name to use display name instead of Telegram ID - Remove legacy reminder array handlers (now using 3-slot system) - Add better-sqlite3 dependency, remove ioredis - Update env.example with BOT_DATABASE_PATH option - Add data/ directory to .gitignore for database files
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
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 { getMainMenuKeyboard, getLightningAddressKeyboard, getCancelKeyboard } from '../utils/keyboards';
|
||||
import { isValidLightningAddress, verifyLightningAddress } from '../utils/format';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
@@ -14,6 +14,7 @@ export async function handleAddressCommand(
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
const username = msg.from?.username;
|
||||
|
||||
if (!userId) {
|
||||
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
|
||||
@@ -31,12 +32,12 @@ export async function handleAddressCommand(
|
||||
}
|
||||
|
||||
const message = user.lightningAddress
|
||||
? messages.address.currentAddress(user.lightningAddress)
|
||||
: messages.address.noAddressSet;
|
||||
? messages.address.currentAddressWithOptions(user.lightningAddress, username)
|
||||
: messages.address.noAddressSetWithOptions(username);
|
||||
|
||||
await bot.sendMessage(chatId, message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
reply_markup: getLightningAddressKeyboard(username),
|
||||
});
|
||||
|
||||
await stateManager.updateUserState(userId, 'updating_address');
|
||||
@@ -55,6 +56,7 @@ export async function handleLightningAddressInput(
|
||||
): Promise<boolean> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
const username = msg.from?.username;
|
||||
const text = msg.text?.trim();
|
||||
|
||||
if (!userId || !text) return false;
|
||||
@@ -73,7 +75,19 @@ export async function handleLightningAddressInput(
|
||||
if (!isValidLightningAddress(text)) {
|
||||
await bot.sendMessage(chatId, messages.address.invalidFormat, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
reply_markup: getLightningAddressKeyboard(username),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Verify the lightning address actually works
|
||||
await bot.sendMessage(chatId, messages.address.verifying);
|
||||
const verification = await verifyLightningAddress(text);
|
||||
|
||||
if (!verification.valid) {
|
||||
await bot.sendMessage(chatId, messages.address.verificationFailed(text, verification.error), {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getLightningAddressKeyboard(username),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -81,7 +95,7 @@ export async function handleLightningAddressInput(
|
||||
// Save the lightning address
|
||||
await stateManager.updateLightningAddress(userId, text);
|
||||
|
||||
logUserAction(userId, 'Lightning address updated');
|
||||
logUserAction(userId, 'Lightning address updated', { address: text });
|
||||
|
||||
const responseMessage = user.state === 'awaiting_lightning_address'
|
||||
? messages.address.firstTimeSuccess(text)
|
||||
@@ -100,7 +114,85 @@ export async function handleLightningAddressInput(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle lightning address selection callback (21Tipbot/Bittip)
|
||||
*/
|
||||
export async function handleLightningAddressCallback(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery,
|
||||
action: string
|
||||
): Promise<void> {
|
||||
const chatId = query.message?.chat.id;
|
||||
const userId = query.from.id;
|
||||
const username = query.from.username;
|
||||
|
||||
if (!chatId) return;
|
||||
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
if (!user) {
|
||||
await bot.sendMessage(chatId, messages.errors.startFirst);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has a username
|
||||
if (!username) {
|
||||
await bot.sendMessage(chatId, messages.address.noUsername, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate address based on selection
|
||||
let address: string;
|
||||
let serviceName: string;
|
||||
|
||||
if (action === '21tipbot') {
|
||||
address = `${username}@twentyone.tips`;
|
||||
serviceName = '21Tipbot';
|
||||
} else if (action === 'bittip') {
|
||||
address = `${username}@btip.nl`;
|
||||
serviceName = 'Bittip';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the address
|
||||
await bot.sendMessage(chatId, messages.address.verifyingService(serviceName, address));
|
||||
const verification = await verifyLightningAddress(address);
|
||||
|
||||
if (!verification.valid) {
|
||||
await bot.sendMessage(chatId, messages.address.serviceNotSetup(serviceName, verification.error), {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getLightningAddressKeyboard(username),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the address
|
||||
await stateManager.updateLightningAddress(userId, address);
|
||||
|
||||
logUserAction(userId, 'Lightning address set via tipbot', { address, serviceName });
|
||||
|
||||
const responseMessage = user.state === 'awaiting_lightning_address'
|
||||
? messages.address.firstTimeSuccess(address)
|
||||
: messages.address.updateSuccess(address);
|
||||
|
||||
await bot.sendMessage(chatId, responseMessage, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in handleLightningAddressCallback', { error, userId, action });
|
||||
await bot.sendMessage(chatId, messages.errors.generic);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
handleAddressCommand,
|
||||
handleLightningAddressInput,
|
||||
handleLightningAddressCallback,
|
||||
};
|
||||
|
||||
@@ -273,11 +273,11 @@ export async function handlePurchaseConfirmation(
|
||||
|
||||
logUserAction(userId, 'Confirmed purchase', { tickets: pendingData.ticketCount });
|
||||
|
||||
// Create invoice
|
||||
// Create invoice with user's display name
|
||||
const purchaseResult = await apiClient.buyTickets(
|
||||
pendingData.ticketCount,
|
||||
user.lightningAddress,
|
||||
userId
|
||||
user.displayName || 'Anon'
|
||||
);
|
||||
|
||||
logPaymentEvent(userId, purchaseResult.ticket_purchase_id, 'created', {
|
||||
@@ -295,21 +295,23 @@ export async function handlePurchaseConfirmation(
|
||||
parse_mode: 'Markdown',
|
||||
});
|
||||
|
||||
// Send QR code
|
||||
await bot.sendPhoto(chatId, qrBuffer, {
|
||||
// Send QR code with caption
|
||||
const qrMessage = 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
|
||||
),
|
||||
});
|
||||
|
||||
// Send invoice string as separate message for easy copying
|
||||
const invoiceMessage = await bot.sendMessage(
|
||||
chatId,
|
||||
messages.buy.invoiceString(purchaseResult.invoice.payment_request),
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
|
||||
// Store purchase and start polling
|
||||
const paymentData: AwaitingPaymentData = {
|
||||
...pendingData,
|
||||
@@ -317,13 +319,16 @@ export async function handlePurchaseConfirmation(
|
||||
paymentRequest: purchaseResult.invoice.payment_request,
|
||||
publicUrl: purchaseResult.public_url,
|
||||
pollStartTime: Date.now(),
|
||||
headerMessageId: messageId,
|
||||
invoiceMessageId: invoiceMessage.message_id,
|
||||
qrMessageId: qrMessage.message_id,
|
||||
};
|
||||
|
||||
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);
|
||||
// Start payment polling - pass all message IDs to delete on completion
|
||||
pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id, messageId, qrMessage.message_id, invoiceMessage.message_id);
|
||||
} catch (error) {
|
||||
logger.error('Error in handlePurchaseConfirmation', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.invoiceCreationFailed, {
|
||||
@@ -340,7 +345,10 @@ async function pollPaymentStatus(
|
||||
bot: TelegramBot,
|
||||
chatId: number,
|
||||
userId: number,
|
||||
purchaseId: string
|
||||
purchaseId: string,
|
||||
headerMessageId?: number,
|
||||
qrMessageId?: number,
|
||||
invoiceMessageId?: number
|
||||
): Promise<void> {
|
||||
const pollInterval = config.bot.paymentPollIntervalMs;
|
||||
const timeout = config.bot.paymentPollTimeoutMs;
|
||||
@@ -348,11 +356,41 @@ async function pollPaymentStatus(
|
||||
|
||||
logPaymentEvent(userId, purchaseId, 'polling');
|
||||
|
||||
// Helper to delete all invoice-related messages
|
||||
const deleteInvoiceMessages = async () => {
|
||||
// Delete in reverse order (bottom to top)
|
||||
if (invoiceMessageId) {
|
||||
try {
|
||||
await bot.deleteMessage(chatId, invoiceMessageId);
|
||||
} catch (e) {
|
||||
// Ignore if message already deleted
|
||||
}
|
||||
}
|
||||
if (qrMessageId) {
|
||||
try {
|
||||
await bot.deleteMessage(chatId, qrMessageId);
|
||||
} catch (e) {
|
||||
// Ignore if message already deleted
|
||||
}
|
||||
}
|
||||
if (headerMessageId) {
|
||||
try {
|
||||
await bot.deleteMessage(chatId, headerMessageId);
|
||||
} catch (e) {
|
||||
// Ignore if message already deleted
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkPayment = async (): Promise<void> => {
|
||||
try {
|
||||
// Check if we've timed out
|
||||
if (Date.now() - startTime > timeout) {
|
||||
logPaymentEvent(userId, purchaseId, 'expired');
|
||||
|
||||
// Delete the invoice messages
|
||||
await deleteInvoiceMessages();
|
||||
|
||||
await bot.sendMessage(chatId, messages.buy.invoiceExpired, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
@@ -374,6 +412,12 @@ async function pollPaymentStatus(
|
||||
tickets: status.tickets.length,
|
||||
});
|
||||
|
||||
// Delete the invoice messages
|
||||
await deleteInvoiceMessages();
|
||||
|
||||
// Track user as cycle participant for notifications
|
||||
await stateManager.addCycleParticipant(status.purchase.cycle_id, userId, purchaseId);
|
||||
|
||||
// Payment received!
|
||||
const ticketNumbers = status.tickets
|
||||
.map((t) => `#${t.serial_number.toString().padStart(4, '0')}`)
|
||||
@@ -397,6 +441,10 @@ async function pollPaymentStatus(
|
||||
|
||||
if (status.purchase.invoice_status === 'expired') {
|
||||
logPaymentEvent(userId, purchaseId, 'expired');
|
||||
|
||||
// Delete the invoice messages
|
||||
await deleteInvoiceMessages();
|
||||
|
||||
await bot.sendMessage(chatId, messages.buy.invoiceExpiredShort, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
|
||||
@@ -2,6 +2,19 @@ import TelegramBot from 'node-telegram-bot-api';
|
||||
import { groupStateManager } from '../services/groupState';
|
||||
import { logger, logUserAction } from '../services/logger';
|
||||
import { messages } from '../messages';
|
||||
import {
|
||||
GroupSettings,
|
||||
REMINDER_PRESETS,
|
||||
ANNOUNCEMENT_DELAY_OPTIONS,
|
||||
DEFAULT_GROUP_REMINDER_SLOTS,
|
||||
ReminderTime,
|
||||
formatReminderTime,
|
||||
reminderTimeToMinutes
|
||||
} from '../types/groups';
|
||||
|
||||
// Track settings messages for auto-deletion
|
||||
const settingsMessageTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||
const SETTINGS_MESSAGE_TTL = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
/**
|
||||
* Check if a user is an admin in a group
|
||||
@@ -106,7 +119,7 @@ export async function handleGroupSettings(
|
||||
return;
|
||||
}
|
||||
|
||||
await bot.sendMessage(
|
||||
const sentMessage = await bot.sendMessage(
|
||||
chatId,
|
||||
messages.groups.settingsOverview(currentSettings),
|
||||
{
|
||||
@@ -114,12 +127,45 @@ export async function handleGroupSettings(
|
||||
reply_markup: getGroupSettingsKeyboard(currentSettings),
|
||||
}
|
||||
);
|
||||
|
||||
// Schedule auto-delete after 2 minutes
|
||||
scheduleSettingsMessageDeletion(bot, chatId, sentMessage.message_id);
|
||||
} catch (error) {
|
||||
logger.error('Error in handleGroupSettings', { error, chatId });
|
||||
await bot.sendMessage(chatId, messages.errors.generic);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule deletion of settings message after 2 minutes
|
||||
*/
|
||||
function scheduleSettingsMessageDeletion(
|
||||
bot: TelegramBot,
|
||||
chatId: number,
|
||||
messageId: number
|
||||
): void {
|
||||
const key = `${chatId}:${messageId}`;
|
||||
|
||||
// Clear any existing timeout for this message
|
||||
const existingTimeout = settingsMessageTimeouts.get(key);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
|
||||
// Schedule new deletion
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
await bot.deleteMessage(chatId, messageId);
|
||||
logger.debug('Auto-deleted settings message', { chatId, messageId });
|
||||
} catch (error) {
|
||||
// Ignore errors (message might already be deleted)
|
||||
}
|
||||
settingsMessageTimeouts.delete(key);
|
||||
}, SETTINGS_MESSAGE_TTL);
|
||||
|
||||
settingsMessageTimeouts.set(key, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle group settings toggle callback
|
||||
*/
|
||||
@@ -144,51 +190,132 @@ export async function handleGroupSettingsCallback(
|
||||
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;
|
||||
}
|
||||
// Refresh auto-delete timer on any interaction
|
||||
scheduleSettingsMessageDeletion(bot, chatId, messageId);
|
||||
|
||||
try {
|
||||
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);
|
||||
let updatedSettings: GroupSettings | null = null;
|
||||
|
||||
// Handle toggle actions
|
||||
if (action.startsWith('toggle_')) {
|
||||
let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed' | 'newJackpotAnnouncement' | 'reminder1Enabled' | 'reminder2Enabled' | 'reminder3Enabled';
|
||||
|
||||
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;
|
||||
case 'toggle_newjackpot':
|
||||
setting = 'newJackpotAnnouncement';
|
||||
break;
|
||||
case 'toggle_reminder1':
|
||||
setting = 'reminder1Enabled';
|
||||
break;
|
||||
case 'toggle_reminder2':
|
||||
setting = 'reminder2Enabled';
|
||||
break;
|
||||
case 'toggle_reminder3':
|
||||
setting = 'reminder3Enabled';
|
||||
break;
|
||||
default:
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = currentSettings[setting] !== false; // Default true for new settings
|
||||
const newValue = !currentValue;
|
||||
updatedSettings = await groupStateManager.updateSetting(chatId, setting, newValue);
|
||||
|
||||
if (updatedSettings) {
|
||||
logUserAction(userId, 'Updated group setting', {
|
||||
groupId: chatId,
|
||||
setting,
|
||||
newValue,
|
||||
});
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: `${setting} ${newValue ? 'enabled' : 'disabled'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy handlers removed - now using 3-slot reminder system with toggle_reminder1/2/3 and time adjustments
|
||||
|
||||
// Handle announcement delay selection
|
||||
if (action.startsWith('announce_delay_')) {
|
||||
const seconds = parseInt(action.replace('announce_delay_', ''), 10);
|
||||
if (!isNaN(seconds)) {
|
||||
updatedSettings = await groupStateManager.updateAnnouncementDelay(chatId, seconds);
|
||||
if (updatedSettings) {
|
||||
logUserAction(userId, 'Updated announcement delay', { groupId: chatId, seconds });
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: seconds === 0 ? 'Announce immediately' : `Announce ${seconds}s after draw`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reminder time adjustments (reminder1_add_1_hours, reminder2_sub_1_days, etc.)
|
||||
const reminderTimeMatch = action.match(/^reminder(\d)_(add|sub)_(\d+)_(minutes|hours|days)$/);
|
||||
if (reminderTimeMatch) {
|
||||
const slot = parseInt(reminderTimeMatch[1], 10) as 1 | 2 | 3;
|
||||
const operation = reminderTimeMatch[2] as 'add' | 'sub';
|
||||
const amount = parseInt(reminderTimeMatch[3], 10);
|
||||
const unit = reminderTimeMatch[4] as 'minutes' | 'hours' | 'days';
|
||||
|
||||
// Get current time for this slot
|
||||
const currentTimeKey = `reminder${slot}Time` as 'reminder1Time' | 'reminder2Time' | 'reminder3Time';
|
||||
const defaultTimes: Record<string, ReminderTime> = {
|
||||
reminder1Time: { value: 1, unit: 'hours' },
|
||||
reminder2Time: { value: 1, unit: 'days' },
|
||||
reminder3Time: { value: 6, unit: 'days' },
|
||||
};
|
||||
const currentTime = currentSettings[currentTimeKey] || defaultTimes[currentTimeKey];
|
||||
|
||||
// Convert to minutes for calculation
|
||||
const currentMinutes = reminderTimeToMinutes(currentTime);
|
||||
const adjustMinutes = unit === 'minutes' ? amount : unit === 'hours' ? amount * 60 : amount * 60 * 24;
|
||||
const newMinutes = operation === 'add'
|
||||
? currentMinutes + adjustMinutes
|
||||
: Math.max(1, currentMinutes - adjustMinutes); // Minimum 1 minute
|
||||
|
||||
// Convert back to best unit
|
||||
let newTime: ReminderTime;
|
||||
if (newMinutes >= 1440 && newMinutes % 1440 === 0) {
|
||||
newTime = { value: newMinutes / 1440, unit: 'days' };
|
||||
} else if (newMinutes >= 60 && newMinutes % 60 === 0) {
|
||||
newTime = { value: newMinutes / 60, unit: 'hours' };
|
||||
} else {
|
||||
newTime = { value: newMinutes, unit: 'minutes' };
|
||||
}
|
||||
|
||||
updatedSettings = await groupStateManager.updateReminderTime(chatId, slot, newTime);
|
||||
if (updatedSettings) {
|
||||
logUserAction(userId, 'Updated reminder time', { groupId: chatId, slot, newTime });
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: `Reminder ${slot}: ${formatReminderTime(newTime)} before draw`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -205,41 +332,132 @@ export async function handleGroupSettingsCallback(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Format delay option for display
|
||||
*/
|
||||
function formatDelayOption(seconds: number): string {
|
||||
if (seconds === 0) return 'Instant';
|
||||
if (seconds >= 60) {
|
||||
const minutes = seconds / 60;
|
||||
return minutes === 1 ? '1 min' : `${minutes} min`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time adjustment buttons for a reminder slot
|
||||
*/
|
||||
function getReminderTimeAdjustButtons(slot: number, currentTime: ReminderTime): TelegramBot.InlineKeyboardButton[] {
|
||||
return [
|
||||
{ text: '−1m', callback_data: `group_reminder${slot}_sub_1_minutes` },
|
||||
{ text: '+1m', callback_data: `group_reminder${slot}_add_1_minutes` },
|
||||
{ text: '−1h', callback_data: `group_reminder${slot}_sub_1_hours` },
|
||||
{ text: '+1h', callback_data: `group_reminder${slot}_add_1_hours` },
|
||||
{ text: '−1d', callback_data: `group_reminder${slot}_sub_1_days` },
|
||||
{ text: '+1d', callback_data: `group_reminder${slot}_add_1_days` },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a reminder time is already set
|
||||
*/
|
||||
function hasReminder(settings: GroupSettings, rt: ReminderTime): boolean {
|
||||
if (!settings.reminderTimes) return false;
|
||||
return settings.reminderTimes.some(
|
||||
r => r.value === rt.value && r.unit === rt.unit
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate inline keyboard for group settings
|
||||
*/
|
||||
function getGroupSettingsKeyboard(settings: {
|
||||
enabled: boolean;
|
||||
drawAnnouncements: boolean;
|
||||
reminders: boolean;
|
||||
ticketPurchaseAllowed: boolean;
|
||||
}): TelegramBot.InlineKeyboardMarkup {
|
||||
const onOff = (val: boolean) => val ? '✅' : '❌';
|
||||
function getGroupSettingsKeyboard(settings: GroupSettings): TelegramBot.InlineKeyboardMarkup {
|
||||
const onOff = (val: boolean | undefined) => val !== false ? '✅' : '❌';
|
||||
const selected = (current: number, option: number) => current === option ? '●' : '○';
|
||||
|
||||
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',
|
||||
}],
|
||||
],
|
||||
};
|
||||
const keyboard: TelegramBot.InlineKeyboardButton[][] = [
|
||||
[{
|
||||
text: `${onOff(settings.enabled)} Bot Enabled`,
|
||||
callback_data: 'group_toggle_enabled',
|
||||
}],
|
||||
[{
|
||||
text: `${onOff(settings.newJackpotAnnouncement)} New Jackpot Announcement`,
|
||||
callback_data: 'group_toggle_newjackpot',
|
||||
}],
|
||||
[{
|
||||
text: `${onOff(settings.drawAnnouncements)} Draw Result Announcements`,
|
||||
callback_data: 'group_toggle_announcements',
|
||||
}],
|
||||
];
|
||||
|
||||
// Add announcement delay options if announcements are enabled
|
||||
if (settings.drawAnnouncements) {
|
||||
keyboard.push(
|
||||
ANNOUNCEMENT_DELAY_OPTIONS.map(seconds => ({
|
||||
text: `${selected(settings.announcementDelaySeconds || 0, seconds)} ${formatDelayOption(seconds)}`,
|
||||
callback_data: `group_announce_delay_${seconds}`,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
keyboard.push([{
|
||||
text: `${onOff(settings.reminders)} Draw Reminders`,
|
||||
callback_data: 'group_toggle_reminders',
|
||||
}]);
|
||||
|
||||
// Add 3-tier reminder options if reminders are enabled
|
||||
if (settings.reminders) {
|
||||
// Get default values with fallback for migration
|
||||
const r1Enabled = settings.reminder1Enabled !== false;
|
||||
const r2Enabled = settings.reminder2Enabled === true;
|
||||
const r3Enabled = settings.reminder3Enabled === true;
|
||||
|
||||
// Get times with defaults
|
||||
const r1Time = settings.reminder1Time || { value: 1, unit: 'hours' as const };
|
||||
const r2Time = settings.reminder2Time || { value: 1, unit: 'days' as const };
|
||||
const r3Time = settings.reminder3Time || { value: 6, unit: 'days' as const };
|
||||
|
||||
// Reminder 1
|
||||
keyboard.push([{
|
||||
text: `${onOff(r1Enabled)} Reminder 1: ${formatReminderTime(r1Time)} before`,
|
||||
callback_data: 'group_toggle_reminder1',
|
||||
}]);
|
||||
if (r1Enabled) {
|
||||
keyboard.push(getReminderTimeAdjustButtons(1, r1Time));
|
||||
}
|
||||
|
||||
// Reminder 2
|
||||
keyboard.push([{
|
||||
text: `${onOff(r2Enabled)} Reminder 2: ${formatReminderTime(r2Time)} before`,
|
||||
callback_data: 'group_toggle_reminder2',
|
||||
}]);
|
||||
if (r2Enabled) {
|
||||
keyboard.push(getReminderTimeAdjustButtons(2, r2Time));
|
||||
}
|
||||
|
||||
// Reminder 3
|
||||
keyboard.push([{
|
||||
text: `${onOff(r3Enabled)} Reminder 3: ${formatReminderTime(r3Time)} before`,
|
||||
callback_data: 'group_toggle_reminder3',
|
||||
}]);
|
||||
if (r3Enabled) {
|
||||
keyboard.push(getReminderTimeAdjustButtons(3, r3Time));
|
||||
}
|
||||
}
|
||||
|
||||
keyboard.push(
|
||||
[{
|
||||
text: `${onOff(settings.ticketPurchaseAllowed)} Allow Ticket Purchases`,
|
||||
callback_data: 'group_toggle_purchases',
|
||||
}],
|
||||
[{
|
||||
text: '🔄 Refresh',
|
||||
callback_data: 'group_refresh',
|
||||
}]
|
||||
);
|
||||
|
||||
return { inline_keyboard: keyboard };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,6 +472,9 @@ export async function handleGroupRefresh(
|
||||
|
||||
if (!chatId || !messageId) return;
|
||||
|
||||
// Refresh auto-delete timer
|
||||
scheduleSettingsMessageDeletion(bot, chatId, messageId);
|
||||
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Refreshed!' });
|
||||
|
||||
const settings = await groupStateManager.getGroup(chatId);
|
||||
|
||||
@@ -4,23 +4,32 @@ import { getMainMenuKeyboard } from '../utils/keyboards';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
* Handle /help command
|
||||
* Handle /lottohelp command
|
||||
*/
|
||||
export async function handleHelpCommand(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
msg: TelegramBot.Message,
|
||||
isGroup: boolean = false
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
|
||||
if (userId) {
|
||||
logUserAction(userId, 'Viewed help');
|
||||
logUserAction(userId, 'Viewed help', { isGroup });
|
||||
}
|
||||
|
||||
await bot.sendMessage(chatId, messages.help.message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
if (isGroup) {
|
||||
// Show group-specific help with admin commands
|
||||
await bot.sendMessage(chatId, messages.help.groupMessage, {
|
||||
parse_mode: 'Markdown',
|
||||
});
|
||||
} else {
|
||||
// Show user help in DM
|
||||
await bot.sendMessage(chatId, messages.help.message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default handleHelpCommand;
|
||||
|
||||
@@ -2,6 +2,7 @@ export { handleStart } from './start';
|
||||
export {
|
||||
handleAddressCommand,
|
||||
handleLightningAddressInput,
|
||||
handleLightningAddressCallback,
|
||||
} from './address';
|
||||
export {
|
||||
handleBuyCommand,
|
||||
@@ -11,6 +12,7 @@ export {
|
||||
} from './buy';
|
||||
export {
|
||||
handleTicketsCommand,
|
||||
handleTicketsPage,
|
||||
handleViewTicket,
|
||||
handleStatusCheck,
|
||||
} from './tickets';
|
||||
@@ -21,6 +23,11 @@ export {
|
||||
handleCancel,
|
||||
handleMenuCallback,
|
||||
} from './menu';
|
||||
export {
|
||||
handleSettingsCommand,
|
||||
handleSettingsCallback,
|
||||
handleDisplayNameInput,
|
||||
} from './settings';
|
||||
export {
|
||||
handleBotAddedToGroup,
|
||||
handleBotRemovedFromGroup,
|
||||
|
||||
172
telegram_bot/src/handlers/settings.ts
Normal file
172
telegram_bot/src/handlers/settings.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { stateManager } from '../services/state';
|
||||
import { logger, logUserAction } from '../services/logger';
|
||||
import { messages } from '../messages';
|
||||
import { getMainMenuKeyboard, getSettingsKeyboard } from '../utils/keyboards';
|
||||
import { NotificationPreferences, DEFAULT_NOTIFICATIONS } from '../types';
|
||||
|
||||
/**
|
||||
* Handle /settings command (private chat only)
|
||||
*/
|
||||
export async function handleSettingsCommand(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
// Only works in private chats
|
||||
if (msg.chat.type !== 'private') {
|
||||
await bot.sendMessage(chatId, '❌ This command only works in private chat. Message me directly!');
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Viewed settings');
|
||||
|
||||
const user = await stateManager.getUser(userId);
|
||||
if (!user) {
|
||||
await bot.sendMessage(chatId, messages.errors.startFirst, {
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure notifications object exists
|
||||
const notifications = user.notifications || { ...DEFAULT_NOTIFICATIONS };
|
||||
const displayName = user.displayName || 'Anon';
|
||||
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
messages.settings.overview(displayName, notifications),
|
||||
{
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getSettingsKeyboard(displayName, notifications),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle settings callback
|
||||
*/
|
||||
export async function handleSettingsCallback(
|
||||
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;
|
||||
|
||||
const user = await stateManager.getUser(userId);
|
||||
if (!user) {
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Please /start first' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle notification toggles
|
||||
if (action.startsWith('toggle_notif_')) {
|
||||
const setting = action.replace('toggle_notif_', '') as keyof NotificationPreferences;
|
||||
const currentNotifications = user.notifications || { ...DEFAULT_NOTIFICATIONS };
|
||||
const newValue = !currentNotifications[setting];
|
||||
|
||||
const updatedUser = await stateManager.updateNotifications(userId, { [setting]: newValue });
|
||||
|
||||
if (updatedUser) {
|
||||
logUserAction(userId, 'Updated notification setting', { setting, newValue });
|
||||
await bot.answerCallbackQuery(query.id, {
|
||||
text: `${setting} ${newValue ? 'enabled' : 'disabled'}`,
|
||||
});
|
||||
|
||||
// Update message
|
||||
await bot.editMessageText(
|
||||
messages.settings.overview(updatedUser.displayName || 'Anon', updatedUser.notifications),
|
||||
{
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getSettingsKeyboard(updatedUser.displayName || 'Anon', updatedUser.notifications),
|
||||
}
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle display name change
|
||||
if (action === 'change_name') {
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
await stateManager.updateUserState(userId, 'awaiting_display_name');
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
messages.settings.enterDisplayName,
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle back to menu
|
||||
if (action === 'back_menu') {
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
await bot.deleteMessage(chatId, messageId);
|
||||
await bot.sendMessage(chatId, messages.menu.header, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await bot.answerCallbackQuery(query.id);
|
||||
} catch (error) {
|
||||
logger.error('Error in handleSettingsCallback', { error, userId, action });
|
||||
await bot.answerCallbackQuery(query.id, { text: 'Error updating settings' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle display name input
|
||||
*/
|
||||
export async function handleDisplayNameInput(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
const text = msg.text?.trim();
|
||||
|
||||
if (!userId || !text) return;
|
||||
|
||||
// Validate display name (max 20 chars, alphanumeric + spaces + some symbols)
|
||||
if (text.length > 20) {
|
||||
await bot.sendMessage(chatId, messages.settings.nameTooLong);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean the display name
|
||||
const cleanName = text.replace(/[^\w\s\-_.]/g, '').trim() || 'Anon';
|
||||
|
||||
await stateManager.updateDisplayName(userId, cleanName);
|
||||
logUserAction(userId, 'Set display name', { displayName: cleanName });
|
||||
|
||||
const user = await stateManager.getUser(userId);
|
||||
const notifications = user?.notifications || { ...DEFAULT_NOTIFICATIONS };
|
||||
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
messages.settings.nameUpdated(cleanName),
|
||||
{
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getSettingsKeyboard(cleanName, notifications),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
handleSettingsCommand,
|
||||
handleSettingsCallback,
|
||||
handleDisplayNameInput,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { getMainMenuKeyboard, getLightningAddressKeyboard } from '../utils/keyboards';
|
||||
import { messages } from '../messages';
|
||||
|
||||
/**
|
||||
@@ -10,6 +10,7 @@ import { messages } from '../messages';
|
||||
export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
const username = msg.from?.username;
|
||||
|
||||
if (!userId) {
|
||||
await bot.sendMessage(chatId, messages.errors.userNotIdentified);
|
||||
@@ -17,7 +18,7 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Started bot', {
|
||||
username: msg.from?.username,
|
||||
username: username,
|
||||
firstName: msg.from?.first_name,
|
||||
});
|
||||
|
||||
@@ -29,7 +30,7 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P
|
||||
// Create new user
|
||||
user = await stateManager.createUser(
|
||||
userId,
|
||||
msg.from?.username,
|
||||
username,
|
||||
msg.from?.first_name,
|
||||
msg.from?.last_name
|
||||
);
|
||||
@@ -40,9 +41,9 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P
|
||||
|
||||
// Check if lightning address is set
|
||||
if (!user.lightningAddress) {
|
||||
await bot.sendMessage(chatId, messages.start.needAddress, {
|
||||
await bot.sendMessage(chatId, messages.start.needAddressWithOptions(username), {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getCancelKeyboard(),
|
||||
reply_markup: getLightningAddressKeyboard(username),
|
||||
});
|
||||
|
||||
await stateManager.updateUserState(userId, 'awaiting_lightning_address');
|
||||
|
||||
@@ -7,12 +7,25 @@ import { getMainMenuKeyboard, getViewTicketKeyboard } from '../utils/keyboards';
|
||||
import { formatSats, formatDate } from '../utils/format';
|
||||
import { messages } from '../messages';
|
||||
|
||||
const TICKETS_PER_PAGE = 5;
|
||||
|
||||
interface PurchaseInfo {
|
||||
id: string;
|
||||
cycleId: string;
|
||||
ticketCount: number;
|
||||
scheduledAt: string;
|
||||
invoiceStatus: string;
|
||||
isWinner: boolean;
|
||||
hasDrawn: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /tickets command or "My Tickets" button
|
||||
*/
|
||||
export async function handleTicketsCommand(
|
||||
bot: TelegramBot,
|
||||
msg: TelegramBot.Message
|
||||
msg: TelegramBot.Message,
|
||||
page: number = 0
|
||||
): Promise<void> {
|
||||
const chatId = msg.chat.id;
|
||||
const userId = msg.from?.id;
|
||||
@@ -22,8 +35,46 @@ export async function handleTicketsCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
logUserAction(userId, 'Viewed tickets');
|
||||
logUserAction(userId, 'Viewed tickets', { page });
|
||||
|
||||
await sendTicketsList(bot, chatId, userId, page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tickets page navigation callback
|
||||
*/
|
||||
export async function handleTicketsPage(
|
||||
bot: TelegramBot,
|
||||
query: TelegramBot.CallbackQuery,
|
||||
page: number
|
||||
): 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);
|
||||
|
||||
// Delete old message and send new one
|
||||
try {
|
||||
await bot.deleteMessage(chatId, messageId);
|
||||
} catch (e) {
|
||||
// Ignore delete errors
|
||||
}
|
||||
|
||||
await sendTicketsList(bot, chatId, userId, page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tickets list with pagination
|
||||
*/
|
||||
async function sendTicketsList(
|
||||
bot: TelegramBot,
|
||||
chatId: number,
|
||||
userId: number,
|
||||
page: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const user = await stateManager.getUser(userId);
|
||||
|
||||
@@ -32,8 +83,12 @@ export async function handleTicketsCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user's purchase IDs from state
|
||||
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 10);
|
||||
// Get current cycle to identify current round tickets
|
||||
const currentJackpot = await apiClient.getNextJackpot();
|
||||
const currentCycleId = currentJackpot?.cycle?.id;
|
||||
|
||||
// Get ALL user's purchase IDs from state (increase limit for pagination)
|
||||
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 100);
|
||||
|
||||
if (purchaseIds.length === 0) {
|
||||
await bot.sendMessage(chatId, messages.tickets.empty, {
|
||||
@@ -44,21 +99,15 @@ export async function handleTicketsCommand(
|
||||
}
|
||||
|
||||
// Fetch status for each purchase
|
||||
const purchases: Array<{
|
||||
id: string;
|
||||
ticketCount: number;
|
||||
scheduledAt: string;
|
||||
invoiceStatus: string;
|
||||
isWinner: boolean;
|
||||
hasDrawn: boolean;
|
||||
}> = [];
|
||||
const allPurchases: PurchaseInfo[] = [];
|
||||
|
||||
for (const purchaseId of purchaseIds) {
|
||||
try {
|
||||
const status = await apiClient.getTicketStatus(purchaseId);
|
||||
if (status) {
|
||||
purchases.push({
|
||||
allPurchases.push({
|
||||
id: status.purchase.id,
|
||||
cycleId: status.purchase.cycle_id,
|
||||
ticketCount: status.purchase.number_of_tickets,
|
||||
scheduledAt: status.cycle.scheduled_at,
|
||||
invoiceStatus: status.purchase.invoice_status,
|
||||
@@ -72,7 +121,7 @@ export async function handleTicketsCommand(
|
||||
}
|
||||
}
|
||||
|
||||
if (purchases.length === 0) {
|
||||
if (allPurchases.length === 0) {
|
||||
await bot.sendMessage(chatId, messages.tickets.notFound, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
@@ -80,56 +129,134 @@ export async function handleTicketsCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
// Format purchases list
|
||||
let message = messages.tickets.header;
|
||||
// Separate current round and past tickets
|
||||
const currentRoundTickets = allPurchases.filter(p =>
|
||||
p.cycleId === currentCycleId && !p.hasDrawn
|
||||
);
|
||||
const pastTickets = allPurchases.filter(p =>
|
||||
p.cycleId !== currentCycleId || p.hasDrawn
|
||||
);
|
||||
|
||||
for (let i = 0; i < purchases.length; i++) {
|
||||
const p = purchases[i];
|
||||
const drawDate = new Date(p.scheduledAt);
|
||||
// Sort past tickets by date (newest first)
|
||||
pastTickets.sort((a, b) =>
|
||||
new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime()
|
||||
);
|
||||
|
||||
// Build message
|
||||
let message = '';
|
||||
const inlineKeyboard: TelegramBot.InlineKeyboardButton[][] = [];
|
||||
|
||||
// Current round section (always shown on page 0)
|
||||
if (page === 0 && currentRoundTickets.length > 0) {
|
||||
message += `🎯 *Current Round*\n\n`;
|
||||
|
||||
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;
|
||||
for (const p of currentRoundTickets) {
|
||||
const drawDate = new Date(p.scheduledAt);
|
||||
const statusInfo = getStatusInfo(p);
|
||||
message += `${statusInfo.emoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} – Draw: ${formatDate(drawDate)}\n`;
|
||||
|
||||
inlineKeyboard.push([{
|
||||
text: `🎟 View Current Tickets #${p.id.substring(0, 8)}...`,
|
||||
callback_data: `view_ticket_${p.id}`,
|
||||
}]);
|
||||
}
|
||||
|
||||
message += `${i + 1}. ${statusEmoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} – ${formatDate(drawDate)} – ${statusText}\n`;
|
||||
|
||||
if (pastTickets.length > 0) {
|
||||
message += `\n📜 *Past Tickets*\n\n`;
|
||||
}
|
||||
} else if (page === 0) {
|
||||
message += messages.tickets.header;
|
||||
} else {
|
||||
message += `📜 *Past Tickets (Page ${page + 1})*\n\n`;
|
||||
}
|
||||
|
||||
message += messages.tickets.tapForDetails;
|
||||
// Calculate pagination for past tickets
|
||||
const startIdx = page === 0 && currentRoundTickets.length > 0
|
||||
? 0
|
||||
: page * TICKETS_PER_PAGE - (currentRoundTickets.length > 0 ? 0 : 0);
|
||||
const adjustedStartIdx = page === 0 ? 0 : (page - 1) * TICKETS_PER_PAGE + (currentRoundTickets.length > 0 ? TICKETS_PER_PAGE : 0);
|
||||
const pageStartIdx = page === 0 ? 0 : (page - (currentRoundTickets.length > 0 ? 1 : 0)) * TICKETS_PER_PAGE;
|
||||
const ticketsToShow = pastTickets.slice(pageStartIdx, pageStartIdx + TICKETS_PER_PAGE);
|
||||
|
||||
// Past tickets section
|
||||
for (let i = 0; i < ticketsToShow.length; i++) {
|
||||
const p = ticketsToShow[i];
|
||||
const drawDate = new Date(p.scheduledAt);
|
||||
const statusInfo = getStatusInfo(p);
|
||||
const globalIdx = pageStartIdx + i + 1;
|
||||
|
||||
message += `${globalIdx}. ${statusInfo.emoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} – ${formatDate(drawDate)} – ${statusInfo.text}\n`;
|
||||
|
||||
inlineKeyboard.push([{
|
||||
text: `${globalIdx}. ${statusInfo.emoji} View #${p.id.substring(0, 8)}...`,
|
||||
callback_data: `view_ticket_${p.id}`,
|
||||
}]);
|
||||
}
|
||||
|
||||
// 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}`,
|
||||
}]);
|
||||
// Pagination buttons
|
||||
const totalPastPages = Math.ceil(pastTickets.length / TICKETS_PER_PAGE);
|
||||
const hasCurrentRound = currentRoundTickets.length > 0;
|
||||
const effectivePage = hasCurrentRound ? page : page;
|
||||
const maxPage = hasCurrentRound ? totalPastPages : totalPastPages - 1;
|
||||
|
||||
const navButtons: TelegramBot.InlineKeyboardButton[] = [];
|
||||
|
||||
if (page > 0) {
|
||||
navButtons.push({
|
||||
text: '⬅️ Previous',
|
||||
callback_data: `tickets_page_${page - 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (pageStartIdx + TICKETS_PER_PAGE < pastTickets.length) {
|
||||
navButtons.push({
|
||||
text: '➡️ Next',
|
||||
callback_data: `tickets_page_${page + 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (navButtons.length > 0) {
|
||||
inlineKeyboard.push(navButtons);
|
||||
}
|
||||
|
||||
// Add page info if paginated
|
||||
if (pastTickets.length > TICKETS_PER_PAGE) {
|
||||
const currentPageNum = page + 1;
|
||||
const totalPages = Math.ceil(pastTickets.length / TICKETS_PER_PAGE) + (hasCurrentRound ? 1 : 0);
|
||||
message += `\n_Page ${currentPageNum} of ${totalPages}_`;
|
||||
}
|
||||
|
||||
message += `\n\nTap a ticket to view details.`;
|
||||
|
||||
await bot.sendMessage(chatId, message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: { inline_keyboard: inlineKeyboard },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in handleTicketsCommand', { error, userId });
|
||||
logger.error('Error in sendTicketsList', { error, userId });
|
||||
await bot.sendMessage(chatId, messages.errors.fetchTicketsFailed, {
|
||||
reply_markup: getMainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status emoji and text for a purchase
|
||||
*/
|
||||
function getStatusInfo(p: PurchaseInfo): { emoji: string; text: string } {
|
||||
if (p.invoiceStatus === 'pending') {
|
||||
return { emoji: '⏳', text: messages.tickets.statusPending };
|
||||
} else if (p.invoiceStatus === 'expired') {
|
||||
return { emoji: '❌', text: messages.tickets.statusExpired };
|
||||
} else if (!p.hasDrawn) {
|
||||
return { emoji: '🎟', text: messages.tickets.statusActive };
|
||||
} else if (p.isWinner) {
|
||||
return { emoji: '🏆', text: messages.tickets.statusWon };
|
||||
} else {
|
||||
return { emoji: '😔', text: messages.tickets.statusLost };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle viewing a specific ticket
|
||||
*/
|
||||
@@ -254,6 +381,7 @@ export async function handleStatusCheck(
|
||||
|
||||
export default {
|
||||
handleTicketsCommand,
|
||||
handleTicketsPage,
|
||||
handleViewTicket,
|
||||
handleStatusCheck,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user