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,225 +1,213 @@
import Redis from 'ioredis';
import config from '../config';
import { botDatabase } from './database';
import { logger } from './logger';
import { GroupSettings, DEFAULT_GROUP_SETTINGS } from '../types/groups';
const GROUP_PREFIX = 'tg_group:';
const GROUPS_LIST_KEY = 'tg_groups_list';
const STATE_TTL = 60 * 60 * 24 * 365; // 1 year
import { GroupSettings, ReminderTime, reminderTimeToMinutes } from '../types/groups';
class GroupStateManager {
private redis: Redis | null = null;
private memoryStore: Map<string, string> = new Map();
private useRedis: boolean = false;
async init(redisUrl: string | null): Promise<void> {
if (redisUrl) {
try {
this.redis = new Redis(redisUrl);
await this.redis.ping();
this.useRedis = true;
logger.info('Group state manager initialized with Redis');
} catch (error) {
logger.warn('Failed to connect to Redis for groups, using in-memory store');
this.redis = null;
this.useRedis = false;
}
}
}
private async get(key: string): Promise<string | null> {
if (this.useRedis && this.redis) {
return await this.redis.get(key);
}
return this.memoryStore.get(key) || null;
}
private async set(key: string, value: string, ttl?: number): Promise<void> {
if (this.useRedis && this.redis) {
if (ttl) {
await this.redis.setex(key, ttl, value);
} else {
await this.redis.set(key, value);
}
} else {
this.memoryStore.set(key, value);
}
}
private async del(key: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.del(key);
} else {
this.memoryStore.delete(key);
}
}
private async sadd(key: string, value: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.sadd(key, value);
} else {
const existing = this.memoryStore.get(key);
const set = existing ? new Set(JSON.parse(existing)) : new Set();
set.add(value);
this.memoryStore.set(key, JSON.stringify([...set]));
}
}
private async srem(key: string, value: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.srem(key, value);
} else {
const existing = this.memoryStore.get(key);
if (existing) {
const set = new Set(JSON.parse(existing));
set.delete(value);
this.memoryStore.set(key, JSON.stringify([...set]));
}
}
}
private async smembers(key: string): Promise<string[]> {
if (this.useRedis && this.redis) {
return await this.redis.smembers(key);
}
const existing = this.memoryStore.get(key);
return existing ? JSON.parse(existing) : [];
async init(): Promise<void> {
// Database is initialized separately
logger.info('Group state manager initialized (using SQLite database)');
}
/**
* Get group settings
*/
async getGroup(groupId: number): Promise<GroupSettings | null> {
const key = `${GROUP_PREFIX}${groupId}`;
const data = await this.get(key);
if (!data) return null;
try {
const settings = JSON.parse(data);
return {
...settings,
addedAt: new Date(settings.addedAt),
updatedAt: new Date(settings.updatedAt),
};
} catch (error) {
logger.error('Failed to parse group settings', { groupId, error });
return null;
}
return botDatabase.getGroup(groupId);
}
/**
* Create or update group settings
*/
async saveGroup(settings: GroupSettings): Promise<void> {
const key = `${GROUP_PREFIX}${settings.groupId}`;
settings.updatedAt = new Date();
await this.set(key, JSON.stringify(settings), STATE_TTL);
await this.sadd(GROUPS_LIST_KEY, settings.groupId.toString());
logger.debug('Group settings saved', { groupId: settings.groupId });
}
/**
* Register a new group when bot is added
* Register a new group
*/
async registerGroup(
groupId: number,
groupTitle: string,
addedBy: number
): Promise<GroupSettings> {
const existing = await this.getGroup(groupId);
if (existing) {
// Update title if changed
existing.groupTitle = groupTitle;
existing.updatedAt = new Date();
await this.saveGroup(existing);
return existing;
}
const settings: GroupSettings = {
groupId,
groupTitle,
...DEFAULT_GROUP_SETTINGS,
addedBy,
addedAt: new Date(),
updatedAt: new Date(),
};
await this.saveGroup(settings);
logger.info('New group registered', { groupId, groupTitle, addedBy });
return settings;
return botDatabase.registerGroup(groupId, groupTitle, addedBy);
}
/**
* Remove group when bot is removed
* Remove a group
*/
async removeGroup(groupId: number): Promise<void> {
const key = `${GROUP_PREFIX}${groupId}`;
await this.del(key);
await this.srem(GROUPS_LIST_KEY, groupId.toString());
logger.info('Group removed', { groupId });
botDatabase.removeGroup(groupId);
}
/**
* Update a specific setting
* Save group settings
*/
async saveGroup(settings: GroupSettings): Promise<void> {
botDatabase.saveGroup(settings);
logger.debug('Group settings saved', { groupId: settings.groupId });
}
/**
* Update a group setting
*/
async updateSetting(
groupId: number,
setting: keyof Pick<GroupSettings, 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed'>,
setting:
| 'enabled'
| 'drawAnnouncements'
| 'reminders'
| 'newJackpotAnnouncement'
| 'ticketPurchaseAllowed'
| 'reminder1Enabled'
| 'reminder2Enabled'
| 'reminder3Enabled',
value: boolean
): Promise<GroupSettings | null> {
const settings = await this.getGroup(groupId);
if (!settings) return null;
settings[setting] = value;
await this.saveGroup(settings);
return settings;
return botDatabase.updateGroupSetting(groupId, setting, value);
}
/**
* Get all groups with a specific feature enabled
* Update reminder time for a slot
*/
async updateReminderTime(
groupId: number,
slot: 1 | 2 | 3,
time: ReminderTime
): Promise<GroupSettings | null> {
return botDatabase.updateReminderTime(groupId, slot, time);
}
/**
* Update announcement delay
*/
async updateAnnouncementDelay(
groupId: number,
seconds: number
): Promise<GroupSettings | null> {
return botDatabase.updateAnnouncementDelay(groupId, seconds);
}
/**
* Get groups with specific feature enabled
*/
async getGroupsWithFeature(
feature: 'enabled' | 'drawAnnouncements' | 'reminders'
feature: 'enabled' | 'drawAnnouncements' | 'reminders' | 'newJackpotAnnouncement'
): Promise<GroupSettings[]> {
const groupIds = await this.smembers(GROUPS_LIST_KEY);
const groups: GroupSettings[] = [];
for (const id of groupIds) {
const settings = await this.getGroup(parseInt(id, 10));
if (settings && settings.enabled && settings[feature]) {
groups.push(settings);
}
if (feature === 'newJackpotAnnouncement') {
const allGroups = await this.getAllGroups();
return allGroups.filter(g => g.enabled && g.newJackpotAnnouncement);
}
return groups;
return botDatabase.getGroupsWithFeature(feature as 'enabled' | 'drawAnnouncements' | 'reminders');
}
/**
* Get all registered groups
* Get all groups
*/
async getAllGroups(): Promise<GroupSettings[]> {
const groupIds = await this.smembers(GROUPS_LIST_KEY);
const groups: GroupSettings[] = [];
return botDatabase.getAllGroups();
}
for (const id of groupIds) {
const settings = await this.getGroup(parseInt(id, 10));
if (settings) {
groups.push(settings);
/**
* Get groups that need reminders for a specific draw time
*/
async getGroupsNeedingReminders(drawTime: Date): Promise<Array<{
settings: GroupSettings;
reminderSlot: 1 | 2 | 3;
}>> {
const allGroups = await this.getGroupsWithFeature('reminders');
const now = new Date();
const minutesUntilDraw = (drawTime.getTime() - now.getTime()) / (1000 * 60);
const results: Array<{ settings: GroupSettings; reminderSlot: 1 | 2 | 3 }> = [];
for (const group of allGroups) {
// Check each reminder slot
if (group.reminder1Enabled) {
const reminderMinutes = reminderTimeToMinutes(group.reminder1Time);
if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) {
results.push({ settings: group, reminderSlot: 1 });
}
}
if (group.reminder2Enabled) {
const reminderMinutes = reminderTimeToMinutes(group.reminder2Time);
if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) {
results.push({ settings: group, reminderSlot: 2 });
}
}
if (group.reminder3Enabled) {
const reminderMinutes = reminderTimeToMinutes(group.reminder3Time);
if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) {
results.push({ settings: group, reminderSlot: 3 });
}
}
}
return groups;
return results;
}
async close(): Promise<void> {
if (this.redis) {
await this.redis.quit();
/**
* Add time to a reminder
*/
async addReminderTime(
groupId: number,
slot: 1 | 2 | 3,
amount: number,
unit: 'minutes' | 'hours' | 'days'
): Promise<GroupSettings | null> {
const group = await this.getGroup(groupId);
if (!group) return null;
const timeKey = `reminder${slot}Time` as 'reminder1Time' | 'reminder2Time' | 'reminder3Time';
const currentTime = group[timeKey];
// Convert everything to minutes, add, then convert back
let totalMinutes = reminderTimeToMinutes(currentTime);
switch (unit) {
case 'minutes': totalMinutes += amount; break;
case 'hours': totalMinutes += amount * 60; break;
case 'days': totalMinutes += amount * 24 * 60; break;
}
// Ensure minimum of 1 minute
totalMinutes = Math.max(1, totalMinutes);
// Convert back to best unit
const newTime = this.minutesToReminderTime(totalMinutes);
return this.updateReminderTime(groupId, slot, newTime);
}
/**
* Remove time from a reminder
*/
async removeReminderTime(
groupId: number,
slot: 1 | 2 | 3,
amount: number,
unit: 'minutes' | 'hours' | 'days'
): Promise<GroupSettings | null> {
return this.addReminderTime(groupId, slot, -amount, unit);
}
/**
* Convert total minutes to the best ReminderTime representation
*/
private minutesToReminderTime(totalMinutes: number): ReminderTime {
// Use days if evenly divisible and >= 1 day
if (totalMinutes >= 1440 && totalMinutes % 1440 === 0) {
return { value: totalMinutes / 1440, unit: 'days' };
}
// Use hours if evenly divisible and >= 1 hour
if (totalMinutes >= 60 && totalMinutes % 60 === 0) {
return { value: totalMinutes / 60, unit: 'hours' };
}
// Use minutes
return { value: totalMinutes, unit: 'minutes' };
}
/**
* Shutdown
*/
async close(): Promise<void> {
// Database close is handled separately
logger.info('Group state manager closed');
}
}
export const groupStateManager = new GroupStateManager();
export default groupStateManager;