- 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
564 lines
17 KiB
TypeScript
564 lines
17 KiB
TypeScript
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,
|
||
};
|
||
|
||
|