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:
@@ -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;
|
||||
|
||||
649
telegram_bot/src/services/database.ts
Normal file
649
telegram_bot/src/services/database.ts
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
661
telegram_bot/src/services/notificationScheduler.ts
Normal file
661
telegram_bot/src/services/notificationScheduler.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user