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

@@ -70,7 +70,7 @@ class ApiClient {
async buyTickets(
tickets: number,
lightningAddress: string,
telegramUserId: number
displayName: string = 'Anon'
): Promise<BuyTicketsResponse> {
try {
const response = await this.client.post<ApiResponse<BuyTicketsResponse>>(
@@ -78,7 +78,7 @@ class ApiClient {
{
tickets,
lightning_address: lightningAddress,
buyer_name: `TG:${telegramUserId}`,
buyer_name: displayName || 'Anon',
}
);
return response.data.data;

View File

@@ -0,0 +1,649 @@
import Database from 'better-sqlite3';
import path from 'path';
import { logger } from './logger';
import { TelegramUser, NotificationPreferences, DEFAULT_NOTIFICATIONS } from '../types';
import { GroupSettings, DEFAULT_GROUP_SETTINGS, ReminderTime } from '../types/groups';
const DB_PATH = process.env.BOT_DATABASE_PATH || path.join(__dirname, '../../data/bot.db');
class BotDatabase {
private db: Database.Database | null = null;
/**
* Initialize the database
*/
init(): void {
try {
// Ensure data directory exists
const fs = require('fs');
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
this.db = new Database(DB_PATH);
this.db.pragma('journal_mode = WAL');
this.createTables();
logger.info('Bot database initialized', { path: DB_PATH });
} catch (error) {
logger.error('Failed to initialize bot database', { error });
throw error;
}
}
/**
* Create database tables
*/
private createTables(): void {
if (!this.db) return;
// Users table
this.db.exec(`
CREATE TABLE IF NOT EXISTS users (
telegram_id INTEGER PRIMARY KEY,
username TEXT,
first_name TEXT,
last_name TEXT,
display_name TEXT DEFAULT 'Anon',
lightning_address TEXT,
state TEXT DEFAULT 'idle',
state_data TEXT,
notif_draw_reminders INTEGER DEFAULT 1,
notif_draw_results INTEGER DEFAULT 1,
notif_new_jackpot_alerts INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// User purchases (tracking which purchases belong to which user)
this.db.exec(`
CREATE TABLE IF NOT EXISTS user_purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_id INTEGER NOT NULL,
purchase_id TEXT NOT NULL UNIQUE,
cycle_id TEXT,
ticket_count INTEGER,
amount_sats INTEGER,
lightning_address TEXT,
payment_request TEXT,
public_url TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (telegram_id) REFERENCES users(telegram_id)
)
`);
// Cycle participants (for notifications)
this.db.exec(`
CREATE TABLE IF NOT EXISTS cycle_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cycle_id TEXT NOT NULL,
telegram_id INTEGER NOT NULL,
purchase_id TEXT NOT NULL,
joined_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cycle_id, telegram_id, purchase_id),
FOREIGN KEY (telegram_id) REFERENCES users(telegram_id)
)
`);
// Groups table
this.db.exec(`
CREATE TABLE IF NOT EXISTS groups (
group_id INTEGER PRIMARY KEY,
group_title TEXT,
enabled INTEGER DEFAULT 1,
draw_announcements INTEGER DEFAULT 1,
reminders INTEGER DEFAULT 1,
new_jackpot_announcement INTEGER DEFAULT 1,
ticket_purchase_allowed INTEGER DEFAULT 0,
reminder1_enabled INTEGER DEFAULT 1,
reminder1_time TEXT DEFAULT '{"value":1,"unit":"hours"}',
reminder2_enabled INTEGER DEFAULT 0,
reminder2_time TEXT DEFAULT '{"value":1,"unit":"days"}',
reminder3_enabled INTEGER DEFAULT 0,
reminder3_time TEXT DEFAULT '{"value":6,"unit":"days"}',
announcement_delay_seconds INTEGER DEFAULT 10,
added_by INTEGER,
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// Create indexes
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_user_purchases_telegram_id ON user_purchases(telegram_id);
CREATE INDEX IF NOT EXISTS idx_user_purchases_cycle_id ON user_purchases(cycle_id);
CREATE INDEX IF NOT EXISTS idx_cycle_participants_cycle_id ON cycle_participants(cycle_id);
CREATE INDEX IF NOT EXISTS idx_cycle_participants_telegram_id ON cycle_participants(telegram_id);
`);
logger.debug('Database tables created/verified');
}
// ═══════════════════════════════════════════════════════════════════════════
// USER METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get user by Telegram ID
*/
getUser(telegramId: number): TelegramUser | null {
if (!this.db) return null;
const row = this.db.prepare(`
SELECT * FROM users WHERE telegram_id = ?
`).get(telegramId) as any;
if (!row) return null;
return this.rowToUser(row);
}
/**
* Create a new user
*/
createUser(
telegramId: number,
username?: string,
firstName?: string,
lastName?: string
): TelegramUser {
if (!this.db) throw new Error('Database not initialized');
const now = new Date().toISOString();
this.db.prepare(`
INSERT INTO users (telegram_id, username, first_name, last_name, display_name, created_at, updated_at)
VALUES (?, ?, ?, ?, 'Anon', ?, ?)
`).run(telegramId, username || null, firstName || null, lastName || null, now, now);
logger.info('New user created', { telegramId, username });
return this.getUser(telegramId)!;
}
/**
* Update user
*/
saveUser(user: TelegramUser): void {
if (!this.db) return;
const now = new Date().toISOString();
const stateData = user.stateData ? JSON.stringify(user.stateData) : null;
this.db.prepare(`
UPDATE users SET
username = ?,
first_name = ?,
last_name = ?,
display_name = ?,
lightning_address = ?,
state = ?,
state_data = ?,
notif_draw_reminders = ?,
notif_draw_results = ?,
notif_new_jackpot_alerts = ?,
updated_at = ?
WHERE telegram_id = ?
`).run(
user.username || null,
user.firstName || null,
user.lastName || null,
user.displayName || 'Anon',
user.lightningAddress || null,
user.state,
stateData,
user.notifications?.drawReminders ? 1 : 0,
user.notifications?.drawResults ? 1 : 0,
user.notifications?.newJackpotAlerts ? 1 : 0,
now,
user.telegramId
);
}
/**
* Update user state
*/
updateUserState(telegramId: number, state: string, stateData?: Record<string, any>): void {
if (!this.db) return;
const now = new Date().toISOString();
const data = stateData ? JSON.stringify(stateData) : null;
this.db.prepare(`
UPDATE users SET state = ?, state_data = ?, updated_at = ? WHERE telegram_id = ?
`).run(state, data, now, telegramId);
}
/**
* Update lightning address
*/
updateLightningAddress(telegramId: number, address: string): void {
if (!this.db) return;
const now = new Date().toISOString();
this.db.prepare(`
UPDATE users SET lightning_address = ?, state = 'idle', state_data = NULL, updated_at = ?
WHERE telegram_id = ?
`).run(address, now, telegramId);
}
/**
* Update display name
*/
updateDisplayName(telegramId: number, displayName: string): void {
if (!this.db) return;
const now = new Date().toISOString();
this.db.prepare(`
UPDATE users SET display_name = ?, state = 'idle', state_data = NULL, updated_at = ?
WHERE telegram_id = ?
`).run(displayName || 'Anon', now, telegramId);
}
/**
* Update notification preferences
*/
updateNotifications(telegramId: number, updates: Partial<NotificationPreferences>): TelegramUser | null {
if (!this.db) return null;
const user = this.getUser(telegramId);
if (!user) return null;
const now = new Date().toISOString();
const notifications = { ...user.notifications, ...updates };
this.db.prepare(`
UPDATE users SET
notif_draw_reminders = ?,
notif_draw_results = ?,
notif_new_jackpot_alerts = ?,
updated_at = ?
WHERE telegram_id = ?
`).run(
notifications.drawReminders ? 1 : 0,
notifications.drawResults ? 1 : 0,
notifications.newJackpotAlerts ? 1 : 0,
now,
telegramId
);
return this.getUser(telegramId);
}
/**
* Get users with specific notification enabled
*/
getUsersWithNotification(preference: keyof NotificationPreferences): TelegramUser[] {
if (!this.db) return [];
const column = preference === 'drawReminders' ? 'notif_draw_reminders'
: preference === 'drawResults' ? 'notif_draw_results'
: 'notif_new_jackpot_alerts';
const rows = this.db.prepare(`
SELECT * FROM users WHERE ${column} = 1
`).all() as any[];
return rows.map(row => this.rowToUser(row));
}
/**
* Convert database row to TelegramUser
*/
private rowToUser(row: any): TelegramUser {
return {
telegramId: row.telegram_id,
username: row.username || undefined,
firstName: row.first_name || undefined,
lastName: row.last_name || undefined,
displayName: row.display_name || 'Anon',
lightningAddress: row.lightning_address || undefined,
state: row.state || 'idle',
stateData: row.state_data ? JSON.parse(row.state_data) : undefined,
notifications: {
drawReminders: row.notif_draw_reminders === 1,
drawResults: row.notif_draw_results === 1,
newJackpotAlerts: row.notif_new_jackpot_alerts === 1,
},
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
};
}
// ═══════════════════════════════════════════════════════════════════════════
// PURCHASE METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Store a purchase
*/
storePurchase(
telegramId: number,
purchaseId: string,
data: {
cycleId: string;
ticketCount: number;
totalAmount: number;
lightningAddress: string;
paymentRequest: string;
publicUrl: string;
}
): void {
if (!this.db) return;
this.db.prepare(`
INSERT OR REPLACE INTO user_purchases
(telegram_id, purchase_id, cycle_id, ticket_count, amount_sats, lightning_address, payment_request, public_url, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')
`).run(
telegramId,
purchaseId,
data.cycleId,
data.ticketCount,
data.totalAmount,
data.lightningAddress,
data.paymentRequest,
data.publicUrl
);
}
/**
* Update purchase status
*/
updatePurchaseStatus(purchaseId: string, status: string): void {
if (!this.db) return;
this.db.prepare(`
UPDATE user_purchases SET status = ? WHERE purchase_id = ?
`).run(status, purchaseId);
}
/**
* Get user's recent purchase IDs
*/
getUserPurchaseIds(telegramId: number, limit: number = 10): string[] {
if (!this.db) return [];
const rows = this.db.prepare(`
SELECT purchase_id FROM user_purchases
WHERE telegram_id = ?
ORDER BY created_at DESC
LIMIT ?
`).all(telegramId, limit) as any[];
return rows.map(row => row.purchase_id);
}
/**
* Get purchase by ID
*/
getPurchase(purchaseId: string): any | null {
if (!this.db) return null;
return this.db.prepare(`
SELECT * FROM user_purchases WHERE purchase_id = ?
`).get(purchaseId);
}
// ═══════════════════════════════════════════════════════════════════════════
// CYCLE PARTICIPANT METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Add user as cycle participant
*/
addCycleParticipant(cycleId: string, telegramId: number, purchaseId: string): void {
if (!this.db) return;
try {
this.db.prepare(`
INSERT OR IGNORE INTO cycle_participants (cycle_id, telegram_id, purchase_id)
VALUES (?, ?, ?)
`).run(cycleId, telegramId, purchaseId);
} catch (error) {
// Ignore duplicate entries
}
}
/**
* Get cycle participants
*/
getCycleParticipants(cycleId: string): Array<{ telegramId: number; purchaseId: string }> {
if (!this.db) return [];
const rows = this.db.prepare(`
SELECT telegram_id, purchase_id FROM cycle_participants WHERE cycle_id = ?
`).all(cycleId) as any[];
return rows.map(row => ({
telegramId: row.telegram_id,
purchaseId: row.purchase_id,
}));
}
/**
* Check if user participated in cycle
*/
didUserParticipate(cycleId: string, telegramId: number): boolean {
if (!this.db) return false;
const row = this.db.prepare(`
SELECT 1 FROM cycle_participants WHERE cycle_id = ? AND telegram_id = ? LIMIT 1
`).get(cycleId, telegramId);
return !!row;
}
// ═══════════════════════════════════════════════════════════════════════════
// GROUP METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get group settings
*/
getGroup(groupId: number): GroupSettings | null {
if (!this.db) return null;
const row = this.db.prepare(`
SELECT * FROM groups WHERE group_id = ?
`).get(groupId) as any;
if (!row) return null;
return this.rowToGroup(row);
}
/**
* Register or update group
*/
registerGroup(groupId: number, groupTitle: string, addedBy: number): GroupSettings {
if (!this.db) throw new Error('Database not initialized');
const existing = this.getGroup(groupId);
if (existing) {
// Update title
this.db.prepare(`
UPDATE groups SET group_title = ?, updated_at = CURRENT_TIMESTAMP WHERE group_id = ?
`).run(groupTitle, groupId);
return this.getGroup(groupId)!;
}
// Insert new group
this.db.prepare(`
INSERT INTO groups (group_id, group_title, added_by)
VALUES (?, ?, ?)
`).run(groupId, groupTitle, addedBy);
logger.info('New group registered', { groupId, groupTitle, addedBy });
return this.getGroup(groupId)!;
}
/**
* Remove group
*/
removeGroup(groupId: number): void {
if (!this.db) return;
this.db.prepare(`DELETE FROM groups WHERE group_id = ?`).run(groupId);
logger.info('Group removed', { groupId });
}
/**
* Save group settings
*/
saveGroup(settings: GroupSettings): void {
if (!this.db) return;
this.db.prepare(`
UPDATE groups SET
group_title = ?,
enabled = ?,
draw_announcements = ?,
reminders = ?,
new_jackpot_announcement = ?,
ticket_purchase_allowed = ?,
reminder1_enabled = ?,
reminder1_time = ?,
reminder2_enabled = ?,
reminder2_time = ?,
reminder3_enabled = ?,
reminder3_time = ?,
announcement_delay_seconds = ?,
updated_at = CURRENT_TIMESTAMP
WHERE group_id = ?
`).run(
settings.groupTitle,
settings.enabled ? 1 : 0,
settings.drawAnnouncements ? 1 : 0,
settings.reminders ? 1 : 0,
settings.newJackpotAnnouncement ? 1 : 0,
settings.ticketPurchaseAllowed ? 1 : 0,
settings.reminder1Enabled ? 1 : 0,
JSON.stringify(settings.reminder1Time),
settings.reminder2Enabled ? 1 : 0,
JSON.stringify(settings.reminder2Time),
settings.reminder3Enabled ? 1 : 0,
JSON.stringify(settings.reminder3Time),
settings.announcementDelaySeconds,
settings.groupId
);
}
/**
* Update a boolean group setting
*/
updateGroupSetting(
groupId: number,
setting: string,
value: boolean
): GroupSettings | null {
const group = this.getGroup(groupId);
if (!group) return null;
(group as any)[setting] = value;
this.saveGroup(group);
return this.getGroup(groupId);
}
/**
* Update reminder time
*/
updateReminderTime(groupId: number, slot: 1 | 2 | 3, time: ReminderTime): GroupSettings | null {
const group = this.getGroup(groupId);
if (!group) return null;
switch (slot) {
case 1: group.reminder1Time = time; break;
case 2: group.reminder2Time = time; break;
case 3: group.reminder3Time = time; break;
}
this.saveGroup(group);
return this.getGroup(groupId);
}
/**
* Update announcement delay
*/
updateAnnouncementDelay(groupId: number, seconds: number): GroupSettings | null {
const group = this.getGroup(groupId);
if (!group) return null;
group.announcementDelaySeconds = seconds;
this.saveGroup(group);
return this.getGroup(groupId);
}
/**
* Get groups with a specific feature enabled
*/
getGroupsWithFeature(feature: 'enabled' | 'drawAnnouncements' | 'reminders'): GroupSettings[] {
if (!this.db) return [];
const column = feature === 'enabled' ? 'enabled'
: feature === 'drawAnnouncements' ? 'draw_announcements'
: 'reminders';
const rows = this.db.prepare(`
SELECT * FROM groups WHERE enabled = 1 AND ${column} = 1
`).all() as any[];
return rows.map(row => this.rowToGroup(row));
}
/**
* Get all groups
*/
getAllGroups(): GroupSettings[] {
if (!this.db) return [];
const rows = this.db.prepare(`SELECT * FROM groups`).all() as any[];
return rows.map(row => this.rowToGroup(row));
}
/**
* Convert database row to GroupSettings
*/
private rowToGroup(row: any): GroupSettings {
return {
groupId: row.group_id,
groupTitle: row.group_title || 'Group',
enabled: row.enabled === 1,
drawAnnouncements: row.draw_announcements === 1,
reminders: row.reminders === 1,
newJackpotAnnouncement: row.new_jackpot_announcement === 1,
ticketPurchaseAllowed: row.ticket_purchase_allowed === 1,
reminder1Enabled: row.reminder1_enabled === 1,
reminder1Time: row.reminder1_time ? JSON.parse(row.reminder1_time) : { value: 1, unit: 'hours' },
reminder2Enabled: row.reminder2_enabled === 1,
reminder2Time: row.reminder2_time ? JSON.parse(row.reminder2_time) : { value: 1, unit: 'days' },
reminder3Enabled: row.reminder3_enabled === 1,
reminder3Time: row.reminder3_time ? JSON.parse(row.reminder3_time) : { value: 6, unit: 'days' },
reminderTimes: [], // Legacy field
announcementDelaySeconds: row.announcement_delay_seconds || 10,
addedBy: row.added_by,
addedAt: new Date(row.added_at),
updatedAt: new Date(row.updated_at),
};
}
/**
* Close database connection
*/
close(): void {
if (this.db) {
this.db.close();
logger.info('Bot database connection closed');
}
}
}
export const botDatabase = new BotDatabase();
export default botDatabase;

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;

View File

@@ -0,0 +1,661 @@
import TelegramBot from 'node-telegram-bot-api';
import { groupStateManager } from './groupState';
import { stateManager } from './state';
import { apiClient } from './api';
import { logger } from './logger';
import { messages } from '../messages';
import { GroupSettings, reminderTimeToMinutes, formatReminderTime, ReminderTime, DEFAULT_GROUP_REMINDER_SLOTS } from '../types/groups';
import { TelegramUser } from '../types';
interface CycleInfo {
id: string;
scheduled_at: string;
status: string;
pot_total_sats: number;
}
interface ScheduledReminder {
groupId?: number;
telegramId?: number;
cycleId: string;
reminderKey: string;
scheduledFor: Date;
timeout: NodeJS.Timeout;
}
class NotificationScheduler {
private bot: TelegramBot | null = null;
private pollInterval: NodeJS.Timeout | null = null;
private scheduledReminders: Map<string, ScheduledReminder> = new Map();
private lastCycleId: string | null = null;
private lastCycleStatus: string | null = null;
private isRunning = false;
private announcedCycles: Set<string> = new Set(); // Track announced cycles
/**
* Initialize the scheduler with the bot instance
*/
init(bot: TelegramBot): void {
this.bot = bot;
logger.info('Notification scheduler initialized');
}
/**
* Start the scheduler
*/
start(): void {
if (this.isRunning || !this.bot) {
return;
}
this.isRunning = true;
logger.info('Starting notification scheduler');
// Poll every 30 seconds
this.pollInterval = setInterval(() => this.poll(), 30 * 1000);
// Run immediately
this.poll();
}
/**
* Stop the scheduler
*/
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
// Clear all scheduled reminders
for (const reminder of this.scheduledReminders.values()) {
clearTimeout(reminder.timeout);
}
this.scheduledReminders.clear();
this.isRunning = false;
logger.info('Notification scheduler stopped');
}
/**
* Main poll loop
*/
private async poll(): Promise<void> {
try {
const jackpot = await apiClient.getNextJackpot();
if (!jackpot?.cycle) {
return;
}
const cycle = jackpot.cycle;
const lottery = jackpot.lottery;
// Check for draw completion (same cycle, status changed to completed)
if (this.lastCycleId === cycle.id &&
this.lastCycleStatus !== 'completed' &&
cycle.status === 'completed') {
await this.handleDrawCompleted(cycle);
}
// Check for new cycle (new jackpot started)
// IMPORTANT: When we detect a new cycle, the old one is completed
// Send draw completion for the old cycle BEFORE new cycle announcement
if (this.lastCycleId && this.lastCycleId !== cycle.id) {
// The previous cycle has completed - announce the draw result first
await this.handlePreviousCycleCompleted(this.lastCycleId);
// Then announce the new cycle
await this.handleNewCycle(cycle, lottery);
}
// Schedule reminders for current cycle
await this.scheduleGroupReminders(cycle);
await this.scheduleUserReminders(cycle);
this.lastCycleId = cycle.id;
this.lastCycleStatus = cycle.status;
} catch (error) {
logger.error('Error in notification scheduler poll', { error });
}
}
/**
* Handle previous cycle completion when we detect a new cycle
*/
private async handlePreviousCycleCompleted(previousCycleId: string): Promise<void> {
if (!this.bot) return;
// Check if we've already announced this draw
if (this.announcedCycles.has(`draw:${previousCycleId}`)) {
return;
}
this.announcedCycles.add(`draw:${previousCycleId}`);
// Clear reminders for the old cycle
this.clearRemindersForCycle(previousCycleId);
// Get participants for the previous cycle
const participants = await stateManager.getCycleParticipants(previousCycleId);
const hasParticipants = participants.length > 0;
logger.info('Processing previous cycle completion', {
cycleId: previousCycleId,
participantCount: participants.length
});
// Get draw result details for winner announcement
let winnerDisplayName = 'Anon';
let winnerTicketNumber = '0000';
let potSats = 0;
// Notify each participant about their result
for (const participant of participants) {
try {
const user = await stateManager.getUser(participant.telegramId);
if (!user) continue;
// Check if they won
const status = await apiClient.getTicketStatus(participant.purchaseId);
if (!status) continue;
potSats = status.cycle.pot_total_sats || 0;
const isWinner = status.result.is_winner;
if (isWinner) {
// Store winner info for group announcement
winnerDisplayName = user.displayName || 'Anon';
const winningTicket = status.tickets.find(t => t.is_winning_ticket);
if (winningTicket) {
winnerTicketNumber = winningTicket.serial_number.toString().padStart(4, '0');
}
// Send winner notification if user has drawResults enabled
if (user.notifications?.drawResults !== false) {
const prizeSats = status.result.payout?.amount_sats || potSats;
const payoutStatus = status.result.payout?.status || 'processing';
await this.bot.sendMessage(
participant.telegramId,
messages.notifications.winner(
prizeSats.toLocaleString(),
winnerTicketNumber,
payoutStatus
),
{ parse_mode: 'Markdown' }
);
logger.info('Sent winner notification', { telegramId: participant.telegramId });
}
} else {
// Send loser notification if user has drawResults enabled
if (user.notifications?.drawResults !== false) {
await this.bot.sendMessage(
participant.telegramId,
messages.notifications.loser(
winnerTicketNumber,
potSats.toLocaleString()
),
{ parse_mode: 'Markdown' }
);
logger.debug('Sent draw result to participant', { telegramId: participant.telegramId });
}
}
} catch (error) {
logger.error('Failed to notify participant', {
telegramId: participant.telegramId,
error
});
}
}
// Send group announcements (even if no participants - groups might want to know)
if (hasParticipants) {
await this.sendGroupDrawAnnouncementsImmediate(
previousCycleId,
winnerDisplayName,
winnerTicketNumber,
potSats,
participants.length
);
}
}
/**
* Send draw announcements to groups immediately (no delay - for cycle transition)
*/
private async sendGroupDrawAnnouncementsImmediate(
cycleId: string,
winnerDisplayName: string,
winnerTicketNumber: string,
potSats: number,
totalParticipants: number
): Promise<void> {
if (!this.bot) return;
const groups = await groupStateManager.getGroupsWithFeature('drawAnnouncements');
for (const group of groups) {
try {
const message = messages.notifications.drawAnnouncement(
winnerDisplayName,
`#${winnerTicketNumber}`,
potSats.toLocaleString(),
totalParticipants
);
await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' });
logger.debug('Sent draw announcement to group', { groupId: group.groupId });
} catch (error) {
this.handleSendError(error, group.groupId);
}
}
logger.info('Draw announcements sent', {
cycleId,
groupCount: groups.length
});
}
/**
* Handle new cycle announcement
*/
private async handleNewCycle(
cycle: CycleInfo,
lottery: { name: string; ticket_price_sats: number }
): Promise<void> {
if (!this.bot) return;
// Check if we've already announced this cycle
if (this.announcedCycles.has(`new:${cycle.id}`)) {
return;
}
this.announcedCycles.add(`new:${cycle.id}`);
const drawTime = new Date(cycle.scheduled_at);
// Send to groups
const groups = await groupStateManager.getGroupsWithFeature('enabled');
for (const group of groups) {
if (group.newJackpotAnnouncement === false) continue;
try {
const message = messages.notifications.newJackpot(
lottery.name,
lottery.ticket_price_sats,
drawTime
);
await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' });
logger.debug('Sent new jackpot announcement to group', { groupId: group.groupId });
} catch (error) {
this.handleSendError(error, group.groupId);
}
}
// Send to users with new jackpot alerts enabled
const users = await stateManager.getUsersWithNotification('newJackpotAlerts');
for (const user of users) {
try {
const message = messages.notifications.newJackpot(
lottery.name,
lottery.ticket_price_sats,
drawTime
);
await this.bot.sendMessage(user.telegramId, message, { parse_mode: 'Markdown' });
logger.debug('Sent new jackpot alert to user', { telegramId: user.telegramId });
} catch (error) {
logger.error('Failed to send new jackpot alert', { telegramId: user.telegramId, error });
}
}
logger.info('New jackpot announcements sent', { cycleId: cycle.id });
}
/**
* Handle draw completed - send notifications to participants and groups
*/
private async handleDrawCompleted(cycle: CycleInfo): Promise<void> {
if (!this.bot) return;
// Check if we've already announced this draw
if (this.announcedCycles.has(`draw:${cycle.id}`)) {
return;
}
this.announcedCycles.add(`draw:${cycle.id}`);
// Clear reminders for this cycle
this.clearRemindersForCycle(cycle.id);
// Get participants for this cycle
const participants = await stateManager.getCycleParticipants(cycle.id);
const hasParticipants = participants.length > 0;
logger.info('Processing draw completion', {
cycleId: cycle.id,
participantCount: participants.length,
potSats: cycle.pot_total_sats
});
// Only proceed if there were participants
if (!hasParticipants) {
logger.info('No participants in cycle, skipping notifications', { cycleId: cycle.id });
return;
}
// Get draw result details for winner announcement
let winnerDisplayName = 'Anon';
let winnerTicketNumber = '0000';
// Notify each participant about their result
for (const participant of participants) {
try {
const user = await stateManager.getUser(participant.telegramId);
if (!user || !user.notifications?.drawResults) continue;
// Check if they won
const status = await apiClient.getTicketStatus(participant.purchaseId);
if (!status) continue;
const isWinner = status.result.is_winner;
if (isWinner) {
// Store winner info for group announcement
winnerDisplayName = user.displayName || 'Anon';
const winningTicket = status.tickets.find(t => t.is_winning_ticket);
if (winningTicket) {
winnerTicketNumber = winningTicket.serial_number.toString().padStart(4, '0');
}
// Send winner notification
const prizeSats = status.result.payout?.amount_sats || cycle.pot_total_sats;
const payoutStatus = status.result.payout?.status || 'processing';
await this.bot.sendMessage(
participant.telegramId,
messages.notifications.winner(
prizeSats.toLocaleString(),
winnerTicketNumber,
payoutStatus
),
{ parse_mode: 'Markdown' }
);
logger.info('Sent winner notification', { telegramId: participant.telegramId });
} else {
// Send loser notification
await this.bot.sendMessage(
participant.telegramId,
messages.notifications.loser(
winnerTicketNumber,
cycle.pot_total_sats.toLocaleString()
),
{ parse_mode: 'Markdown' }
);
logger.debug('Sent draw result to participant', { telegramId: participant.telegramId });
}
} catch (error) {
logger.error('Failed to notify participant', {
telegramId: participant.telegramId,
error
});
}
}
// Send group announcements (only if there were participants)
await this.sendGroupDrawAnnouncements(cycle, winnerDisplayName, winnerTicketNumber, participants.length);
}
/**
* Send draw announcements to groups
*/
private async sendGroupDrawAnnouncements(
cycle: CycleInfo,
winnerDisplayName: string,
winnerTicketNumber: string,
totalParticipants: number
): Promise<void> {
if (!this.bot) return;
const groups = await groupStateManager.getGroupsWithFeature('drawAnnouncements');
for (const group of groups) {
const delay = (group.announcementDelaySeconds || 0) * 1000;
setTimeout(async () => {
try {
const message = messages.notifications.drawAnnouncement(
winnerDisplayName,
`#${winnerTicketNumber}`,
cycle.pot_total_sats.toLocaleString(),
totalParticipants
);
if (this.bot) {
await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' });
logger.debug('Sent draw announcement to group', { groupId: group.groupId });
}
} catch (error) {
this.handleSendError(error, group.groupId);
}
}, delay);
}
logger.info('Draw announcements scheduled', {
cycleId: cycle.id,
groupCount: groups.length
});
}
/**
* Schedule reminders for groups (3-tier system with custom times)
*/
private async scheduleGroupReminders(cycle: CycleInfo): Promise<void> {
if (!this.bot || cycle.status === 'completed' || cycle.status === 'cancelled') {
return;
}
const drawTime = new Date(cycle.scheduled_at);
const now = new Date();
const groups = await groupStateManager.getGroupsWithFeature('reminders');
for (const group of groups) {
// Build list of enabled reminders from 3-tier system with custom times
const enabledReminders: { slot: number; time: ReminderTime }[] = [];
// Check each of the 3 reminder slots with their custom times
if (group.reminder1Enabled !== false) {
const time = group.reminder1Time || DEFAULT_GROUP_REMINDER_SLOTS[0];
enabledReminders.push({ slot: 1, time });
}
if (group.reminder2Enabled === true) {
const time = group.reminder2Time || DEFAULT_GROUP_REMINDER_SLOTS[1];
enabledReminders.push({ slot: 2, time });
}
if (group.reminder3Enabled === true) {
const time = group.reminder3Time || DEFAULT_GROUP_REMINDER_SLOTS[2];
enabledReminders.push({ slot: 3, time });
}
if (enabledReminders.length === 0) {
continue;
}
for (const { slot, time: reminderTime } of enabledReminders) {
const reminderKey = `slot${slot}_${formatReminderTime(reminderTime)}`;
const uniqueKey = `group:${group.groupId}:${cycle.id}:${reminderKey}`;
if (this.scheduledReminders.has(uniqueKey)) {
continue;
}
const minutesBefore = reminderTimeToMinutes(reminderTime);
const reminderDate = new Date(drawTime.getTime() - minutesBefore * 60 * 1000);
if (reminderDate <= now) {
continue;
}
const delay = reminderDate.getTime() - now.getTime();
const timeout = setTimeout(async () => {
await this.sendGroupReminder(group, cycle, reminderTime, drawTime);
this.scheduledReminders.delete(uniqueKey);
}, delay);
this.scheduledReminders.set(uniqueKey, {
groupId: group.groupId,
cycleId: cycle.id,
reminderKey,
scheduledFor: reminderDate,
timeout,
});
logger.debug('Scheduled group reminder', {
groupId: group.groupId,
cycleId: cycle.id,
slot,
reminderKey,
scheduledFor: reminderDate.toISOString(),
});
}
}
}
/**
* Schedule reminders for individual users with drawReminders enabled
*/
private async scheduleUserReminders(cycle: CycleInfo): Promise<void> {
if (!this.bot || cycle.status === 'completed' || cycle.status === 'cancelled') {
return;
}
const drawTime = new Date(cycle.scheduled_at);
const now = new Date();
// Get users with draw reminders enabled
const users = await stateManager.getUsersWithNotification('drawReminders');
// Default reminder: 15 minutes before
const defaultReminder: ReminderTime = { value: 15, unit: 'minutes' };
const reminderKey = formatReminderTime(defaultReminder);
for (const user of users) {
const uniqueKey = `user:${user.telegramId}:${cycle.id}:${reminderKey}`;
if (this.scheduledReminders.has(uniqueKey)) {
continue;
}
const minutesBefore = reminderTimeToMinutes(defaultReminder);
const reminderDate = new Date(drawTime.getTime() - minutesBefore * 60 * 1000);
if (reminderDate <= now) {
continue;
}
const delay = reminderDate.getTime() - now.getTime();
const timeout = setTimeout(async () => {
await this.sendUserReminder(user, cycle, defaultReminder, drawTime);
this.scheduledReminders.delete(uniqueKey);
}, delay);
this.scheduledReminders.set(uniqueKey, {
telegramId: user.telegramId,
cycleId: cycle.id,
reminderKey,
scheduledFor: reminderDate,
timeout,
});
}
}
/**
* Send a reminder to a group
*/
private async sendGroupReminder(
group: GroupSettings,
cycle: CycleInfo,
reminderTime: ReminderTime,
drawTime: Date
): Promise<void> {
if (!this.bot) return;
try {
const message = messages.notifications.drawReminder(
reminderTime.value,
reminderTime.unit,
drawTime,
cycle.pot_total_sats
);
await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' });
logger.info('Sent draw reminder to group', {
groupId: group.groupId,
reminderKey: formatReminderTime(reminderTime)
});
} catch (error) {
this.handleSendError(error, group.groupId);
}
}
/**
* Send a reminder to a user
*/
private async sendUserReminder(
user: TelegramUser,
cycle: CycleInfo,
reminderTime: ReminderTime,
drawTime: Date
): Promise<void> {
if (!this.bot) return;
try {
const message = messages.notifications.drawReminder(
reminderTime.value,
reminderTime.unit,
drawTime,
cycle.pot_total_sats
);
await this.bot.sendMessage(user.telegramId, message, { parse_mode: 'Markdown' });
logger.info('Sent draw reminder to user', { telegramId: user.telegramId });
} catch (error) {
logger.error('Failed to send reminder to user', { telegramId: user.telegramId, error });
}
}
/**
* Handle send errors (remove group if bot was kicked)
*/
private handleSendError(error: any, groupId: number): void {
logger.error('Failed to send message to group', { groupId, error });
if (error?.response?.statusCode === 403) {
groupStateManager.removeGroup(groupId);
}
}
/**
* Clear all scheduled reminders for a cycle
*/
private clearRemindersForCycle(cycleId: string): void {
for (const [key, reminder] of this.scheduledReminders.entries()) {
if (reminder.cycleId === cycleId) {
clearTimeout(reminder.timeout);
this.scheduledReminders.delete(key);
}
}
}
/**
* Get status info for debugging
*/
getStatus(): object {
return {
isRunning: this.isRunning,
lastCycleId: this.lastCycleId,
lastCycleStatus: this.lastCycleStatus,
scheduledReminders: this.scheduledReminders.size,
announcedCycles: this.announcedCycles.size,
};
}
}
export const notificationScheduler = new NotificationScheduler();
export default notificationScheduler;

View File

@@ -1,130 +1,31 @@
import Redis from 'ioredis';
import config from '../config';
import { botDatabase } from './database';
import { logger } from './logger';
import {
TelegramUser,
UserState,
AwaitingPaymentData,
NotificationPreferences,
DEFAULT_NOTIFICATIONS,
} from '../types';
const STATE_PREFIX = 'tg_user:';
const PURCHASE_PREFIX = 'tg_purchase:';
const USER_PURCHASES_PREFIX = 'tg_user_purchases:';
const STATE_TTL = 60 * 60 * 24 * 30; // 30 days
class StateManager {
private redis: Redis | null = null;
private memoryStore: Map<string, string> = new Map();
private useRedis: boolean = false;
async init(): Promise<void> {
if (config.redis.url) {
try {
this.redis = new Redis(config.redis.url);
this.redis.on('error', (error) => {
logger.error('Redis connection error', { error: error.message });
});
this.redis.on('connect', () => {
logger.info('Connected to Redis');
});
// Test connection
await this.redis.ping();
this.useRedis = true;
logger.info('State manager initialized with Redis');
} catch (error) {
logger.warn('Failed to connect to Redis, falling back to in-memory store', {
error: (error as Error).message,
});
this.redis = null;
this.useRedis = false;
}
} else {
logger.info('State manager initialized with in-memory store');
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 lpush(key: string, value: string): Promise<void> {
if (this.useRedis && this.redis) {
await this.redis.lpush(key, value);
await this.redis.ltrim(key, 0, 99); // Keep last 100 purchases
} else {
const existing = this.memoryStore.get(key);
const list = existing ? JSON.parse(existing) : [];
list.unshift(value);
if (list.length > 100) list.pop();
this.memoryStore.set(key, JSON.stringify(list));
}
}
private async lrange(key: string, start: number, stop: number): Promise<string[]> {
if (this.useRedis && this.redis) {
return await this.redis.lrange(key, start, stop);
}
const existing = this.memoryStore.get(key);
if (!existing) return [];
const list = JSON.parse(existing);
return list.slice(start, stop + 1);
// Database is initialized separately
logger.info('State manager initialized (using SQLite database)');
}
/**
* Get or create user
* Get user by Telegram ID
*/
async getUser(telegramId: number): Promise<TelegramUser | null> {
const key = `${STATE_PREFIX}${telegramId}`;
const data = await this.get(key);
if (!data) return null;
try {
const user = JSON.parse(data);
return {
...user,
createdAt: new Date(user.createdAt),
updatedAt: new Date(user.updatedAt),
};
} catch (error) {
logger.error('Failed to parse user data', { telegramId, error });
return null;
}
return botDatabase.getUser(telegramId);
}
/**
* Create or update user
* Save user
*/
async saveUser(user: TelegramUser): Promise<void> {
const key = `${STATE_PREFIX}${user.telegramId}`;
user.updatedAt = new Date();
await this.set(key, JSON.stringify(user), STATE_TTL);
botDatabase.saveUser(user);
logger.debug('User saved', { telegramId: user.telegramId, state: user.state });
}
@@ -137,18 +38,7 @@ class StateManager {
firstName?: string,
lastName?: string
): Promise<TelegramUser> {
const user: TelegramUser = {
telegramId,
username,
firstName,
lastName,
state: 'awaiting_lightning_address',
createdAt: new Date(),
updatedAt: new Date(),
};
await this.saveUser(user);
logger.info('New user created', { telegramId, username });
return user;
return botDatabase.createUser(telegramId, username, firstName, lastName);
}
/**
@@ -159,14 +49,7 @@ class StateManager {
state: UserState,
stateData?: Record<string, any>
): Promise<void> {
const user = await this.getUser(telegramId);
if (!user) {
logger.warn('Attempted to update state for non-existent user', { telegramId });
return;
}
user.state = state;
user.stateData = stateData;
await this.saveUser(user);
botDatabase.updateUserState(telegramId, state, stateData);
}
/**
@@ -176,15 +59,25 @@ class StateManager {
telegramId: number,
lightningAddress: string
): Promise<void> {
const user = await this.getUser(telegramId);
if (!user) {
logger.warn('Attempted to update address for non-existent user', { telegramId });
return;
}
user.lightningAddress = lightningAddress;
user.state = 'idle';
user.stateData = undefined;
await this.saveUser(user);
botDatabase.updateLightningAddress(telegramId, lightningAddress);
}
/**
* Update user display name
*/
async updateDisplayName(telegramId: number, displayName: string): Promise<void> {
botDatabase.updateDisplayName(telegramId, displayName);
logger.info('Display name updated', { telegramId, displayName });
}
/**
* Update user notification preferences
*/
async updateNotifications(
telegramId: number,
updates: Partial<NotificationPreferences>
): Promise<TelegramUser | null> {
return botDatabase.updateNotifications(telegramId, updates);
}
/**
@@ -195,33 +88,36 @@ class StateManager {
purchaseId: string,
data: AwaitingPaymentData
): Promise<void> {
// Store purchase data
const purchaseKey = `${PURCHASE_PREFIX}${purchaseId}`;
await this.set(purchaseKey, JSON.stringify({
telegramId,
...data,
createdAt: new Date().toISOString(),
}), STATE_TTL);
// Add to user's purchase list
const userPurchasesKey = `${USER_PURCHASES_PREFIX}${telegramId}`;
await this.lpush(userPurchasesKey, purchaseId);
botDatabase.storePurchase(telegramId, purchaseId, {
cycleId: data.cycleId,
ticketCount: data.ticketCount,
totalAmount: data.totalAmount,
lightningAddress: data.paymentRequest ? '' : '', // Not storing sensitive data
paymentRequest: data.paymentRequest,
publicUrl: data.publicUrl,
});
}
/**
* Get purchase data
*/
async getPurchase(purchaseId: string): Promise<(AwaitingPaymentData & { telegramId: number }) | null> {
const key = `${PURCHASE_PREFIX}${purchaseId}`;
const data = await this.get(key);
if (!data) return null;
try {
return JSON.parse(data);
} catch (error) {
logger.error('Failed to parse purchase data', { purchaseId, error });
return null;
}
const purchase = botDatabase.getPurchase(purchaseId);
if (!purchase) return null;
return {
telegramId: purchase.telegram_id,
cycleId: purchase.cycle_id,
ticketCount: purchase.ticket_count,
scheduledAt: '', // Not stored locally
ticketPrice: 0, // Not stored locally
totalAmount: purchase.amount_sats,
lotteryName: '', // Not stored locally
purchaseId: purchase.purchase_id,
paymentRequest: purchase.payment_request,
publicUrl: purchase.public_url,
pollStartTime: 0,
};
}
/**
@@ -231,33 +127,62 @@ class StateManager {
telegramId: number,
limit: number = 10
): Promise<string[]> {
const key = `${USER_PURCHASES_PREFIX}${telegramId}`;
return await this.lrange(key, 0, limit - 1);
return botDatabase.getUserPurchaseIds(telegramId, limit);
}
/**
* Clear user state data (keeping lightning address)
*/
async clearUserStateData(telegramId: number): Promise<void> {
const user = await this.getUser(telegramId);
if (!user) return;
user.state = 'idle';
user.stateData = undefined;
await this.saveUser(user);
botDatabase.updateUserState(telegramId, 'idle', undefined);
}
/**
* Add user as participant to a cycle
*/
async addCycleParticipant(cycleId: string, telegramId: number, purchaseId: string): Promise<void> {
botDatabase.addCycleParticipant(cycleId, telegramId, purchaseId);
logger.debug('Added cycle participant', { cycleId, telegramId });
}
/**
* Get all participants for a cycle
*/
async getCycleParticipants(cycleId: string): Promise<Array<{ telegramId: number; purchaseId: string }>> {
return botDatabase.getCycleParticipants(cycleId);
}
/**
* Check if user participated in a cycle
*/
async didUserParticipate(cycleId: string, telegramId: number): Promise<boolean> {
return botDatabase.didUserParticipate(cycleId, telegramId);
}
/**
* Get all users with specific notification preference enabled
*/
async getUsersWithNotification(
preference: keyof NotificationPreferences
): Promise<TelegramUser[]> {
return botDatabase.getUsersWithNotification(preference);
}
/**
* Get user's display name (for announcements)
*/
getDisplayName(user: TelegramUser): string {
return user.displayName || 'Anon';
}
/**
* Shutdown
*/
async close(): Promise<void> {
if (this.redis) {
await this.redis.quit();
logger.info('Redis connection closed');
}
// Database close is handled separately
logger.info('State manager closed');
}
}
export const stateManager = new StateManager();
export default stateManager;