Files
LightningLotto/telegram_bot/src/handlers/groups.ts
Michilis 13fd2b8989 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
2025-12-08 22:33:40 +00:00

564 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
*/
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;
}
const sentMessage = await bot.sendMessage(
chatId,
messages.groups.settingsOverview(currentSettings),
{
parse_mode: 'Markdown',
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
*/
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;
}
// 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;
}
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;
}
// 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' });
}
}
/**
* 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: GroupSettings): TelegramBot.InlineKeyboardMarkup {
const onOff = (val: boolean | undefined) => val !== false ? '✅' : '❌';
const selected = (current: number, option: number) => current === option ? '●' : '○';
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 };
}
/**
* 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;
// Refresh auto-delete timer
scheduleSettingsMessageDeletion(bot, chatId, messageId);
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,
};