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