607 lines
40 KiB
Python
607 lines
40 KiB
Python
import aiosqlite
|
|
import logging
|
|
from typing import Optional ,Dict ,Any ,List ,Tuple
|
|
from datetime import datetime ,timezone
|
|
|
|
from config .settings import get_settings
|
|
|
|
|
|
try :
|
|
settings =get_settings ()
|
|
DB_NAME =settings .DB_NAME
|
|
except Exception as e :
|
|
logging .critical (f"Could not load settings for database.py: {e}",exc_info =True )
|
|
DB_NAME ="bot_database.sqlite3"
|
|
|
|
DB_BUSY_TIMEOUT_SECONDS =15.0
|
|
|
|
def get_db_connection_manager ():
|
|
"""
|
|
Возвращает awaitable/async context manager для соединения с SQLite.
|
|
"""
|
|
return aiosqlite .connect (DB_NAME ,timeout =DB_BUSY_TIMEOUT_SECONDS )
|
|
|
|
async def _setup_db_connection (db :aiosqlite .Connection ):
|
|
"""Применяет необходимые PRAGMA и row_factory к установленному соединению."""
|
|
try :
|
|
await db .execute ("PRAGMA journal_mode=WAL;")
|
|
await db .execute (f"PRAGMA busy_timeout = {int(DB_BUSY_TIMEOUT_SECONDS * 1000)};")
|
|
db .row_factory =aiosqlite .Row
|
|
except Exception as e :
|
|
logging .error (f"Failed to set PRAGMAs or row_factory on connection: {e}",exc_info =True )
|
|
|
|
async def init_db ():
|
|
"""Инициализирует схему БД."""
|
|
try :
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db )
|
|
|
|
|
|
await db .execute ("""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
user_id INTEGER PRIMARY KEY,
|
|
username TEXT,
|
|
first_name TEXT,
|
|
last_name TEXT,
|
|
language_code TEXT DEFAULT 'en',
|
|
registration_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string, e.g. YYYY-MM-DD HH:MM:SS */
|
|
is_banned INTEGER DEFAULT 0,
|
|
panel_user_uuid TEXT UNIQUE,
|
|
referred_by_id INTEGER,
|
|
FOREIGN KEY (referred_by_id) REFERENCES users(user_id)
|
|
)
|
|
""")
|
|
await db .execute ("CREATE INDEX IF NOT EXISTS idx_users_panel_user_uuid ON users (panel_user_uuid);")
|
|
|
|
|
|
await db .execute ("""
|
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
subscription_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER,
|
|
panel_user_uuid TEXT NOT NULL,
|
|
panel_subscription_uuid TEXT UNIQUE,
|
|
start_date TEXT, /* Storing as ISO TEXT */
|
|
end_date TEXT NOT NULL, /* Storing as ISO TEXT */
|
|
duration_months INTEGER,
|
|
is_active INTEGER DEFAULT 1,
|
|
status_from_panel TEXT,
|
|
traffic_limit_bytes INTEGER,
|
|
traffic_used_bytes INTEGER,
|
|
last_notification_sent TEXT /* Storing as TEXT YYYY-MM-DD */
|
|
)""")
|
|
await db .execute ("CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions (user_id);")
|
|
await db .execute ("CREATE INDEX IF NOT EXISTS idx_subscriptions_panel_user_uuid ON subscriptions (panel_user_uuid);")
|
|
await db .execute ("CREATE INDEX IF NOT EXISTS idx_subscriptions_end_date ON subscriptions (end_date);")
|
|
await db .execute ("CREATE INDEX IF NOT EXISTS idx_subscriptions_is_active ON subscriptions (is_active);")
|
|
|
|
|
|
await db .execute ("""
|
|
CREATE TABLE IF NOT EXISTS payments (
|
|
payment_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
yookassa_payment_id TEXT UNIQUE,
|
|
idempotence_key TEXT UNIQUE,
|
|
amount REAL NOT NULL,
|
|
currency TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
description TEXT,
|
|
subscription_duration_months INTEGER,
|
|
promo_code_id INTEGER,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
|
|
updated_at TIMESTAMP, /* TEXT as ISO8601 string */
|
|
FOREIGN KEY (user_id) REFERENCES users(user_id),
|
|
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(promo_code_id)
|
|
)""")
|
|
await db .execute ("CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments (user_id);")
|
|
await db .execute ("CREATE INDEX IF NOT EXISTS idx_payments_yookassa_payment_id ON payments (yookassa_payment_id);")
|
|
|
|
|
|
await db .execute ("""
|
|
CREATE TABLE IF NOT EXISTS promo_codes (
|
|
promo_code_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
code TEXT UNIQUE NOT NULL,
|
|
bonus_days INTEGER NOT NULL,
|
|
max_activations INTEGER NOT NULL,
|
|
current_activations INTEGER DEFAULT 0,
|
|
is_active INTEGER DEFAULT 1,
|
|
created_by_admin_id INTEGER NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
|
|
valid_until TEXT NULL /* Storing as ISO TEXT YYYY-MM-DD HH:MM:SS */
|
|
)""")
|
|
await db .execute ("CREATE INDEX IF NOT EXISTS idx_promo_codes_code ON promo_codes (code);")
|
|
|
|
|
|
await db .execute ("""
|
|
CREATE TABLE IF NOT EXISTS promo_code_activations (
|
|
activation_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
promo_code_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
activated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
|
|
payment_id INTEGER,
|
|
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(promo_code_id),
|
|
FOREIGN KEY (user_id) REFERENCES users(user_id),
|
|
FOREIGN KEY (payment_id) REFERENCES payments(payment_id),
|
|
UNIQUE (promo_code_id, user_id)
|
|
)""")
|
|
|
|
|
|
await db .execute ("""
|
|
CREATE TABLE IF NOT EXISTS message_logs (
|
|
log_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER,
|
|
telegram_username TEXT,
|
|
telegram_first_name TEXT,
|
|
event_type TEXT NOT NULL,
|
|
content TEXT,
|
|
raw_update_preview TEXT,
|
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
|
|
is_admin_event INTEGER DEFAULT 0,
|
|
target_user_id INTEGER,
|
|
FOREIGN KEY (user_id) REFERENCES users(user_id)
|
|
)
|
|
""")
|
|
await db .execute ("CREATE INDEX IF NOT EXISTS idx_message_logs_user_id ON message_logs (user_id);")
|
|
await db .execute ("CREATE INDEX IF NOT EXISTS idx_message_logs_event_type ON message_logs (event_type);")
|
|
await db .execute ("CREATE INDEX IF NOT EXISTS idx_message_logs_timestamp ON message_logs (timestamp);")
|
|
|
|
|
|
await db .execute ("""
|
|
CREATE TABLE IF NOT EXISTS panel_sync_status (
|
|
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
|
last_sync_time TIMESTAMP, /* TEXT as ISO8601 string */
|
|
status TEXT,
|
|
details TEXT,
|
|
users_processed_from_panel INTEGER DEFAULT 0,
|
|
subscriptions_synced INTEGER DEFAULT 0
|
|
)""")
|
|
await db .execute ("INSERT OR IGNORE INTO panel_sync_status (id, status, details) VALUES (1, 'never_run', 'System initialized')")
|
|
|
|
await db .commit ()
|
|
logging .info ("Database initialized/checked successfully.")
|
|
except Exception as e :
|
|
logging .error (f"Failed to initialize database: {e}",exc_info =True );raise
|
|
|
|
async def add_user_if_not_exists (user_id :int ,username :Optional [str ],first_name :Optional [str ],last_name :Optional [str ],lang_code :str ='en',referred_by_id :Optional [int ]=None ,panel_user_uuid :Optional [str ]=None )->Tuple [bool ,bool ]:
|
|
"""Adds user if not exists. Sets referred_by_id for new users. Returns (success, was_new_user_flag)."""
|
|
was_new_user =False
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db )
|
|
try :
|
|
cursor =await db .execute ("SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned FROM users WHERE user_id = ?",(user_id ,))
|
|
existing_user =await cursor .fetchone ();await cursor .close ()
|
|
if existing_user :
|
|
update_fields ={}
|
|
if username is not None and username !=existing_user ['username']:update_fields ['username']=username
|
|
if first_name is not None and first_name !=existing_user ['first_name']:update_fields ['first_name']=first_name
|
|
if last_name is not None and last_name !=existing_user ['last_name']:update_fields ['last_name']=last_name
|
|
|
|
if panel_user_uuid and existing_user ['panel_user_uuid']is None :update_fields ['panel_user_uuid']=panel_user_uuid
|
|
|
|
if lang_code !=existing_user ['language_code']:update_fields ['language_code']=lang_code
|
|
if update_fields :
|
|
set_clause =", ".join ([f"{field} = ?"for field in update_fields .keys ()]);params =list (update_fields .values ())+[user_id ]
|
|
await db .execute (f"UPDATE users SET {set_clause} WHERE user_id = ?",tuple (params ))
|
|
else :
|
|
await db .execute ("""INSERT INTO users (user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned) VALUES (?, ?, ?, ?, ?, ?, ?, 0)""",(user_id ,username ,first_name ,last_name ,lang_code ,referred_by_id ,panel_user_uuid ))
|
|
logging .info (f"New user {user_id} added. Referred by: {referred_by_id or 'N/A'}.")
|
|
was_new_user =True
|
|
await db .commit ();return True ,was_new_user
|
|
except aiosqlite .IntegrityError as e :
|
|
if "UNIQUE constraint failed: users.panel_user_uuid"in str (e )and panel_user_uuid :
|
|
res =await db .execute ("UPDATE users SET user_id = ?, username = ?, first_name = ?, last_name = ?, language_code = COALESCE(?, language_code) WHERE panel_user_uuid = ? AND user_id IS NULL",(user_id ,username ,first_name ,last_name ,lang_code ,panel_user_uuid ))
|
|
if res .rowcount >0 :await db .commit ();logging .info (f"Linked panel user {panel_user_uuid} to TG {user_id}.");return True ,False
|
|
else :logging .error (f"Conflict: Panel UUID {panel_user_uuid} exists.");await db .rollback ();return False ,False
|
|
else :logging .error (f"DB integrity error for user {user_id}: {e}");await db .rollback ();return False ,False
|
|
except Exception as e :logging .error (f"DB error in add_user_if_not_exists for user {user_id}: {e}",exc_info =True );await db .rollback ();return False ,False
|
|
|
|
async def get_user (user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [aiosqlite .Row ]:
|
|
sql ="SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE user_id = ?"
|
|
if db_conn :
|
|
cursor =await db_conn .execute (sql ,(user_id ,));row =await cursor .fetchone ();
|
|
if cursor :await cursor .close ();return row
|
|
else :
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute (sql ,(user_id ,));row =await cursor .fetchone ();
|
|
if cursor :await cursor .close ();return row
|
|
|
|
async def get_user_by_telegram_username (username :str ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [aiosqlite .Row ]:
|
|
sql ="SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE LOWER(username) = LOWER(?)"
|
|
clean_username =username .lstrip ('@')
|
|
if db_conn :
|
|
cursor =await db_conn .execute (sql ,(clean_username ,));row =await cursor .fetchone ();
|
|
if cursor :await cursor .close ();return row
|
|
else :
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute (sql ,(clean_username ,));row =await cursor .fetchone ();
|
|
if cursor :await cursor .close ();return row
|
|
|
|
async def get_user_by_panel_uuid (panel_user_uuid :str ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [aiosqlite .Row ]:
|
|
sql ="SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE panel_user_uuid = ?"
|
|
if db_conn :
|
|
cursor =await db_conn .execute (sql ,(panel_user_uuid ,));row =await cursor .fetchone ();
|
|
if cursor :await cursor .close ();return row
|
|
else :
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute (sql ,(panel_user_uuid ,));row =await cursor .fetchone ();
|
|
if cursor :await cursor .close ();return row
|
|
|
|
async def get_banned_users_list_paginated (limit :int ,offset :int )->Tuple [List [aiosqlite .Row ],int ]:
|
|
sql_users ="SELECT user_id, username, first_name, last_name FROM users WHERE is_banned = 1 ORDER BY registration_date DESC LIMIT ? OFFSET ?"
|
|
sql_count ="SELECT COUNT(*) as total_banned FROM users WHERE is_banned = 1"
|
|
users_list :List [aiosqlite .Row ]=[]
|
|
total_banned =0
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db )
|
|
cursor_users =await db .execute (sql_users ,(limit ,offset ));users_list =await cursor_users .fetchall ();await cursor_users .close ()
|
|
cursor_count =await db .execute (sql_count );count_row =await cursor_count .fetchone ();await cursor_count .close ()
|
|
if count_row :total_banned =count_row ['total_banned']
|
|
return users_list ,total_banned
|
|
|
|
async def get_user_active_subscription_end_date (user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [str ]:
|
|
sql ="SELECT strftime('%Y-%m-%d', end_date) as end_date_str FROM subscriptions WHERE user_id = ? AND is_active = 1 AND DATETIME(end_date) > DATETIME('now', 'localtime') ORDER BY end_date DESC LIMIT 1"
|
|
if db_conn :
|
|
cursor =await db_conn .execute (sql ,(user_id ,));row =await cursor .fetchone ();
|
|
if cursor :await cursor .close ();return row ['end_date_str']if row else None
|
|
else :
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute (sql ,(user_id ,));row =await cursor .fetchone ();
|
|
if cursor :await cursor .close ();return row ['end_date_str']if row else None
|
|
|
|
async def update_user_language_code (user_id :int ,lang_code :str ):
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );await db .execute ("UPDATE users SET language_code = ? WHERE user_id = ?",(lang_code ,user_id ));await db .commit ()
|
|
|
|
async def set_user_ban_status_db (user_id :int ,is_banned :bool )->bool :
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );
|
|
try :await db .execute ("UPDATE users SET is_banned = ? WHERE user_id = ?",(1 if is_banned else 0 ,user_id ));await db .commit ();return True
|
|
except Exception as e :logging .error (f"Error setting ban status for {user_id}: {e}");await db .rollback ();return False
|
|
|
|
async def update_user_panel_uuid (user_id :int ,panel_user_uuid :str ,db_conn :Optional [aiosqlite .Connection ]=None ):
|
|
async def _operation (db_op :aiosqlite .Connection ):
|
|
try :await db_op .execute ("UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",(panel_user_uuid ,user_id ));await db_op .commit ()
|
|
except aiosqlite .IntegrityError :logging .error (f"Failed to update panel_uuid for user {user_id} (UNIQUE constraint).");await db_op .rollback ()
|
|
except Exception as e :logging .error (f"Error in update_user_panel_uuid for {user_id}: {e}");await db_op .rollback ()
|
|
if db_conn :await _setup_db_connection (db_conn )if not db_conn .row_factory else None ;await _operation (db_conn )
|
|
else :
|
|
async with get_db_connection_manager ()as db :await _setup_db_connection (db );await _operation (db )
|
|
|
|
async def update_or_create_user_from_panel (panel_data :Dict [str ,Any ],db_conn :Optional [aiosqlite .Connection ]=None )->bool :
|
|
panel_uuid =panel_data .get ('uuid');username =panel_data .get ('username');
|
|
if not panel_uuid :logging .warning ("update_or_create_user_from_panel: panel_uuid missing.");return False
|
|
async def _operation (db_op :aiosqlite .Connection ):
|
|
existing_user =await get_user_by_panel_uuid (panel_uuid ,db_conn =db_op )
|
|
if not existing_user :
|
|
reg_date_str =panel_data .get ('createdAt',datetime .utcnow ().isoformat ())
|
|
if isinstance (reg_date_str ,datetime ):reg_date_str =reg_date_str .isoformat ()
|
|
await db_op .execute ("INSERT INTO users (panel_user_uuid, username, registration_date, is_banned) VALUES (?, ?, ?, 0)",(panel_uuid ,username ,reg_date_str ))
|
|
await db_op .commit ();return True
|
|
try :
|
|
if db_conn :await _setup_db_connection (db_conn )if not db_conn .row_factory else None ;return await _operation (db_conn )
|
|
else :
|
|
async with get_db_connection_manager ()as db :await _setup_db_connection (db );return await _operation (db )
|
|
except Exception as e :logging .error (f"Error in update_or_create_user_from_panel panel_uuid {panel_uuid}: {e}",exc_info =True );return False
|
|
|
|
async def update_or_create_subscription_from_panel (panel_user_uuid :str ,sub_data :Dict [str ,Any ],db_conn :Optional [aiosqlite .Connection ]=None )->bool :
|
|
async def _operation (db_op :aiosqlite .Connection ):
|
|
cursor =await db_op .execute ("SELECT user_id FROM users WHERE panel_user_uuid = ?",(panel_user_uuid ,));user_row =await cursor .fetchone ();await cursor .close ()
|
|
bot_user_id =user_row ['user_id']if user_row else None
|
|
panel_sub_link_uuid =sub_data .get ('subscriptionUuid')or sub_data .get ('shortUuid');
|
|
if not panel_sub_link_uuid :logging .warning (f"No panel_subscription_uuid or shortUuid for panel user {panel_user_uuid}");return False
|
|
end_date_str =sub_data .get ('expireAt');end_date =None
|
|
if end_date_str :
|
|
try :end_date =datetime .fromisoformat (end_date_str .replace ("Z","+00:00"))
|
|
except ValueError :logging .warning (f"Bad expireAt: {end_date_str} for {panel_user_uuid}");return False
|
|
if not end_date :logging .warning (f"No end_date for {panel_user_uuid}");return False
|
|
status_panel =sub_data .get ('status','UNKNOWN').upper ();is_active_panel =1 if status_panel =='ACTIVE'else 0
|
|
traffic_limit =sub_data .get ('trafficLimitBytes');traffic_used =sub_data .get ('usedTrafficBytes',sub_data .get ('lifetimeUsedTrafficBytes'))
|
|
start_date_iso =sub_data .get ('createdAt',datetime .utcnow ().isoformat ());
|
|
if isinstance (start_date_iso ,datetime ):start_date_iso =start_date_iso .isoformat ()
|
|
duration_months_val =sub_data .get ('duration_months')
|
|
upsert_sql =""" INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel, traffic_limit_bytes, traffic_used_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = excluded.duration_months, is_active = excluded.is_active, status_from_panel = excluded.status_from_panel, traffic_limit_bytes = excluded.traffic_limit_bytes, traffic_used_bytes = excluded.traffic_used_bytes, last_notification_sent = NULL; """
|
|
params =(bot_user_id ,panel_user_uuid ,panel_sub_link_uuid ,start_date_iso ,end_date .isoformat (),duration_months_val ,is_active_panel ,status_panel ,traffic_limit ,traffic_used )
|
|
await db_op .execute (upsert_sql ,params );await db_op .commit ();return True
|
|
try :
|
|
if db_conn :await _setup_db_connection (db_conn )if not db_conn .row_factory else None ;return await _operation (db_conn )
|
|
else :
|
|
async with get_db_connection_manager ()as db :await _setup_db_connection (db );return await _operation (db )
|
|
except Exception as e :logging .error (f"Error in update_or_create_subscription_from_panel for {panel_user_uuid}: {e}",exc_info =True );return False
|
|
|
|
async def update_sync_status (status :str ,details :str ,users_processed :int =0 ,subs_synced :int =0 ):
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );await db .execute ("UPDATE panel_sync_status SET last_sync_time=CURRENT_TIMESTAMP, status=?, details=?, users_processed_from_panel=?, subscriptions_synced=? WHERE id=1",(status ,details ,users_processed ,subs_synced ));await db .commit ()
|
|
|
|
async def get_last_sync_status ()->Optional [aiosqlite .Row ]:
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute ("SELECT * FROM panel_sync_status WHERE id = 1");row =await cursor .fetchone ();await cursor .close ();return row
|
|
|
|
async def get_all_users_for_broadcast ()->List [aiosqlite .Row ]:
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute ("SELECT user_id FROM users WHERE user_id IS NOT NULL AND is_banned = 0");rows =await cursor .fetchall ();await cursor .close ();return rows
|
|
|
|
async def get_user_count_stats ()->Dict [str ,int ]:
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db )
|
|
cursor =await db .execute ("SELECT COUNT(DISTINCT user_id) as count FROM users WHERE user_id IS NOT NULL");total_users_c_row =await cursor .fetchone ();await cursor .close ()
|
|
cursor =await db .execute ("SELECT COUNT(DISTINCT user_id) as count FROM users WHERE user_id IS NOT NULL AND is_banned = 1");banned_users_c_row =await cursor .fetchone ();await cursor .close ()
|
|
cursor =await db .execute ("SELECT COUNT(DISTINCT user_id) as count FROM subscriptions WHERE user_id IS NOT NULL AND is_active = 1 AND DATETIME(end_date) > DATETIME('now', 'localtime')");active_subs_c_row =await cursor .fetchone ();await cursor .close ()
|
|
return {"total_users":total_users_c_row ['count']if total_users_c_row else 0 ,"banned_users":banned_users_c_row ['count']if banned_users_c_row else 0 ,"users_with_active_subscriptions":active_subs_c_row ['count']if active_subs_c_row else 0 }
|
|
|
|
async def get_payment_logs (limit :int =20 ,offset :int =0 )->List [aiosqlite .Row ]:
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute ("""SELECT p.payment_id, p.user_id, u.username, p.amount, p.currency, p.status, p.description, strftime('%Y-%m-%d %H:%M:%S', p.created_at) as created_at FROM payments p LEFT JOIN users u ON p.user_id = u.user_id ORDER BY p.created_at DESC LIMIT ? OFFSET ?""",(limit ,offset ));rows =await cursor .fetchall ();await cursor .close ();return rows
|
|
|
|
async def log_user_action (user_id :Optional [int ],event_type :str ,content :Optional [str ]=None ,telegram_username :Optional [str ]=None ,telegram_first_name :Optional [str ]=None ,is_admin_event :bool =False ,target_user_id :Optional [int ]=None ,raw_update_preview :Optional [str ]=None ):
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );
|
|
try :
|
|
await db .execute ("""INSERT INTO message_logs (user_id, telegram_username, telegram_first_name, event_type, content, raw_update_preview, is_admin_event, target_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",(user_id ,telegram_username ,telegram_first_name ,event_type ,content ,raw_update_preview ,1 if is_admin_event else 0 ,target_user_id ))
|
|
await db .commit ()
|
|
except Exception as e :logging .error (f"Failed to log action for user {user_id}, type {event_type}: {e}",exc_info =True )
|
|
|
|
async def get_message_logs_db (limit :int =20 ,offset :int =0 )->List [aiosqlite .Row ]:
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute ("""SELECT log_id, user_id, telegram_username, telegram_first_name, event_type, content, strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str, is_admin_event FROM message_logs ORDER BY timestamp DESC LIMIT ? OFFSET ?""",(limit ,offset ));rows =await cursor .fetchall ();await cursor .close ();return rows
|
|
|
|
async def count_all_message_logs ()->int :
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute ("SELECT COUNT(*) as count FROM message_logs");row =await cursor .fetchone ();await cursor .close ();return row ['count']if row else 0
|
|
|
|
async def get_user_message_logs_paginated (user_id_to_search :int ,limit :int ,offset :int )->List [aiosqlite .Row ]:
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute ("""SELECT log_id, user_id, telegram_username, telegram_first_name, event_type, content, strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str FROM message_logs WHERE user_id = ? OR target_user_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?""",(user_id_to_search ,user_id_to_search ,limit ,offset ));rows =await cursor .fetchall ();await cursor .close ();return rows
|
|
|
|
async def count_user_message_logs (user_id_to_search :int )->int :
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute ("SELECT COUNT(*) as count FROM message_logs WHERE user_id = ? OR target_user_id = ?",(user_id_to_search ,user_id_to_search ));row =await cursor .fetchone ();await cursor .close ();return row ['count']if row else 0
|
|
|
|
async def add_payment_record (user_id :int ,yookassa_payment_id :Optional [str ],idempotence_key :Optional [str ],amount :float ,currency :str ,status :str ,description :str ,sub_months :int ,promo_id :Optional [int ]=None )->Optional [int ]:
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );
|
|
try :
|
|
cursor =await db .execute ("""INSERT INTO payments (user_id, yookassa_payment_id, idempotence_key, amount, currency, status, description, subscription_duration_months, promo_code_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",(user_id ,yookassa_payment_id ,idempotence_key ,amount ,currency ,status ,description ,sub_months ,promo_id ))
|
|
await db .commit ();return cursor .lastrowid
|
|
except Exception as e :logging .error (f"Failed to add payment record user {user_id}, yk_id {yookassa_payment_id}: {e}");await db .rollback ();return None
|
|
|
|
async def update_payment_status (payment_db_id :Optional [int ]=None ,yookassa_payment_id :Optional [str ]=None ,new_status :Optional [str ]=None ,db_conn :Optional [aiosqlite .Connection ]=None ):
|
|
async def _operation (db_op :aiosqlite .Connection ):
|
|
if new_status is None :return
|
|
if payment_db_id :await db_op .execute ("UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ?",(new_status ,payment_db_id ))
|
|
elif yookassa_payment_id :await db_op .execute ("UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE yookassa_payment_id = ?",(new_status ,yookassa_payment_id ))
|
|
else :logging .warning ("update_payment_status called without payment_id or yookassa_payment_id");return
|
|
await db_op .commit ()
|
|
if db_conn :await _setup_db_connection (db_conn )if not db_conn .row_factory else None ;await _operation (db_conn )
|
|
else :
|
|
async with get_db_connection_manager ()as db :await _setup_db_connection (db );await _operation (db )
|
|
|
|
async def get_payment_by_yookassa_id (yookassa_payment_id :str )->Optional [aiosqlite .Row ]:
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute ("SELECT * FROM payments WHERE yookassa_payment_id = ?",(yookassa_payment_id ,));row =await cursor .fetchone ();await cursor .close ();return row
|
|
|
|
async def create_promo_code_db (code :str ,bonus_days :int ,max_activations :int ,admin_id :int ,valid_until_dt :Optional [datetime ]=None )->Optional [int ]:
|
|
valid_until_iso :Optional [str ]=valid_until_dt .isoformat ()if valid_until_dt else None
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );
|
|
try :
|
|
cursor =await db .execute ("INSERT INTO promo_codes (code, bonus_days, max_activations, created_by_admin_id, valid_until) VALUES (?, ?, ?, ?, ?)",(code .upper (),bonus_days ,max_activations ,admin_id ,valid_until_iso ))
|
|
await db .commit ();return cursor .lastrowid
|
|
except aiosqlite .IntegrityError :logging .warning (f"Promo code {code.upper()} exists.");await db .rollback ();return None
|
|
except Exception as e :logging .error (f"Error creating promo {code.upper()}: {e}");await db .rollback ();return None
|
|
|
|
async def get_promo_codes_db (is_active_only :bool =True ,limit :int =20 ,offset :int =0 )->List [aiosqlite .Row ]:
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );
|
|
query ="SELECT promo_code_id, code, bonus_days, max_activations, current_activations, is_active, created_by_admin_id, created_at, valid_until FROM promo_codes";params =[]
|
|
conditions =[]
|
|
if is_active_only :conditions .append ("is_active = 1")
|
|
conditions .append ("(valid_until IS NULL OR DATETIME(valid_until) > DATETIME('now', 'localtime'))")
|
|
if conditions :query +=" WHERE "+" AND ".join (conditions )
|
|
query +=" ORDER BY created_at DESC LIMIT ? OFFSET ?";params .extend ([limit ,offset ])
|
|
cursor =await db .execute (query ,tuple (params ));rows =await cursor .fetchall ();await cursor .close ();return rows
|
|
|
|
async def get_promo_code_by_code (code :str ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [aiosqlite .Row ]:
|
|
sql ="SELECT * FROM promo_codes WHERE code = ? AND is_active = 1 AND (DATETIME(valid_until) IS NULL OR DATETIME(valid_until) > DATETIME('now', 'localtime'))"
|
|
if db_conn :
|
|
cursor =await db_conn .execute (sql ,(code .upper (),));row =await cursor .fetchone ();
|
|
if cursor :await cursor .close ();return row
|
|
else :
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db );cursor =await db .execute (sql ,(code .upper (),));row =await cursor .fetchone ();
|
|
if cursor :await cursor .close ();return row
|
|
|
|
async def increment_promo_activation (promo_code_id :int ,user_id :int ,db_conn :aiosqlite .Connection ,payment_id :Optional [int ]=None )->bool :
|
|
|
|
db =db_conn
|
|
try :
|
|
cursor =await db .execute ("SELECT 1 FROM promo_code_activations WHERE promo_code_id = ? AND user_id = ?",(promo_code_id ,user_id ));existing =await cursor .fetchone ();await cursor .close ()
|
|
if existing :logging .info (f"User {user_id} already used promo {promo_code_id}.");return False
|
|
await db .execute ("INSERT INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)",(promo_code_id ,user_id ,payment_id ))
|
|
await db .execute ("UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?",(promo_code_id ,))
|
|
return True
|
|
except aiosqlite .IntegrityError :logging .warning (f"IntegrityError on promo activation p:{promo_code_id} u:{user_id}.");return False
|
|
except Exception as e :logging .error (f"Error promo activation p:{promo_code_id} u:{user_id}: {e}",exc_info =True );return False
|
|
|
|
async def get_all_message_logs_paginated (limit :int ,offset :int )->List [aiosqlite .Row ]:
|
|
"""
|
|
Fetches a paginated list of all message logs, ordered by the newest first.
|
|
"""
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db )
|
|
|
|
|
|
sql ="""
|
|
SELECT
|
|
log_id,
|
|
user_id,
|
|
telegram_username,
|
|
telegram_first_name,
|
|
event_type,
|
|
content,
|
|
strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str,
|
|
is_admin_event,
|
|
target_user_id
|
|
FROM message_logs
|
|
ORDER BY timestamp DESC
|
|
LIMIT ? OFFSET ?
|
|
"""
|
|
try :
|
|
cursor =await db .execute (sql ,(limit ,offset ))
|
|
rows =await cursor .fetchall ()
|
|
await cursor .close ()
|
|
return rows
|
|
except Exception as e :
|
|
logging .error (f"Error fetching all message logs: {e}",exc_info =True )
|
|
return []
|
|
|
|
async def has_had_any_subscription (user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->bool :
|
|
"""Checks if a user has ever had any subscription (trial or paid)."""
|
|
sql ="SELECT 1 FROM subscriptions WHERE user_id = ? LIMIT 1"
|
|
if db_conn :
|
|
cursor =await db_conn .execute (sql ,(user_id ,))
|
|
row =await cursor .fetchone ();await cursor .close ()
|
|
return row is not None
|
|
else :
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db )
|
|
cursor =await db .execute (sql ,(user_id ,))
|
|
row =await cursor .fetchone ();await cursor .close ()
|
|
return row is not None
|
|
|
|
async def sync_panel_user_data (panel_user_dict :Dict [str ,Any ],db_conn_passed :Optional [aiosqlite .Connection ]=None )->bool :
|
|
"""
|
|
Synchronizes a single panel user's data (user info and subscription) with the local bot database.
|
|
Prioritizes matching/creating bot user by panel_user_dict['telegramId'].
|
|
Manages its own transaction if db_conn_passed is None.
|
|
"""
|
|
panel_uuid =panel_user_dict .get ('uuid')
|
|
telegram_id_from_panel =panel_user_dict .get ('telegramId')
|
|
panel_username =panel_user_dict .get ('username')
|
|
|
|
if not panel_uuid :
|
|
logging .warning (f"Sync: Panel user data missing 'uuid'. Data: {panel_user_dict}")
|
|
return False
|
|
if not telegram_id_from_panel :
|
|
logging .info (f"Sync: Panel user {panel_uuid} (username: {panel_username}) has no 'telegramId'. Skipping TG ID based sync for this user.")
|
|
|
|
|
|
|
|
return False
|
|
|
|
async def _operation (db :aiosqlite .Connection ):
|
|
|
|
bot_user_id =int (telegram_id_from_panel )
|
|
|
|
existing_bot_user =await get_user (bot_user_id ,db_conn =db )
|
|
|
|
if existing_bot_user :
|
|
|
|
if existing_bot_user ['panel_user_uuid']!=panel_uuid :
|
|
if existing_bot_user ['panel_user_uuid']is not None :
|
|
logging .warning (f"Sync: TG User {bot_user_id} already linked to panel_uuid {existing_bot_user['panel_user_uuid']}, but panel now provides {panel_uuid} for this TG ID. Updating to new panel_uuid.")
|
|
|
|
cursor_conflict =await db .execute ("SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?",(panel_uuid ,bot_user_id ))
|
|
conflicting_user =await cursor_conflict .fetchone ();await cursor_conflict .close ()
|
|
if conflicting_user :
|
|
logging .error (f"Sync: CRITICAL CONFLICT! New panel_uuid {panel_uuid} (for TG ID {bot_user_id}) is already linked to different TG User {conflicting_user['user_id']}. Skipping user update for panel_uuid.")
|
|
else :
|
|
await db .execute ("UPDATE users SET panel_user_uuid = ?, username = ? WHERE user_id = ?",(panel_uuid ,panel_username ,bot_user_id ))
|
|
logging .info (f"Sync: Updated panel_uuid for existing TG user {bot_user_id} to {panel_uuid}.")
|
|
else :
|
|
|
|
if panel_username and existing_bot_user ['username']!=panel_username :
|
|
await db .execute ("UPDATE users SET username = ? WHERE user_id = ?",(panel_username ,bot_user_id ))
|
|
logging .info (f"Sync: Updated username for TG user {bot_user_id} from panel username {panel_username}.")
|
|
else :
|
|
|
|
|
|
cursor_conflict =await db .execute ("SELECT user_id FROM users WHERE panel_user_uuid = ?",(panel_uuid ,))
|
|
conflicting_user =await cursor_conflict .fetchone ();await cursor_conflict .close ()
|
|
if conflicting_user :
|
|
logging .error (f"Sync: CRITICAL CONFLICT! Panel UUID {panel_uuid} (for new TG ID {bot_user_id}) is already linked to existing TG User {conflicting_user['user_id']}. Skipping new user creation.")
|
|
return False
|
|
|
|
reg_date_str =panel_user_dict .get ('createdAt',datetime .now (timezone .utc ).isoformat ())
|
|
if isinstance (reg_date_str ,datetime ):reg_date_str =reg_date_str .isoformat ()
|
|
await db .execute (
|
|
"""INSERT INTO users (user_id, username, panel_user_uuid, registration_date, is_banned, language_code)
|
|
VALUES (?, ?, ?, ?, 0, ?)""",
|
|
(bot_user_id ,panel_username ,panel_uuid ,reg_date_str ,settings .DEFAULT_LANGUAGE )
|
|
)
|
|
logging .info (f"Sync: New user created in bot DB from panel data: TG ID {bot_user_id}, Panel UUID {panel_uuid}, Panel Username {panel_username}")
|
|
|
|
|
|
panel_sub_link_uuid =panel_user_dict .get ('subscriptionUuid')or panel_user_dict .get ('shortUuid')
|
|
if not panel_sub_link_uuid :
|
|
logging .warning (f"Sync: Panel user {panel_uuid} (TG ID: {bot_user_id}) has no 'subscriptionUuid' or 'shortUuid'. Cannot sync subscription link.")
|
|
|
|
await db .commit ()
|
|
return True
|
|
|
|
end_date_str =panel_user_dict .get ('expireAt')
|
|
end_date_obj =None
|
|
if end_date_str :
|
|
try :end_date_obj =datetime .fromisoformat (end_date_str .replace ("Z","+00:00"))
|
|
except ValueError :logging .warning (f"Sync: Bad expireAt '{end_date_str}' for panel user {panel_uuid}. Skipping subscription update.");return True
|
|
|
|
if not end_date_obj :
|
|
logging .warning (f"Sync: No valid end_date for panel user {panel_uuid}. Skipping subscription update.");return True
|
|
|
|
status_panel =panel_user_dict .get ('status','UNKNOWN').upper ()
|
|
is_active_panel =1 if status_panel =='ACTIVE'else 0
|
|
traffic_limit =panel_user_dict .get ('trafficLimitBytes')
|
|
traffic_used =panel_user_dict .get ('usedTrafficBytes',panel_user_dict .get ('lifetimeUsedTrafficBytes'))
|
|
|
|
start_date_iso =panel_user_dict .get ('createdAt',datetime .now (timezone .utc ).isoformat ())
|
|
if isinstance (start_date_iso ,datetime ):start_date_iso =start_date_iso .isoformat ()
|
|
|
|
|
|
|
|
duration_months_val =None
|
|
|
|
|
|
await db .execute ("UPDATE subscriptions SET is_active = 0 WHERE user_id = ? AND panel_user_uuid = ? AND is_active = 1 AND panel_subscription_uuid != ?",
|
|
(bot_user_id ,panel_uuid ,panel_sub_link_uuid ))
|
|
|
|
upsert_sub_sql ="""
|
|
INSERT INTO subscriptions (
|
|
user_id, panel_user_uuid, panel_subscription_uuid,
|
|
start_date, end_date, duration_months,
|
|
is_active, status_from_panel, traffic_limit_bytes, traffic_used_bytes,
|
|
last_notification_sent
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
ON CONFLICT(panel_subscription_uuid) DO UPDATE SET
|
|
user_id = excluded.user_id,
|
|
panel_user_uuid = excluded.panel_user_uuid,
|
|
start_date = excluded.start_date,
|
|
end_date = excluded.end_date,
|
|
duration_months = excluded.duration_months,
|
|
is_active = excluded.is_active,
|
|
status_from_panel = excluded.status_from_panel,
|
|
traffic_limit_bytes = excluded.traffic_limit_bytes,
|
|
traffic_used_bytes = excluded.traffic_used_bytes,
|
|
last_notification_sent = NULL;
|
|
"""
|
|
sub_params =(
|
|
bot_user_id ,panel_uuid ,panel_sub_link_uuid ,
|
|
start_date_iso ,end_date_obj .isoformat (),duration_months_val ,
|
|
is_active_panel ,status_panel ,traffic_limit ,traffic_used
|
|
)
|
|
await db .execute (upsert_sub_sql ,sub_params )
|
|
logging .info (f"Sync: Subscription upserted for TG ID {bot_user_id}, Panel UUID {panel_uuid}, Link ID {panel_sub_link_uuid}")
|
|
|
|
await db .commit ()
|
|
return True
|
|
|
|
|
|
if db_conn_passed :
|
|
return await _operation (db_conn_passed )
|
|
else :
|
|
async with get_db_connection_manager ()as db :
|
|
await _setup_db_connection (db )
|
|
try :
|
|
return await _operation (db )
|
|
|
|
except Exception as e :
|
|
logging .error (f"Sync: General DB error during sync_panel_user_data for panel UUID {panel_uuid}: {e}",exc_info =True )
|
|
await db .rollback ()
|
|
return False |