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,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;
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user