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:
Michilis
2025-12-08 22:33:40 +00:00
parent dd6b26c524
commit 13fd2b8989
24 changed files with 3354 additions and 637 deletions

View File

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

View File

@@ -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(),

View File

@@ -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);

View File

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

View File

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

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

View File

@@ -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');

View File

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