diff --git a/bot/filters/admin_filter.py b/bot/filters/admin_filter.py index b170ff6..f2f8044 100644 --- a/bot/filters/admin_filter.py +++ b/bot/filters/admin_filter.py @@ -1,14 +1,17 @@ -from typing import List ,Union -from aiogram .filters import Filter -from aiogram .types import Message ,CallbackQuery ,User +from typing import List, Union +from aiogram.filters import Filter +from aiogram.types import Message, CallbackQuery, User -class AdminFilter (Filter ): - def __init__ (self ,admin_ids :List [int ]): - self .admin_ids =admin_ids - async def __call__ (self ,event :Union [Message ,CallbackQuery ],event_from_user :User )->bool : - if not event_from_user : +class AdminFilter(Filter): + + def __init__(self, admin_ids: List[int]): + self.admin_ids = admin_ids + + async def __call__(self, event: Union[Message, CallbackQuery], + event_from_user: User) -> bool: + if not event_from_user: return False - if not self .admin_ids : + if not self.admin_ids: return False - return event_from_user .id in self .admin_ids \ No newline at end of file + return event_from_user.id in self.admin_ids diff --git a/bot/handlers/admin/__init__.py b/bot/handlers/admin/__init__.py index c149232..3ec5047 100644 --- a/bot/handlers/admin/__init__.py +++ b/bot/handlers/admin/__init__.py @@ -1,6 +1,5 @@ from aiogram import Router - from .common import router as common_admin_router from .promo_codes import router as promo_codes_admin_router from .user_management import router as user_management_admin_router @@ -9,15 +8,12 @@ from .statistics import router as statistics_admin_router from .sync_admin import router as sync_admin_router from .logs_admin import router as logs_admin_router +admin_router_aggregate = Router(name="admin_router_aggregate") -admin_router_aggregate =Router (name ="admin_router_aggregate") - - -admin_router_aggregate .include_router (common_admin_router ) -admin_router_aggregate .include_router (promo_codes_admin_router ) -admin_router_aggregate .include_router (user_management_admin_router ) -admin_router_aggregate .include_router (broadcast_admin_router ) -admin_router_aggregate .include_router (statistics_admin_router ) -admin_router_aggregate .include_router (sync_admin_router ) -admin_router_aggregate .include_router (logs_admin_router ) - +admin_router_aggregate.include_router(common_admin_router) +admin_router_aggregate.include_router(promo_codes_admin_router) +admin_router_aggregate.include_router(user_management_admin_router) +admin_router_aggregate.include_router(broadcast_admin_router) +admin_router_aggregate.include_router(statistics_admin_router) +admin_router_aggregate.include_router(sync_admin_router) +admin_router_aggregate.include_router(logs_admin_router) diff --git a/bot/handlers/admin/broadcast.py b/bot/handlers/admin/broadcast.py index 6c0ba65..3ab1083 100644 --- a/bot/handlers/admin/broadcast.py +++ b/bot/handlers/admin/broadcast.py @@ -1,172 +1,204 @@ import logging import asyncio -from aiogram import Router ,F ,types ,Bot -from aiogram .filters import Command -from aiogram .fsm .context import FSMContext +from aiogram import Router, F, types, Bot +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext from typing import Optional -from config .settings import Settings +from config.settings import Settings -from db .database import get_all_users_for_broadcast ,log_user_action -from bot .states .admin_states import AdminStates -from bot .keyboards .inline .admin_keyboards import get_broadcast_confirmation_keyboard ,get_back_to_admin_panel_keyboard -from bot .middlewares .i18n import JsonI18n +from db.database import get_all_users_for_broadcast, log_user_action +from bot.states.admin_states import AdminStates +from bot.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard +from bot.middlewares.i18n import JsonI18n -router =Router (name ="admin_broadcast_router") +router = Router(name="admin_broadcast_router") -async def broadcast_message_prompt_handler ( -callback :types .CallbackQuery , -state :FSMContext , -i18n_data :dict , -settings :Settings -): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n missing in broadcast_message_prompt_handler") - await callback .answer ("Language service error.",show_alert =True ) + +async def broadcast_message_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing in broadcast_message_prompt_handler") + await callback.answer("Language service error.", show_alert=True) return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - prompt_text =_ ("admin_broadcast_enter_message") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + prompt_text = _("admin_broadcast_enter_message") - if callback .message : - try : - await callback .message .edit_text (prompt_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - except Exception as e : - logging .warning (f"Could not edit message for broadcast prompt: {e}") - await callback .message .answer (prompt_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - await callback .answer () - await state .set_state (AdminStates .waiting_for_broadcast_message ) + if callback.message: + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) + except Exception as e: + logging.warning( + f"Could not edit message for broadcast prompt: {e}") + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) + await callback.answer() + await state.set_state(AdminStates.waiting_for_broadcast_message) -@router .message (AdminStates .waiting_for_broadcast_message ,F .text ) -async def process_broadcast_message_handler ( -message :types .Message , -state :FSMContext , -i18n_data :dict , -settings :Settings -): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n missing in process_broadcast_message_handler") - await message .reply ("Language service error.") +@router.message(AdminStates.waiting_for_broadcast_message, F.text) +async def process_broadcast_message_handler(message: types.Message, + state: FSMContext, i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing in process_broadcast_message_handler") + await message.reply("Language service error.") return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - broadcast_message_text =message .html_text - await state .update_data (broadcast_message =broadcast_message_text ) + broadcast_message_text = message.html_text + await state.update_data(broadcast_message=broadcast_message_text) - preview_snippet =broadcast_message_text [:200 ]+"..."if len (broadcast_message_text )>200 else broadcast_message_text - confirmation_prompt =_ ("admin_broadcast_confirm_prompt",message_preview =preview_snippet ) + preview_snippet = broadcast_message_text[:200] + "..." if len( + broadcast_message_text) > 200 else broadcast_message_text + confirmation_prompt = _("admin_broadcast_confirm_prompt", + message_preview=preview_snippet) - await message .answer ( - confirmation_prompt , - reply_markup =get_broadcast_confirmation_keyboard (current_lang ,i18n ), - parse_mode ="HTML" - ) - await state .set_state (AdminStates .confirming_broadcast ) + await message.answer(confirmation_prompt, + reply_markup=get_broadcast_confirmation_keyboard( + current_lang, i18n), + parse_mode="HTML") + await state.set_state(AdminStates.confirming_broadcast) -@router .callback_query (F .data =="admin_action:main",AdminStates .waiting_for_broadcast_message ) -async def cancel_broadcast_at_prompt_stage (callback :types .CallbackQuery ,state :FSMContext ,settings :Settings ,i18n_data :dict ): +@router.callback_query(F.data == "admin_action:main", + AdminStates.waiting_for_broadcast_message) +async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery, + state: FSMContext, + settings: Settings, + i18n_data: dict): from .common import admin_panel_actions_callback_handler - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :logging .error ("i18n missing");await callback .answer ("Language error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing") + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - if callback .message : - try :await callback .message .edit_text (_ ("admin_broadcast_cancelled"),reply_markup =None ) - except Exception :await callback .message .answer (_ ("admin_broadcast_cancelled")) - await callback .answer (_ ("admin_broadcast_cancelled")) - await state .clear () + if callback.message: + try: + await callback.message.edit_text(_("admin_broadcast_cancelled"), + reply_markup=None) + except Exception: + await callback.message.answer(_("admin_broadcast_cancelled")) + await callback.answer(_("admin_broadcast_cancelled")) + await state.clear() + + callback.data = "admin_action:main" + + from bot.keyboards.inline.admin_keyboards import get_admin_panel_keyboard + if callback.message: + await callback.message.answer(_("admin_panel_title"), + reply_markup=get_admin_panel_keyboard( + i18n, current_lang)) - callback .data ="admin_action:main" +@router.callback_query(F.data.startswith("broadcast_final_action:"), + AdminStates.confirming_broadcast) +async def confirm_broadcast_callback_handler(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, bot: Bot, + settings: Settings): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing") + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + action = callback.data.split(":")[1] + user_fsm_data = await state.get_data() + broadcast_message = user_fsm_data.get("broadcast_message") + if not callback.message: + await callback.answer("Error: message context lost.", show_alert=True) + await state.clear() + return + if action == "send": + if not broadcast_message: + await callback.message.edit_text( + _("admin_broadcast_error_no_message")) + await state.clear() + await callback.answer(show_alert=True) + return - from bot .keyboards .inline .admin_keyboards import get_admin_panel_keyboard - if callback .message : - await callback .message .answer (_ ("admin_panel_title"),reply_markup =get_admin_panel_keyboard (i18n ,current_lang )) + await callback.message.edit_text(_("admin_broadcast_sending_started"), + reply_markup=None) + await callback.answer() + users_to_broadcast = await get_all_users_for_broadcast() + sent_count = 0 + failed_count = 0 + logging.info( + f"Starting broadcast: '{broadcast_message[:50]}...' to {len(users_to_broadcast)} users." + ) -@router .callback_query (F .data .startswith ("broadcast_final_action:"),AdminStates .confirming_broadcast ) -async def confirm_broadcast_callback_handler ( -callback :types .CallbackQuery , -state :FSMContext , -i18n_data :dict , -bot :Bot , -settings :Settings -): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :logging .error ("i18n missing");await callback .answer ("Language error.",show_alert =True );return + admin_user = callback.from_user - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) + for user_row in users_to_broadcast: + user_id = user_row['user_id'] + try: + await bot.send_message(user_id, + broadcast_message, + parse_mode="HTML") + sent_count += 1 - action =callback .data .split (":")[1 ] - user_fsm_data =await state .get_data () - broadcast_message =user_fsm_data .get ("broadcast_message") - - if not callback .message : - await callback .answer ("Error: message context lost.",show_alert =True ) - await state .clear ();return - - if action =="send": - if not broadcast_message : - await callback .message .edit_text (_ ("admin_broadcast_error_no_message")) - await state .clear ();await callback .answer (show_alert =True );return - - await callback .message .edit_text (_ ("admin_broadcast_sending_started"),reply_markup =None ) - await callback .answer () - - users_to_broadcast =await get_all_users_for_broadcast () - sent_count =0 ;failed_count =0 - logging .info (f"Starting broadcast: '{broadcast_message[:50]}...' to {len(users_to_broadcast)} users.") - - admin_user =callback .from_user - - for user_row in users_to_broadcast : - user_id =user_row ['user_id'] - try : - await bot .send_message (user_id ,broadcast_message ,parse_mode ="HTML") - sent_count +=1 - - await log_user_action ( - user_id =admin_user .id , - telegram_username =admin_user .username , - telegram_first_name =admin_user .first_name , - event_type ="admin_broadcast_sent", - content =f"To user {user_id}: {broadcast_message[:70]}...", - is_admin_event =True , - target_user_id =user_id + await log_user_action( + user_id=admin_user.id, + telegram_username=admin_user.username, + telegram_first_name=admin_user.first_name, + event_type="admin_broadcast_sent", + content=f"To user {user_id}: {broadcast_message[:70]}...", + is_admin_event=True, + target_user_id=user_id) + except Exception as e: + failed_count += 1 + logging.warning( + f"Failed to send broadcast to user {user_id}: {type(e).__name__} - {e}" ) - except Exception as e : - failed_count +=1 - logging .warning (f"Failed to send broadcast to user {user_id}: {type(e).__name__} - {e}") - await log_user_action ( - user_id =admin_user .id , - telegram_username =admin_user .username , - telegram_first_name =admin_user .first_name , - event_type ="admin_broadcast_failed", - content =f"For user {user_id}: {type(e).__name__} - {str(e)[:70]}...", - is_admin_event =True , - target_user_id =user_id - ) - await asyncio .sleep (0.1 ) + await log_user_action( + user_id=admin_user.id, + telegram_username=admin_user.username, + telegram_first_name=admin_user.first_name, + event_type="admin_broadcast_failed", + content= + f"For user {user_id}: {type(e).__name__} - {str(e)[:70]}...", + is_admin_event=True, + target_user_id=user_id) + await asyncio.sleep(0.1) - result_message =_ ("admin_broadcast_finished_stats",sent_count =sent_count ,failed_count =failed_count ) - await callback .message .answer (result_message ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) + result_message = _("admin_broadcast_finished_stats", + sent_count=sent_count, + failed_count=failed_count) + await callback.message.answer( + result_message, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - elif action =="cancel": - await callback .message .edit_text (_ ("admin_broadcast_cancelled"),reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - await callback .answer () + elif action == "cancel": + await callback.message.edit_text( + _("admin_broadcast_cancelled"), + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + await callback.answer() - await state .clear () \ No newline at end of file + await state.clear() diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index b01cf81..ecba824 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -1,96 +1,114 @@ import logging -from aiogram import Router ,F ,types ,Bot -from aiogram .filters import Command -from aiogram .fsm .context import FSMContext +from aiogram import Router, F, types, Bot +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext from typing import Optional -from config .settings import Settings -from bot .keyboards .inline .admin_keyboards import get_admin_panel_keyboard -from bot .middlewares .i18n import JsonI18n -from bot .services .panel_api_service import PanelApiService +from config.settings import Settings +from bot.keyboards.inline.admin_keyboards import get_admin_panel_keyboard +from bot.middlewares.i18n import JsonI18n +from bot.services.panel_api_service import PanelApiService + +from . import broadcast as admin_broadcast_handlers +from . import promo_codes as admin_promo_handlers +from . import user_management as admin_user_mgmnt_handlers +from . import statistics as admin_stats_handlers +from . import sync_admin as admin_sync_handlers +from . import logs_admin as admin_logs_handlers + +router = Router(name="admin_common_router") -from .import broadcast as admin_broadcast_handlers -from .import promo_codes as admin_promo_handlers -from .import user_management as admin_user_mgmnt_handlers -from .import statistics as admin_stats_handlers -from .import sync_admin as admin_sync_handlers -from .import logs_admin as admin_logs_handlers - -router =Router (name ="admin_common_router") - -@router .message (Command ("admin")) -async def admin_panel_command_handler (message :types .Message ,settings :Settings ,i18n_data :dict ): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n missing in admin_panel_command_handler") - await message .answer ("Language service error.") +@router.message(Command("admin")) +async def admin_panel_command_handler(message: types.Message, + settings: Settings, i18n_data: dict): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing in admin_panel_command_handler") + await message.answer("Language service error.") return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - await message .answer ( - _ (key ="admin_panel_title"), - reply_markup =get_admin_panel_keyboard (i18n ,current_lang ,settings ) - ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + await message.answer(_(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard( + i18n, current_lang, settings)) -@router .callback_query (F .data .startswith ("admin_action:")) -async def admin_panel_actions_callback_handler ( -callback :types .CallbackQuery , -state :FSMContext , -settings :Settings , -i18n_data :dict , -bot :Bot , -panel_service :PanelApiService -): - action_parts =callback .data .split (":") - action =action_parts [1 ] - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n missing in admin_panel_actions_callback_handler") - await callback .answer ("Language error.",show_alert =True ) +@router.callback_query(F.data.startswith("admin_action:")) +async def admin_panel_actions_callback_handler(callback: types.CallbackQuery, + state: FSMContext, + settings: Settings, + i18n_data: dict, bot: Bot, + panel_service: PanelApiService): + action_parts = callback.data.split(":") + action = action_parts[1] + + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing in admin_panel_actions_callback_handler") + await callback.answer("Language error.", show_alert=True) return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - if not callback .message : - logging .error (f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for action {action}") - await callback .answer ("Error processing action: message context lost.",show_alert =True ) + if not callback.message: + logging.error( + f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for action {action}" + ) + await callback.answer("Error processing action: message context lost.", + show_alert=True) return - if action =="stats": - await admin_stats_handlers .show_statistics_handler (callback ,i18n_data ,settings ) - elif action =="broadcast": - await admin_broadcast_handlers .broadcast_message_prompt_handler (callback ,state ,i18n_data ,settings ) - elif action =="create_promo": - await admin_promo_handlers .create_promo_prompt_handler (callback ,state ,i18n_data ,settings ) - elif action =="view_promos": - await admin_promo_handlers .view_promo_codes_handler (callback ,i18n_data ,settings ) - elif action =="ban_user_prompt": - await admin_user_mgmnt_handlers .ban_user_prompt_handler (callback ,state ,i18n_data ,settings ) - elif action =="unban_user_prompt": - await admin_user_mgmnt_handlers .unban_user_prompt_handler (callback ,state ,i18n_data ,settings ) - elif action =="view_banned": + if action == "stats": + await admin_stats_handlers.show_statistics_handler( + callback, i18n_data, settings) + elif action == "broadcast": + await admin_broadcast_handlers.broadcast_message_prompt_handler( + callback, state, i18n_data, settings) + elif action == "create_promo": + await admin_promo_handlers.create_promo_prompt_handler( + callback, state, i18n_data, settings) + elif action == "view_promos": + await admin_promo_handlers.view_promo_codes_handler( + callback, i18n_data, settings) + elif action == "ban_user_prompt": + await admin_user_mgmnt_handlers.ban_user_prompt_handler( + callback, state, i18n_data, settings) + elif action == "unban_user_prompt": + await admin_user_mgmnt_handlers.unban_user_prompt_handler( + callback, state, i18n_data, settings) + elif action == "view_banned": - await admin_user_mgmnt_handlers .view_banned_users_handler (callback ,i18n_data ,settings ,state ) - elif action =="view_logs_menu": - await admin_logs_handlers .display_logs_menu (callback ,i18n_data ,settings ) - elif action =="sync_panel": - await admin_sync_handlers .sync_command_handler (callback .message ,bot =bot ,settings =settings ,i18n_data =i18n_data ,panel_service =panel_service ) - await callback .answer (_ ("admin_sync_initiated_from_panel")) - elif action =="main": - try : - await callback .message .edit_text ( - _ (key ="admin_panel_title"), - reply_markup =get_admin_panel_keyboard (i18n ,current_lang ,settings ) - ) - except Exception : - await callback .message .answer ( - _ (key ="admin_panel_title"), - reply_markup =get_admin_panel_keyboard (i18n ,current_lang ,settings ) - ) - await callback .answer () - else : - logging .warning (f"Unknown admin_action received: {action} from callback {callback.data}") - await callback .answer (_ ("admin_unknown_action"),show_alert =True ) \ No newline at end of file + await admin_user_mgmnt_handlers.view_banned_users_handler( + callback, i18n_data, settings, state) + elif action == "view_logs_menu": + await admin_logs_handlers.display_logs_menu(callback, i18n_data, + settings) + elif action == "sync_panel": + await admin_sync_handlers.sync_command_handler( + callback.message, + bot=bot, + settings=settings, + i18n_data=i18n_data, + panel_service=panel_service) + await callback.answer(_("admin_sync_initiated_from_panel")) + elif action == "main": + try: + await callback.message.edit_text( + _(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, + settings)) + except Exception: + await callback.message.answer( + _(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, + settings)) + await callback.answer() + else: + logging.warning( + f"Unknown admin_action received: {action} from callback {callback.data}" + ) + await callback.answer(_("admin_unknown_action"), show_alert=True) diff --git a/bot/handlers/admin/logs_admin.py b/bot/handlers/admin/logs_admin.py index c9cea02..4f7d726 100644 --- a/bot/handlers/admin/logs_admin.py +++ b/bot/handlers/admin/logs_admin.py @@ -1,233 +1,321 @@ import logging import math import re -from aiogram import Router ,F ,types ,Bot -from aiogram .fsm .context import FSMContext -from typing import Optional ,List ,Dict ,Any +from aiogram import Router, F, types, Bot +from aiogram.fsm.context import FSMContext +from typing import Optional, List, Dict, Any import aiosqlite -from config .settings import Settings -from db .database import ( -get_all_message_logs_paginated ,count_all_message_logs , -get_user_message_logs_paginated ,count_user_message_logs , -get_user ,get_user_by_telegram_username -) -from bot .states .admin_states import AdminStates -from bot .keyboards .inline .admin_keyboards import ( -get_logs_menu_keyboard , -get_logs_pagination_keyboard , -get_back_to_admin_panel_keyboard , -get_admin_panel_keyboard -) -from bot .middlewares .i18n import JsonI18n +from config.settings import Settings +from db.database import (get_all_message_logs_paginated, + count_all_message_logs, + get_user_message_logs_paginated, + count_user_message_logs, get_user, + get_user_by_telegram_username) +from bot.states.admin_states import AdminStates +from bot.keyboards.inline.admin_keyboards import ( + get_logs_menu_keyboard, get_logs_pagination_keyboard, + get_back_to_admin_panel_keyboard, get_admin_panel_keyboard) +from bot.middlewares.i18n import JsonI18n -router =Router (name ="admin_logs_router") -USERNAME_REGEX =re .compile (r"^[a-zA-Z0-9_]{5,32}$") +router = Router(name="admin_logs_router") +USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") -async def display_logs_menu (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ): - current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE ) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n_instance missing in display_logs_menu") - await callback .answer ("Language service error.",show_alert =True ) +async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + + if not i18n: + logging.error("i18n_instance missing in display_logs_menu") + await callback.answer("Language service error.", show_alert=True) return - if not callback .message : - logging .error ("CallbackQuery has no message in display_logs_menu") - await callback .answer ("Error processing request.",show_alert =True ) + if not callback.message: + logging.error("CallbackQuery has no message in display_logs_menu") + await callback.answer("Error processing request.", show_alert=True) return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - try : - await callback .message .edit_text ( - text =_ (key ="admin_logs_menu_title"), - reply_markup =get_logs_menu_keyboard (i18n ,current_lang ) + try: + await callback.message.edit_text(text=_(key="admin_logs_menu_title"), + reply_markup=get_logs_menu_keyboard( + i18n, current_lang)) + except Exception as e: + logging.warning(f"Failed to edit message for logs menu: {e}") + await callback.message.answer(text=_(key="admin_logs_menu_title"), + reply_markup=get_logs_menu_keyboard( + i18n, current_lang)) + await callback.answer() + + +async def _display_formatted_logs(target_message: types.Message, + logs: List[aiosqlite.Row], + total_logs: int, + current_page: int, + settings: Settings, + title_key: str, + base_pagination_callback_data: str, + i18n: JsonI18n, + current_lang: str, + title_kwargs: Optional[Dict[str, + Any]] = None): + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + page_size = settings.LOGS_PAGE_SIZE + + actual_title_kwargs = title_kwargs or {} + + if not logs and total_logs == 0: + text = _( + title_key, current_page=1, total_pages=1, ** + actual_title_kwargs) + "\n\n" + _("admin_no_logs_found") + reply_markup = get_logs_pagination_keyboard( + current_page, + 1, + base_pagination_callback_data, + i18n, + current_lang, + back_to_logs_menu=True) + else: + total_pages = math.ceil(total_logs / page_size) if page_size > 0 else 1 + text = _(title_key, + current_page=current_page + 1, + total_pages=max(1, total_pages), + **actual_title_kwargs) + "\n" + + log_entries_text = [] + for log_entry in logs: + user_display_parts = [] + + telegram_first_name = log_entry[ + 'telegram_first_name'] if 'telegram_first_name' in log_entry.keys( + ) and log_entry['telegram_first_name'] else None + telegram_username = log_entry[ + 'telegram_username'] if 'telegram_username' in log_entry.keys( + ) and log_entry['telegram_username'] else None + user_id_from_log = log_entry[ + 'user_id'] if 'user_id' in log_entry.keys( + ) and log_entry['user_id'] else None + + if telegram_first_name: + user_display_parts.append(telegram_first_name) + if telegram_username: + user_display_parts.append(f"(@{telegram_username})") + + user_display = " ".join(user_display_parts).strip() + if not user_display: + user_display = _( + "system_or_unknown_user" + ) if not user_id_from_log else f"ID: {user_id_from_log}" + + user_id_display = str( + user_id_from_log) if user_id_from_log is not None else "N/A" + content_raw = log_entry['content'] if 'content' in log_entry.keys( + ) and log_entry['content'] else "" + content_preview = (content_raw[:100] + + "...") if len(content_raw) > 100 else ( + content_raw or "N/A") + + log_entries_text.append( + _("admin_log_entry_format", + timestamp_str=log_entry['timestamp_str'] + if 'timestamp_str' in log_entry.keys() else 'N/A', + user_display=user_display, + user_id=user_id_display, + event_type=log_entry['event_type'] + if 'event_type' in log_entry.keys() else 'N/A', + content_preview=content_preview).replace("\n", "\n ")) + text += "\n\n".join(log_entries_text) + reply_markup = get_logs_pagination_keyboard( + current_page, + total_pages, + base_pagination_callback_data, + i18n, + current_lang, + back_to_logs_menu=True) + + try: + await target_message.edit_text(text, + reply_markup=reply_markup, + parse_mode="HTML", + disable_web_page_preview=True) + except Exception as e: + logging.warning( + f"Failed to edit message for logs display: {e}. Content length: {len(text)}" ) - except Exception as e : - logging .warning (f"Failed to edit message for logs menu: {e}") - await callback .message .answer ( - text =_ (key ="admin_logs_menu_title"), - reply_markup =get_logs_menu_keyboard (i18n ,current_lang ) - ) - await callback .answer () + chunk_size = 4000 + for i in range(0, len(text), chunk_size): + chunk = text[i:i + chunk_size] + is_last_chunk = (i + chunk_size) >= len(text) + await target_message.answer( + chunk, + reply_markup=reply_markup if is_last_chunk else None, + parse_mode="HTML", + disable_web_page_preview=True) -async def _display_formatted_logs ( -target_message :types .Message , -logs :List [aiosqlite .Row ], -total_logs :int , -current_page :int , -settings :Settings , -title_key :str , -base_pagination_callback_data :str , -i18n :JsonI18n , -current_lang :str , -title_kwargs :Optional [Dict [str ,Any ]]=None -): - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - page_size =settings .LOGS_PAGE_SIZE - actual_title_kwargs =title_kwargs or {} +@router.callback_query(F.data.startswith("admin_logs:view_all")) +async def view_all_logs_handler(callback: types.CallbackQuery, + settings: Settings, i18n_data: dict): + page = 0 + parts = callback.data.split(":") - if not logs and total_logs ==0 : - text =_ (title_key ,current_page =1 ,total_pages =1 ,**actual_title_kwargs )+"\n\n"+_ ("admin_no_logs_found") - reply_markup =get_logs_pagination_keyboard (current_page ,1 ,base_pagination_callback_data ,i18n ,current_lang ,back_to_logs_menu =True ) - else : - total_pages =math .ceil (total_logs /page_size )if page_size >0 else 1 - text =_ (title_key ,current_page =current_page +1 ,total_pages =max (1 ,total_pages ),**actual_title_kwargs )+"\n" + if len(parts) == 3: + try: + page = int(parts[2]) + except ValueError: + page = 0 - log_entries_text =[] - for log_entry in logs : - user_display_parts =[] - - telegram_first_name =log_entry ['telegram_first_name']if 'telegram_first_name'in log_entry .keys ()and log_entry ['telegram_first_name']else None - telegram_username =log_entry ['telegram_username']if 'telegram_username'in log_entry .keys ()and log_entry ['telegram_username']else None - user_id_from_log =log_entry ['user_id']if 'user_id'in log_entry .keys ()and log_entry ['user_id']else None - - if telegram_first_name :user_display_parts .append (telegram_first_name ) - if telegram_username :user_display_parts .append (f"(@{telegram_username})") - - user_display =" ".join (user_display_parts ).strip () - if not user_display : - user_display =_ ("system_or_unknown_user")if not user_id_from_log else f"ID: {user_id_from_log}" - - user_id_display =str (user_id_from_log )if user_id_from_log is not None else "N/A" - content_raw =log_entry ['content']if 'content'in log_entry .keys ()and log_entry ['content']else "" - content_preview =(content_raw [:100 ]+"...")if len (content_raw )>100 else (content_raw or "N/A") - - log_entries_text .append ( - _ ("admin_log_entry_format", - timestamp_str =log_entry ['timestamp_str']if 'timestamp_str'in log_entry .keys ()else 'N/A', - user_display =user_display , - user_id =user_id_display , - event_type =log_entry ['event_type']if 'event_type'in log_entry .keys ()else 'N/A', - content_preview =content_preview - ).replace ("\n","\n ") - ) - text +="\n\n".join (log_entries_text ) - reply_markup =get_logs_pagination_keyboard (current_page ,total_pages ,base_pagination_callback_data ,i18n ,current_lang ,back_to_logs_menu =True ) - - try : - await target_message .edit_text (text ,reply_markup =reply_markup ,parse_mode ="HTML",disable_web_page_preview =True ) - except Exception as e : - logging .warning (f"Failed to edit message for logs display: {e}. Content length: {len(text)}") - chunk_size =4000 - for i in range (0 ,len (text ),chunk_size ): - chunk =text [i :i +chunk_size ];is_last_chunk =(i +chunk_size )>=len (text ) - await target_message .answer (chunk ,reply_markup =reply_markup if is_last_chunk else None ,parse_mode ="HTML",disable_web_page_preview =True ) - -@router .callback_query (F .data .startswith ("admin_logs:view_all")) -async def view_all_logs_handler (callback :types .CallbackQuery ,settings :Settings ,i18n_data :dict ): - page =0 - parts =callback .data .split (":") - - if len (parts )==3 : - try : - page =int (parts [2 ]) - except ValueError : - page =0 - - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE ) - if not i18n or not callback .message : - await callback .answer ("Error processing request.",show_alert =True ) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + if not i18n or not callback.message: + await callback.answer("Error processing request.", show_alert=True) return - logs ,total_logs =await get_all_message_logs_paginated (settings .LOGS_PAGE_SIZE ,page *settings .LOGS_PAGE_SIZE ),await count_all_message_logs () + logs, total_logs = await get_all_message_logs_paginated( + settings.LOGS_PAGE_SIZE, + page * settings.LOGS_PAGE_SIZE), await count_all_message_logs() - await _display_formatted_logs ( - target_message =callback .message ,logs =logs ,total_logs =total_logs , - current_page =page ,settings =settings , - title_key ="admin_all_logs_title",base_pagination_callback_data ="admin_logs:view_all", - i18n =i18n ,current_lang =current_lang + await _display_formatted_logs( + target_message=callback.message, + logs=logs, + total_logs=total_logs, + current_page=page, + settings=settings, + title_key="admin_all_logs_title", + base_pagination_callback_data="admin_logs:view_all", + i18n=i18n, + current_lang=current_lang) + await callback.answer() + + +@router.callback_query(F.data == "admin_logs:prompt_user") +async def prompt_user_for_logs_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings): + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + if not i18n or not callback.message: + await callback.answer("Error") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + await callback.message.edit_text( + text=_("admin_prompt_for_user_id_or_username_logs"), + reply_markup=get_logs_menu_keyboard(i18n, current_lang)) + await state.set_state(AdminStates.waiting_for_user_id_for_logs) + await callback.answer() + + +@router.message(AdminStates.waiting_for_user_id_for_logs, F.text) +async def process_user_id_for_logs_handler(message: types.Message, + state: FSMContext, + settings: Settings, + i18n_data: dict): + current_state_fsm = await state.get_state() + logging.info( + f"Processing user input for logs in state {current_state_fsm}: '{message.text}'" ) - await callback .answer () + await state.clear() -@router .callback_query (F .data =="admin_logs:prompt_user") -async def prompt_user_for_logs_handler (callback :types .CallbackQuery ,state :FSMContext ,i18n_data :dict ,settings :Settings ): - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE ) - if not i18n or not callback .message :await callback .answer ("Error");return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + if not i18n: + await message.reply("Language error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - await callback .message .edit_text ( - text =_ ("admin_prompt_for_user_id_or_username_logs"), - reply_markup =get_logs_menu_keyboard (i18n ,current_lang ) - ) - await state .set_state (AdminStates .waiting_for_user_id_for_logs ) - await callback .answer () + input_text = message.text.strip() + user_data_for_logs: Optional[aiosqlite.Row] = None -@router .message (AdminStates .waiting_for_user_id_for_logs ,F .text ) -async def process_user_id_for_logs_handler (message :types .Message ,state :FSMContext ,settings :Settings ,i18n_data :dict ): - current_state_fsm =await state .get_state () - logging .info (f"Processing user input for logs in state {current_state_fsm}: '{message.text}'") - await state .clear () + if input_text.isdigit(): + try: + user_data_for_logs = await get_user(int(input_text)) + except ValueError: + pass + elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]): + user_data_for_logs = await get_user_by_telegram_username(input_text[1:] + ) + elif USERNAME_REGEX.match(input_text): + user_data_for_logs = await get_user_by_telegram_username(input_text) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE ) - if not i18n :await message .reply ("Language error.");return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - - input_text =message .text .strip () - user_data_for_logs :Optional [aiosqlite .Row ]=None - - if input_text .isdigit (): - try :user_data_for_logs =await get_user (int (input_text )) - except ValueError :pass - elif input_text .startswith ("@")and USERNAME_REGEX .match (input_text [1 :]): - user_data_for_logs =await get_user_by_telegram_username (input_text [1 :]) - elif USERNAME_REGEX .match (input_text ): - user_data_for_logs =await get_user_by_telegram_username (input_text ) - - if not user_data_for_logs : - await message .answer (_ ("admin_log_user_not_found",input =input_text )) + if not user_data_for_logs: + await message.answer(_("admin_log_user_not_found", input=input_text)) return - target_user_id =user_data_for_logs ['user_id'] - user_display =user_data_for_logs ['first_name']or (f"@{user_data_for_logs['username']}"if user_data_for_logs .get ('username')else f"ID {target_user_id}") + target_user_id = user_data_for_logs['user_id'] + user_display = user_data_for_logs['first_name'] or ( + f"@{user_data_for_logs['username']}" + if user_data_for_logs.get('username') else f"ID {target_user_id}") - logs ,total_logs =await get_user_message_logs_paginated (target_user_id ,settings .LOGS_PAGE_SIZE ,0 ),await count_user_message_logs (target_user_id ) + logs, total_logs = await get_user_message_logs_paginated( + target_user_id, settings.LOGS_PAGE_SIZE, + 0), await count_user_message_logs(target_user_id) - await _display_formatted_logs ( - target_message =message ,logs =logs ,total_logs =total_logs , - current_page =0 ,settings =settings , - title_key ="admin_user_logs_title", - base_pagination_callback_data =f"admin_logs:view_user:{target_user_id}", - i18n =i18n ,current_lang =current_lang , - title_kwargs ={"user_display":user_display } - ) + await _display_formatted_logs( + target_message=message, + logs=logs, + total_logs=total_logs, + current_page=0, + settings=settings, + title_key="admin_user_logs_title", + base_pagination_callback_data=f"admin_logs:view_user:{target_user_id}", + i18n=i18n, + current_lang=current_lang, + title_kwargs={"user_display": user_display}) -@router .callback_query (F .data .startswith ("admin_logs:view_user:")) -async def view_user_logs_paginated_handler (callback :types .CallbackQuery ,settings :Settings ,i18n_data :dict ): - try : - parts =callback .data .split (":") - target_user_id =int (parts [2 ]) - page =int (parts [3 ]) - except (IndexError ,ValueError ): - await callback .answer ("Invalid log request.",show_alert =True );return - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE ) - if not i18n or not callback .message :await callback .answer ("Error");return +@router.callback_query(F.data.startswith("admin_logs:view_user:")) +async def view_user_logs_paginated_handler(callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict): + try: + parts = callback.data.split(":") + target_user_id = int(parts[2]) + page = int(parts[3]) + except (IndexError, ValueError): + await callback.answer("Invalid log request.", show_alert=True) + return - user_data_for_logs =await get_user (target_user_id ) - if not user_data_for_logs : - await callback .message .edit_text ("User not found for logs.");await callback .answer ();return - user_display =user_data_for_logs ['first_name']or (f"@{user_data_for_logs['username']}"if user_data_for_logs .get ('username')else f"ID {target_user_id}") + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + if not i18n or not callback.message: + await callback.answer("Error") + return - logs ,total_logs =await get_user_message_logs_paginated (target_user_id ,settings .LOGS_PAGE_SIZE ,page *settings .LOGS_PAGE_SIZE ),await count_user_message_logs (target_user_id ) + user_data_for_logs = await get_user(target_user_id) + if not user_data_for_logs: + await callback.message.edit_text("User not found for logs.") + await callback.answer() + return + user_display = user_data_for_logs['first_name'] or ( + f"@{user_data_for_logs['username']}" + if user_data_for_logs.get('username') else f"ID {target_user_id}") - await _display_formatted_logs ( - target_message =callback .message ,logs =logs ,total_logs =total_logs , - current_page =page ,settings =settings , - title_key ="admin_user_logs_title", - base_pagination_callback_data =f"admin_logs:view_user:{target_user_id}", - i18n =i18n ,current_lang =current_lang , - title_kwargs ={"user_display":user_display } - ) - await callback .answer () + logs, total_logs = await get_user_message_logs_paginated( + target_user_id, settings.LOGS_PAGE_SIZE, page * + settings.LOGS_PAGE_SIZE), await count_user_message_logs(target_user_id) -@router .callback_query (F .data =="admin_action:view_logs_menu",AdminStates .waiting_for_user_id_for_logs ) -async def cancel_log_user_input_state_to_menu (callback :types .CallbackQuery ,state :FSMContext ,settings :Settings ,i18n_data :dict ): - await state .clear () - await display_logs_menu (callback ,i18n_data ,settings ) \ No newline at end of file + await _display_formatted_logs( + target_message=callback.message, + logs=logs, + total_logs=total_logs, + current_page=page, + settings=settings, + title_key="admin_user_logs_title", + base_pagination_callback_data=f"admin_logs:view_user:{target_user_id}", + i18n=i18n, + current_lang=current_lang, + title_kwargs={"user_display": user_display}) + await callback.answer() + + +@router.callback_query(F.data == "admin_action:view_logs_menu", + AdminStates.waiting_for_user_id_for_logs) +async def cancel_log_user_input_state_to_menu(callback: types.CallbackQuery, + state: FSMContext, + settings: Settings, + i18n_data: dict): + await state.clear() + await display_logs_menu(callback, i18n_data, settings) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index b3d329e..7655572 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -1,157 +1,187 @@ import logging -from aiogram import Router ,F ,types ,Bot -from aiogram .filters import Command -from aiogram .fsm .context import FSMContext -from datetime import datetime ,timedelta ,timezone +from aiogram import Router, F, types, Bot +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from datetime import datetime, timedelta, timezone from typing import Optional -from config .settings import Settings -from db .database import create_promo_code_db ,get_promo_codes_db -from bot .states .admin_states import AdminStates -from bot .keyboards .inline .admin_keyboards import get_back_to_admin_panel_keyboard -from bot .middlewares .i18n import JsonI18n +from config.settings import Settings +from db.database import create_promo_code_db, get_promo_codes_db +from bot.states.admin_states import AdminStates +from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard +from bot.middlewares.i18n import JsonI18n -router =Router (name ="admin_promo_codes_router") +router = Router(name="admin_promo_codes_router") -async def create_promo_prompt_handler ( -callback :types .CallbackQuery , -state :FSMContext , -i18n_data :dict , -settings :Settings -): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n missing in create_promo_prompt_handler") - await callback .answer ("Language service error.",show_alert =True ) + +async def create_promo_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing in create_promo_prompt_handler") + await callback.answer("Language service error.", show_alert=True) return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - prompt_text =_ ("admin_promo_create_prompt",example_format ="MYPROMO20 7 100 30") + prompt_text = _("admin_promo_create_prompt", + example_format="MYPROMO20 7 100 30") - if callback .message : - try : - await callback .message .edit_text (prompt_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - except Exception as e : - logging .warning (f"Could not edit message for promo prompt: {e}") - await callback .message .answer (prompt_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - await callback .answer () - await state .set_state (AdminStates .waiting_for_promo_details ) + if callback.message: + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) + except Exception as e: + logging.warning(f"Could not edit message for promo prompt: {e}") + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) + await callback.answer() + await state.set_state(AdminStates.waiting_for_promo_details) -@router .message (AdminStates .waiting_for_promo_details ,F .text ) -async def process_promo_code_details_handler ( -message :types .Message , -state :FSMContext , -i18n_data :dict , -settings :Settings -): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n missing in process_promo_code_details_handler") - await message .reply ("Language service error.") + +@router.message(AdminStates.waiting_for_promo_details, F.text) +async def process_promo_code_details_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing in process_promo_code_details_handler") + await message.reply("Language service error.") return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - parts =message .text .strip ().split () - if not (3 <=len (parts )<=4 ): - await message .answer (_ ("admin_promo_invalid_format")) + parts = message.text.strip().split() + if not (3 <= len(parts) <= 4): + await message.answer(_("admin_promo_invalid_format")) return - try : - code =parts [0 ].upper () - if not (3 <=len (code )<=30 and code .isalnum ()): - raise ValueError ("Promo code must be 3-30 alphanumeric characters.") - bonus_days =int (parts [1 ]);max_activations =int (parts [2 ]) - valid_until_date :Optional [datetime ]=None - valid_until_str_display =_ ("admin_promo_valid_indefinitely") + try: + code = parts[0].upper() + if not (3 <= len(code) <= 30 and code.isalnum()): + raise ValueError( + "Promo code must be 3-30 alphanumeric characters.") + bonus_days = int(parts[1]) + max_activations = int(parts[2]) + valid_until_date: Optional[datetime] = None + valid_until_str_display = _("admin_promo_valid_indefinitely") - if len (parts )==4 : - valid_days_from_now =int (parts [3 ]) - if valid_days_from_now <=0 :raise ValueError ("Validity days (if provided) must be positive.") + if len(parts) == 4: + valid_days_from_now = int(parts[3]) + if valid_days_from_now <= 0: + raise ValueError( + "Validity days (if provided) must be positive.") - valid_until_date =datetime .now (timezone .utc )+timedelta (days =valid_days_from_now ) - valid_until_str_display =_ ("admin_promo_valid_until_display",date =valid_until_date .strftime ('%Y-%m-%d')) - if bonus_days <=0 or max_activations <=0 :raise ValueError ("Bonus days and max activations must be positive.") - except ValueError as e : - await message .answer (_ ("admin_promo_invalid_values",error =str (e ))) + valid_until_date = datetime.now( + timezone.utc) + timedelta(days=valid_days_from_now) + valid_until_str_display = _( + "admin_promo_valid_until_display", + date=valid_until_date.strftime('%Y-%m-%d')) + if bonus_days <= 0 or max_activations <= 0: + raise ValueError( + "Bonus days and max activations must be positive.") + except ValueError as e: + await message.answer(_("admin_promo_invalid_values", error=str(e))) return - admin_id =message .from_user .id + admin_id = message.from_user.id - promo_id =await create_promo_code_db (code ,bonus_days ,max_activations ,admin_id ,valid_until_date ) + promo_id = await create_promo_code_db(code, bonus_days, max_activations, + admin_id, valid_until_date) - if promo_id : - success_text =_ ("admin_promo_created_success",code =code ,bonus_days =bonus_days ,max_activations =max_activations ,valid_until_str =valid_until_str_display ) - await message .answer (success_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - else : - await message .answer (_ ("admin_promo_creation_failed")) - await state .clear () + if promo_id: + success_text = _("admin_promo_created_success", + code=code, + bonus_days=bonus_days, + max_activations=max_activations, + valid_until_str=valid_until_str_display) + await message.answer(success_text, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) + else: + await message.answer(_("admin_promo_creation_failed")) + await state.clear() -async def view_promo_codes_handler ( -callback :types .CallbackQuery , -i18n_data :dict , -settings :Settings -): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n missing in view_promo_codes_handler") - await callback .answer ("Language error.",show_alert =True ) + +async def view_promo_codes_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing in view_promo_codes_handler") + await callback.answer("Language error.", show_alert=True) return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - promos =await get_promo_codes_db (is_active_only =True ,limit =20 ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + promos = await get_promo_codes_db(is_active_only=True, limit=20) - if not callback .message : - await callback .answer ("Error: message context lost.",show_alert =True );return + if not callback.message: + await callback.answer("Error: message context lost.", show_alert=True) + return - if not promos : - await callback .message .edit_text (_ ("admin_no_active_promos"),reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - await callback .answer ();return + if not promos: + await callback.message.edit_text( + _("admin_no_active_promos"), + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + await callback.answer() + return - response_text_parts =[f"{_('admin_active_promos_list_header')}\n"] - for promo in promos : - valid_until_display_text =_ ("admin_promo_valid_indefinitely") - if promo ['valid_until']: - try : + response_text_parts = [f"{_('admin_active_promos_list_header')}\n"] + for promo in promos: + valid_until_display_text = _("admin_promo_valid_indefinitely") + if promo['valid_until']: + try: + valid_until_dt: Optional[datetime] = None + if 'T' in promo['valid_until']: + valid_until_dt = datetime.fromisoformat( + promo['valid_until'].replace("Z", "+00:00")) + else: + valid_until_dt = datetime.strptime(promo['valid_until'], + '%Y-%m-%d %H:%M:%S') + if valid_until_dt and valid_until_dt.tzinfo is None: + valid_until_dt = valid_until_dt.replace( + tzinfo=timezone.utc) + valid_until_display_text = valid_until_dt.strftime('%Y-%m-%d') + except ValueError as e: + logging.warning( + f"Could not parse valid_until date string '{promo['valid_until']}' for promo code {promo['code']}: {e}" + ) + valid_until_display_text = promo['valid_until'] + response_text_parts.append( + _("admin_promo_list_item", + code=promo['code'], + bonus=promo['bonus_days'], + current=promo['current_activations'], + max=promo['max_activations'], + valid_until=valid_until_display_text)) - valid_until_dt :Optional [datetime ]=None - if 'T'in promo ['valid_until']: - valid_until_dt =datetime .fromisoformat (promo ['valid_until'].replace ("Z","+00:00")) - else : - valid_until_dt =datetime .strptime (promo ['valid_until'],'%Y-%m-%d %H:%M:%S') - - - if valid_until_dt and valid_until_dt .tzinfo is None : - valid_until_dt =valid_until_dt .replace (tzinfo =timezone .utc ) - - valid_until_display_text =valid_until_dt .strftime ('%Y-%m-%d') - except ValueError as e : - logging .warning (f"Could not parse valid_until date string '{promo['valid_until']}' for promo code {promo['code']}: {e}") - valid_until_display_text =promo ['valid_until'] - - response_text_parts .append ( - _ ("admin_promo_list_item",code =promo ['code'],bonus =promo ['bonus_days'], - current =promo ['current_activations'],max =promo ['max_activations'], - valid_until =valid_until_display_text ) - ) - - final_text ="\n".join (response_text_parts ) - try : - await callback .message .edit_text (final_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n ),parse_mode ="HTML") - except Exception as e : - logging .warning (f"Failed to edit message for promo list: {e}") - if callback .message : - await callback .message .answer (final_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n ),parse_mode ="HTML") - await callback .answer () - - - - + final_text = "\n".join(response_text_parts) + try: + await callback.message.edit_text( + final_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") + except Exception as e: + logging.warning(f"Failed to edit message for promo list: {e}") + if callback.message: + await callback.message.answer( + final_text, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n), + parse_mode="HTML") + await callback.answer() diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index 8b3b0e9..439276b 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -1,101 +1,119 @@ import logging -from aiogram import Router ,F ,types -from typing import Optional ,Dict +from aiogram import Router, F, types +from typing import Optional, Dict from datetime import datetime -from config .settings import Settings -from db .database import get_user_count_stats ,get_payment_logs ,get_message_logs_db ,get_last_sync_status -from bot .keyboards .inline .admin_keyboards import get_back_to_admin_panel_keyboard -from bot .middlewares .i18n import JsonI18n +from config.settings import Settings +from db.database import get_user_count_stats, get_payment_logs, get_message_logs_db, get_last_sync_status +from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard +from bot.middlewares.i18n import JsonI18n -router =Router (name ="admin_statistics_router") +router = Router(name="admin_statistics_router") -async def show_statistics_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n missing in show_statistics_handler") - await callback .answer ("Language service error.",show_alert =True ) +async def show_statistics_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing in show_statistics_handler") + await callback.answer("Language service error.", show_alert=True) return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - await callback .answer () + await callback.answer() - stats_text_parts =[f"{_('admin_stats_header')}"] + stats_text_parts = [f"{_('admin_stats_header')}"] + user_stats = await get_user_count_stats() + stats_text_parts.append( + _("admin_stats_users", + total_users=user_stats.get("total_users", 0), + banned_users=user_stats.get("banned_users", 0), + active_subs=user_stats.get("users_with_active_subscriptions", 0))) - user_stats =await get_user_count_stats () - stats_text_parts .append ( - _ ("admin_stats_users", - total_users =user_stats .get ("total_users",0 ), - banned_users =user_stats .get ("banned_users",0 ), - active_subs =user_stats .get ("users_with_active_subscriptions",0 ) - ) - ) + last_payments = await get_payment_logs(limit=5) + if last_payments: + stats_text_parts.append( + f"\n{_('admin_stats_recent_payments_header')}") + for payment in last_payments: + status_emoji = "✅" if payment['status'] == 'succeeded' else ( + "⏳" if payment['status'] == 'pending' else "❌") + user_info = f"User {payment['user_id']}" + ( + f" (@{payment['username']})" if payment['username'] else "") + payment_date_str = payment['created_at'] + if isinstance(payment_date_str, + str) and len(payment_date_str) > 10: + payment_date_str = payment_date_str[:10] + stats_text_parts.append( + _("admin_stats_payment_item", + status_emoji=status_emoji, + amount=payment['amount'], + currency=payment['currency'], + user_info=user_info, + p_status=payment['status'], + p_date=payment_date_str)) + else: + stats_text_parts.append(f"\n{_('admin_stats_no_payments_found')}") - last_payments =await get_payment_logs (limit =5 ) - if last_payments : - stats_text_parts .append (f"\n{_('admin_stats_recent_payments_header')}") - for payment in last_payments : - status_emoji ="✅"if payment ['status']=='succeeded'else ("⏳"if payment ['status']=='pending'else "❌") - user_info =f"User {payment['user_id']}"+(f" (@{payment['username']})"if payment ['username']else "") - payment_date_str =payment ['created_at'] - if isinstance (payment_date_str ,str )and len (payment_date_str )>10 : - payment_date_str =payment_date_str [:10 ] + sync_status = await get_last_sync_status() + if sync_status: + stats_text_parts.append( + f"\n{_('admin_stats_last_sync_header')}") - stats_text_parts .append ( - _ ("admin_stats_payment_item", - status_emoji =status_emoji ,amount =payment ['amount'],currency =payment ['currency'], - user_info =user_info ,p_status =payment ['status'],p_date =payment_date_str - ) - ) - else : - stats_text_parts .append (f"\n{_('admin_stats_no_payments_found')}") + sync_time_val = sync_status['last_sync_time'] + sync_time_str = "N/A" + if isinstance(sync_time_val, datetime): + sync_time_str = sync_time_val.strftime('%Y-%m-%d %H:%M:%S UTC') + elif isinstance(sync_time_val, str): + sync_time_str = sync_time_val[:19] if len( + sync_time_val) > 19 else sync_time_val + details_val = sync_status['details'] + details_str = (details_val[:100] + + "...") if details_val and len(details_val) > 100 else ( + details_val or "N/A") - sync_status =await get_last_sync_status () - if sync_status : - stats_text_parts .append (f"\n{_('admin_stats_last_sync_header')}") + stats_text_parts.append( + f" {_('admin_stats_sync_time')}: {sync_time_str}") + stats_text_parts.append( + f" {_('admin_stats_sync_status')}: {sync_status['status']}") + stats_text_parts.append( + f" {_('admin_stats_sync_users_processed')}: {sync_status['users_processed_from_panel']}" + ) + stats_text_parts.append( + f" {_('admin_stats_sync_subs_synced')}: {sync_status['subscriptions_synced']}" + ) + stats_text_parts.append( + f" {_('admin_stats_sync_details_label')}: {details_str}") + else: + stats_text_parts.append(f"\n{_('admin_sync_status_never_run')}") - sync_time_val =sync_status ['last_sync_time'] - sync_time_str ="N/A" - if isinstance (sync_time_val ,datetime ): - sync_time_str =sync_time_val .strftime ('%Y-%m-%d %H:%M:%S UTC') - elif isinstance (sync_time_val ,str ): - sync_time_str =sync_time_val [:19 ]if len (sync_time_val )>19 else sync_time_val + final_text = "\n".join(stats_text_parts) - details_val =sync_status ['details'] - details_str =(details_val [:100 ]+"...")if details_val and len (details_val )>100 else (details_val or "N/A") + if callback.message: + try: + await callback.message.edit_text( + final_text, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n), + parse_mode="HTML") + except Exception as e: + logging.error(f"Error editing message for statistics: {e}", + exc_info=True) - - stats_text_parts .append (f" {_('admin_stats_sync_time')}: {sync_time_str}") - stats_text_parts .append (f" {_('admin_stats_sync_status')}: {sync_status['status']}") - stats_text_parts .append (f" {_('admin_stats_sync_users_processed')}: {sync_status['users_processed_from_panel']}") - stats_text_parts .append (f" {_('admin_stats_sync_subs_synced')}: {sync_status['subscriptions_synced']}") - stats_text_parts .append (f" {_('admin_stats_sync_details_label')}: {details_str}") - else : - stats_text_parts .append (f"\n{_('admin_sync_status_never_run')}") - - final_text ="\n".join (stats_text_parts ) - - if callback .message : - try : - await callback .message .edit_text ( - final_text , - reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n ), - parse_mode ="HTML" - ) - except Exception as e : - logging .error (f"Error editing message for statistics: {e}",exc_info =True ) - - for chunk in [final_text [i :i +4000 ]for i in range (0 ,len (final_text ),4000 )]: - await callback .message .answer ( - chunk , - reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )if chunk ==final_text [-len (chunk ):]else None , - parse_mode ="HTML" - ) - else : - logging .error ("Cannot send statistics, callback.message is None.") \ No newline at end of file + for chunk in [ + final_text[i:i + 4000] + for i in range(0, len(final_text), 4000) + ]: + await callback.message.answer( + chunk, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n) + if chunk == final_text[-len(chunk):] else None, + parse_mode="HTML") + else: + logging.error("Cannot send statistics, callback.message is None.") diff --git a/bot/handlers/admin/sync_admin.py b/bot/handlers/admin/sync_admin.py index a88e28b..8e60ecf 100644 --- a/bot/handlers/admin/sync_admin.py +++ b/bot/handlers/admin/sync_admin.py @@ -1,126 +1,148 @@ import logging -from aiogram import Router ,types ,Bot -from aiogram .filters import Command +from aiogram import Router, types, Bot +from aiogram.filters import Command from typing import Optional -from config .settings import Settings -from bot .services .panel_api_service import PanelApiService +from config.settings import Settings +from bot.services.panel_api_service import PanelApiService -from db .database import update_sync_status ,get_last_sync_status ,sync_panel_user_data -from bot .middlewares .i18n import JsonI18n -from bot .keyboards .inline .admin_keyboards import get_back_to_admin_panel_keyboard +from db.database import update_sync_status, get_last_sync_status, sync_panel_user_data +from bot.middlewares.i18n import JsonI18n +from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard -router =Router (name ="admin_sync_router") +router = Router(name="admin_sync_router") -@router .message (Command ("sync")) -async def sync_command_handler ( -message :types .Message , -bot :Bot , -settings :Settings , -i18n_data :dict , -panel_service :PanelApiService -): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :logging .error ("i18n missing");await message .answer ("Language error.");return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - await message .answer (_ ("sync_started")) - logging .info ("Admin triggered panel sync.") +@router.message(Command("sync")) +async def sync_command_handler(message: types.Message, bot: Bot, + settings: Settings, i18n_data: dict, + panel_service: PanelApiService): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing") + await message.answer("Language error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - users_processed_count =0 - users_synced_successfully =0 - sync_errors =[] + await message.answer(_("sync_started")) + logging.info("Admin triggered panel sync.") - try : - panel_users_data =await panel_service .get_all_panel_users () + users_processed_count = 0 + users_synced_successfully = 0 + sync_errors = [] - if panel_users_data is None : - error_msg ="Failed to fetch users from panel or panel API issue." - sync_errors .append (error_msg ) - await update_sync_status ("failed",error_msg ) - await message .answer (_ ("sync_failed",details =error_msg )) + try: + panel_users_data = await panel_service.get_all_panel_users() + + if panel_users_data is None: + error_msg = "Failed to fetch users from panel or panel API issue." + sync_errors.append(error_msg) + await update_sync_status("failed", error_msg) + await message.answer(_("sync_failed", details=error_msg)) return - if not panel_users_data : - status_msg ="No users found in the panel to sync." - await update_sync_status ("success",status_msg ,0 ,0 ) - await message .answer (_ ("sync_completed",status ="Success",details =status_msg )) + if not panel_users_data: + status_msg = "No users found in the panel to sync." + await update_sync_status("success", status_msg, 0, 0) + await message.answer( + _("sync_completed", status="Success", details=status_msg)) return - total_panel_users =len (panel_users_data ) - logging .info (f"Starting sync for {total_panel_users} panel users.") - - - - - - - - - - - - - - - for panel_user_dict in panel_users_data : - users_processed_count +=1 - telegram_id_from_panel =panel_user_dict .get ('telegramId') - panel_uuid =panel_user_dict .get ('uuid') - - if not telegram_id_from_panel : - logging .info (f"Panel user {panel_uuid} (username: {panel_user_dict.get('username')}) has no 'telegramId'. Skipping TG ID based sync.") + total_panel_users = len(panel_users_data) + logging.info(f"Starting sync for {total_panel_users} panel users.") + for panel_user_dict in panel_users_data: + users_processed_count += 1 + telegram_id_from_panel = panel_user_dict.get('telegramId') + panel_uuid = panel_user_dict.get('uuid') + if not telegram_id_from_panel: + logging.info( + f"Panel user {panel_uuid} (username: {panel_user_dict.get('username')}) has no 'telegramId'. Skipping TG ID based sync." + ) continue - if not panel_uuid : - logging .warning (f"Panel user (TG ID: {telegram_id_from_panel}) missing 'uuid'. Skipping.") - sync_errors .append (f"Panel user data for TG ID {telegram_id_from_panel} missing UUID.") + if not panel_uuid: + logging.warning( + f"Panel user (TG ID: {telegram_id_from_panel}) missing 'uuid'. Skipping." + ) + sync_errors.append( + f"Panel user data for TG ID {telegram_id_from_panel} missing UUID." + ) continue + if await sync_panel_user_data(panel_user_dict): + users_synced_successfully += 1 + else: + sync_errors.append( + f"Sync issue for panel user: {panel_uuid} (TG ID: {telegram_id_from_panel})" + ) - if await sync_panel_user_data (panel_user_dict ): - users_synced_successfully +=1 - else : - sync_errors .append (f"Sync issue for panel user: {panel_uuid} (TG ID: {telegram_id_from_panel})") + if users_processed_count % 20 == 0: + logging.info( + f"Sync progress: {users_processed_count}/{total_panel_users} users processed." + ) - if users_processed_count %20 ==0 : - logging .info (f"Sync progress: {users_processed_count}/{total_panel_users} users processed.") + status_msg = f"Panel users checked: {total_panel_users}. Users/Subscriptions synced via TG ID: {users_synced_successfully}." + if sync_errors: + status_msg += f" Errors encountered: {len(sync_errors)}. See logs for details." + error_preview = "\n".join(sync_errors[:3]) + await update_sync_status("partial_success", + status_msg + " " + error_preview, + total_panel_users, + users_synced_successfully) + await message.answer( + _("sync_completed", + status="Partial Success", + details=status_msg)) + else: + await update_sync_status("success", status_msg, total_panel_users, + users_synced_successfully) + await message.answer( + _("sync_completed", status="Success", details=status_msg)) - status_msg =f"Panel users checked: {total_panel_users}. Users/Subscriptions synced via TG ID: {users_synced_successfully}." - if sync_errors : - status_msg +=f" Errors encountered: {len(sync_errors)}. See logs for details." - error_preview ="\n".join (sync_errors [:3 ]) - await update_sync_status ("partial_success",status_msg +" "+error_preview ,total_panel_users ,users_synced_successfully ) - await message .answer (_ ("sync_completed",status ="Partial Success",details =status_msg )) - else : - await update_sync_status ("success",status_msg ,total_panel_users ,users_synced_successfully ) - await message .answer (_ ("sync_completed",status ="Success",details =status_msg )) + except Exception as e: + logging.error(f"Error during /sync command: {e}", exc_info=True) + error_detail = f"An unexpected error occurred during sync: {str(e)}" + await update_sync_status("failed", error_detail, users_processed_count, + users_synced_successfully) + await message.answer(_("sync_failed", details=error_detail)) - except Exception as e : - logging .error (f"Error during /sync command: {e}",exc_info =True ) - error_detail =f"An unexpected error occurred during sync: {str(e)}" - await update_sync_status ("failed",error_detail ,users_processed_count ,users_synced_successfully ) - await message .answer (_ ("sync_failed",details =error_detail )) -@router .message (Command ("syncstatus")) -async def sync_status_command_handler (message :types .Message ,i18n_data :dict ,settings :Settings ): +@router.message(Command("syncstatus")) +async def sync_status_command_handler(message: types.Message, i18n_data: dict, + settings: Settings): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :await message .answer ("Language error.");return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );status_record =await get_last_sync_status ();response_text ="" - if status_record : - last_time_val =status_record ['last_sync_time'];last_time_str =last_time_val .strftime ('%Y-%m-%d %H:%M:%S UTC')if isinstance (last_time_val ,datetime )else str (last_time_val ) - if isinstance (last_time_str ,str )and len (last_time_str )>19 :last_time_str =last_time_str [:19 ] - details_val =status_record ['details'];details_str =(details_val [:200 ]+"...")if details_val and len (details_val )>200 else (details_val or "N/A") - response_text =(f"{_('admin_stats_last_sync_header')}\n" - f" {_('admin_stats_sync_time')}: {last_time_str}\n" - f" {_('admin_stats_sync_status')}: {status_record['status']}\n" - f" {_('admin_stats_sync_users_processed')}: {status_record['users_processed_from_panel']}\n" - f" {_('admin_stats_sync_subs_synced')}: {status_record['subscriptions_synced']}\n" - f" {_('admin_stats_sync_details_label')}: {details_str}") - else :response_text =_ ("admin_sync_status_never_run") - await message .answer (response_text ,parse_mode ="HTML") \ No newline at end of file + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.answer("Language error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + status_record = await get_last_sync_status() + response_text = "" + if status_record: + last_time_val = status_record['last_sync_time'] + last_time_str = last_time_val.strftime( + '%Y-%m-%d %H:%M:%S UTC') if isinstance( + last_time_val, datetime) else str(last_time_val) + if isinstance(last_time_str, str) and len(last_time_str) > 19: + last_time_str = last_time_str[:19] + details_val = status_record['details'] + details_str = (details_val[:200] + + "...") if details_val and len(details_val) > 200 else ( + details_val or "N/A") + response_text = ( + f"{_('admin_stats_last_sync_header')}\n" + f" {_('admin_stats_sync_time')}: {last_time_str}\n" + f" {_('admin_stats_sync_status')}: {status_record['status']}\n" + f" {_('admin_stats_sync_users_processed')}: {status_record['users_processed_from_panel']}\n" + f" {_('admin_stats_sync_subs_synced')}: {status_record['subscriptions_synced']}\n" + f" {_('admin_stats_sync_details_label')}: {details_str}") + else: + response_text = _("admin_sync_status_never_run") + await message.answer(response_text, parse_mode="HTML") diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index b8dfba5..b9d12f2 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -1,220 +1,580 @@ import logging import re -from aiogram import Router ,F ,types ,Bot -from aiogram .fsm .context import FSMContext -from typing import Optional ,Tuple ,List ,Any +from aiogram import Router, F, types, Bot +from aiogram.fsm.context import FSMContext +from typing import Optional, Tuple, List, Any import math import aiosqlite from datetime import datetime -from config .settings import Settings -from db .database import ( -get_user ,set_user_ban_status_db ,get_user_by_telegram_username , -get_banned_users_list_paginated ,get_user_active_subscription_end_date -) -from bot .services .panel_api_service import PanelApiService -from bot .states .admin_states import AdminStates -from bot .keyboards .inline .admin_keyboards import ( -get_back_to_admin_panel_keyboard ,get_user_card_keyboard , -get_banned_users_keyboard ,get_confirmation_keyboard ,get_admin_panel_keyboard -) -from bot .middlewares .i18n import JsonI18n +from config.settings import Settings +from db.database import (get_user, set_user_ban_status_db, + get_user_by_telegram_username, + get_banned_users_list_paginated, + get_user_active_subscription_end_date) +from bot.services.panel_api_service import PanelApiService +from bot.states.admin_states import AdminStates +from bot.keyboards.inline.admin_keyboards import ( + get_back_to_admin_panel_keyboard, get_user_card_keyboard, + get_banned_users_keyboard, get_confirmation_keyboard, + get_admin_panel_keyboard) +from bot.middlewares.i18n import JsonI18n -router =Router (name ="admin_user_management_router") +router = Router(name="admin_user_management_router") -USERNAME_REGEX =re .compile (r"^[a-zA-Z0-9_]{5,32}$") +USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") -async def ban_user_prompt_handler (callback :types .CallbackQuery ,state :FSMContext ,i18n_data :dict ,settings :Settings ): +async def ban_user_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :logging .error ("i18n missing");await callback .answer ("Language error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );prompt_text =_ ("admin_ban_user_prompt") - if callback .message : - try :await callback .message .edit_text (prompt_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - except Exception as e :logging .warning (f"Edit failed: {e}");await callback .message .answer (prompt_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - await callback .answer ();await state .set_state (AdminStates .waiting_for_user_id_to_ban ) - - -@router .message (AdminStates .waiting_for_user_id_to_ban ,F .text ) -async def process_user_input_to_ban_handler (message :types .Message ,state :FSMContext ,i18n_data :dict ,settings :Settings ,panel_service :PanelApiService ): - - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :logging .error ("i18n missing");await message .reply ("Language error.");return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );input_text =message .text .strip ();user_to_ban_data :Optional [aiosqlite .Row ]=None ;user_id_or_username_for_msg =input_text - if input_text .isdigit (): - try :user_id_to_ban_val =int (input_text );user_to_ban_data =await get_user (user_id_to_ban_val ) - except ValueError :await message .answer (_ ("admin_invalid_user_id_format"));return - elif input_text .startswith ("@")and USERNAME_REGEX .match (input_text [1 :]):user_to_ban_data =await get_user_by_telegram_username (input_text [1 :]) - elif USERNAME_REGEX .match (input_text ):user_to_ban_data =await get_user_by_telegram_username (input_text ) - else :await message .answer (_ ("admin_invalid_user_id_format")+" "+_ ("admin_invalid_username_format"));return - if not user_to_ban_data :await message .answer (_ ("admin_user_not_found_in_bot_db",user_id =input_text ));await state .clear ();return - user_id_to_ban =user_to_ban_data ['user_id'];user_id_or_username_for_msg =f"@{user_to_ban_data['username']}"if user_to_ban_data .get ('username')else str (user_id_to_ban ) - if user_id_to_ban ==message .from_user .id or user_id_to_ban in settings .ADMIN_IDS :await message .answer (_ ("admin_cannot_ban_self_or_admin"));await state .clear ();return - if user_to_ban_data ['is_banned']:await message .answer (_ ("admin_user_already_banned",user_id_or_username =user_id_or_username_for_msg ));await state .clear ();return - panel_user_uuid =user_to_ban_data ['panel_user_uuid']if user_to_ban_data and 'panel_user_uuid'in user_to_ban_data .keys ()and user_to_ban_data ['panel_user_uuid']else None - await set_user_ban_status_db (user_id_to_ban ,is_banned =True ) - reply_markup_val =get_back_to_admin_panel_keyboard (current_lang ,i18n ) - if panel_user_uuid : - panel_ban_success =await panel_service .update_user_status_on_panel (panel_user_uuid ,enable =False ) - if panel_ban_success :await message .answer (_ ("admin_user_banned_success_panel_too",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val ) - else :await message .answer (_ ("admin_user_banned_local_panel_fail",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val ) - else :await message .answer (_ ("admin_user_banned_local_no_panel_uuid",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val ) - await state .clear () - - -async def unban_user_prompt_handler (callback :types .CallbackQuery ,state :FSMContext ,i18n_data :dict ,settings :Settings ): - - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :logging .error ("i18n missing");await callback .answer ("Language error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );prompt_text =_ ("admin_unban_user_prompt") - if callback .message : - try :await callback .message .edit_text (prompt_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - except :await callback .message .answer (prompt_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - await callback .answer ();await state .set_state (AdminStates .waiting_for_user_id_to_unban ) - -@router .message (AdminStates .waiting_for_user_id_to_unban ,F .text ) -async def process_user_input_to_unban_handler (message :types .Message ,state :FSMContext ,i18n_data :dict ,settings :Settings ,panel_service :PanelApiService ): - - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :logging .error ("i18n missing");await message .reply ("Language error.");return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );input_text =message .text .strip ();user_to_unban_data :Optional [aiosqlite .Row ]=None ;user_id_or_username_for_msg =input_text - if input_text .isdigit (): - try :user_id_to_unban_val =int (input_text );user_to_unban_data =await get_user (user_id_to_unban_val ) - except ValueError :await message .answer (_ ("admin_invalid_user_id_format"));return - elif input_text .startswith ("@")and USERNAME_REGEX .match (input_text [1 :]):user_to_unban_data =await get_user_by_telegram_username (input_text [1 :]) - elif USERNAME_REGEX .match (input_text ):user_to_unban_data =await get_user_by_telegram_username (input_text ) - else :await message .answer (_ ("admin_invalid_user_id_format")+" "+_ ("admin_invalid_username_format"));return - if not user_to_unban_data :await message .answer (_ ("admin_user_not_found_in_bot_db",user_id =input_text ));await state .clear ();return - user_id_to_unban =user_to_unban_data ['user_id'];user_id_or_username_for_msg =f"@{user_to_unban_data['username']}"if user_to_unban_data .get ('username')else str (user_id_to_unban ) - if not user_to_unban_data ['is_banned']:await message .answer (_ ("admin_user_not_banned",user_id_or_username =user_id_or_username_for_msg ));await state .clear ();return - panel_user_uuid =user_to_unban_data ['panel_user_uuid']if user_to_unban_data and 'panel_user_uuid'in user_to_unban_data .keys ()and user_to_unban_data ['panel_user_uuid']else None - await set_user_ban_status_db (user_id_to_unban ,is_banned =False ) - reply_markup_val =get_back_to_admin_panel_keyboard (current_lang ,i18n ) - if panel_user_uuid : - panel_unban_success =await panel_service .update_user_status_on_panel (panel_user_uuid ,enable =True ) - if panel_unban_success :await message .answer (_ ("admin_user_unbanned_success_panel_too",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val ) - else :await message .answer (_ ("admin_user_unbanned_local_panel_fail",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val ) - else :await message .answer (_ ("admin_user_unbanned_local_no_panel_uuid",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val ) - await state .clear () - - -async def view_banned_users_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ,state :FSMContext ): - await state .clear ();current_page =0 ; - if ":"in callback .data and callback .data .count (":")==2 : - try :current_page =int (callback .data .split (":")[-1 ]) - except ValueError :current_page =0 - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :await callback .answer ("Language error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - - - banned_users ,total_banned =await get_banned_users_list_paginated (limit =settings .LOGS_PAGE_SIZE ,offset =current_page *settings .LOGS_PAGE_SIZE ) - - if not callback .message :await callback .answer ("Error.");return - if total_banned ==0 : - await callback .message .edit_text (_ ("admin_no_banned_users"),reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )) - else : - total_pages =math .ceil (total_banned /settings .LOGS_PAGE_SIZE )if settings .LOGS_PAGE_SIZE >0 else 1 - await callback .message .edit_text (text =_ ("admin_banned_list_title",current_page =current_page +1 ,total_pages =max (1 ,total_pages )),reply_markup =get_banned_users_keyboard (banned_users ,current_page ,total_banned ,i18n ,current_lang ,settings )) - await callback .answer () - -@router .callback_query (F .data .startswith ("admin_user_card:")) -async def show_user_card_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ,panel_service :PanelApiService ,state :FSMContext ,force_user_id :Optional [int ]=None ,force_page :Optional [int ]=None ): - - await state .clear ();user_id_to_show =0 ;banned_list_page_to_return =0 - if force_user_id is not None and force_page is not None :user_id_to_show =force_user_id ;banned_list_page_to_return =force_page - else : - try :parts =callback .data .split (":");user_id_to_show =int (parts [1 ]);banned_list_page_to_return =int (parts [2 ])if len (parts )>2 else 0 - except (IndexError ,ValueError ):await callback .answer ("Invalid user card data.",show_alert =True );return - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :await callback .answer ("Language error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - user_data =await get_user (user_id_to_show ) - if not callback .message :await callback .answer ("Error.");return - if not user_data :await callback .message .edit_text (_ ("admin_user_not_found_in_bot_db",user_id =user_id_to_show ),reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n ));await callback .answer ();return - user_display_name =user_data ['first_name']or (f"@{user_data['username']}"if user_data .get ('username')else f"ID: {user_id_to_show}") - sub_end_date_str =await get_user_active_subscription_end_date (user_id_to_show )or _ ("user_card_sub_na") - reg_date_from_db =user_data ['registration_date_str']if 'registration_date_str'in user_data .keys ()else "N/A" - reg_date_display =reg_date_from_db [:10 ]if reg_date_from_db and reg_date_from_db !="N/A"else "N/A" - card_text =_ ("user_card_info",user_id =user_data ['user_id'],username =user_data .get ('username',"N/A"),first_name =user_data .get ('first_name',""),last_name =user_data .get ('last_name',""),language_code =user_data .get ('language_code',"N/A"),panel_user_uuid =user_data .get ('panel_user_uuid',"N/A"),ban_status =_ (key ="user_card_banned")if user_data ['is_banned']else _ (key ="user_card_active"),reg_date =reg_date_display ,sub_end_date =sub_end_date_str ) - await callback .message .edit_text (text =f"{_('admin_user_card_title', user_display=user_display_name)}\n\n{card_text}",reply_markup =get_user_card_keyboard (user_id_to_show ,bool (user_data ['is_banned']),i18n ,current_lang ,banned_list_page_to_return ),parse_mode ="HTML") - if force_user_id is None :await callback .answer () - -@router .callback_query (F .data .startswith ("admin_unban_confirm:")) -async def confirm_unban_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ): - - try :_ ,user_id_str ,page_str =callback .data .split (":");user_id =int (user_id_str );banned_list_page =int (page_str ) - except :await callback .answer ("Invalid data",show_alert =True );return - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :await callback .answer ("Language error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - user_data =await get_user (user_id );user_display =(user_data ['first_name']or (f"@{user_data['username']}"if user_data .get ('username')else f"ID {user_id}"))if user_data else f"ID {user_id}" - if callback .message :await callback .message .edit_text (text =_ ("admin_confirm_unban_prompt",user_display =user_display ,user_id =user_id ),reply_markup =get_confirmation_keyboard (yes_callback_data =f"admin_unban_do:{user_id}:{banned_list_page}",no_callback_data =f"admin_user_card:{user_id}:{banned_list_page}",i18n_instance =i18n ,lang =current_lang ),parse_mode ="HTML") - await callback .answer () - - -@router .callback_query (F .data .startswith ("admin_ban_confirm:")) -async def confirm_ban_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ): - try :_ ,user_id_str ,page_str =callback .data .split (":");user_id =int (user_id_str );banned_list_page =int (page_str ) - except :await callback .answer ("Invalid data",show_alert =True );return - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :await callback .answer ("Language error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - user_data =await get_user (user_id );user_display =(user_data ['first_name']or (f"@{user_data['username']}"if user_data .get ('username')else f"ID {user_id}"))if user_data else f"ID {user_id}" - if callback .message : - await callback .message .edit_text (text =_ ("admin_confirm_ban_prompt",user_display =user_display ,user_id =user_id ),reply_markup =get_confirmation_keyboard (yes_callback_data =f"admin_ban_do:{user_id}:{banned_list_page}",no_callback_data =f"admin_user_card:{user_id}:{banned_list_page}",i18n_instance =i18n ,lang =current_lang ),parse_mode ="HTML") - await callback .answer () - - -@router .callback_query (F .data .startswith ("admin_unban_do:")) -async def do_unban_user_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ,panel_service :PanelApiService ,state :FSMContext ): - - try :_ ,user_id_str ,page_str =callback .data .split (":");user_id_to_unban =int (user_id_str );banned_list_page =int (page_str ) - except :await callback .answer ("Invalid data",show_alert =True );return - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :await callback .answer ("Language error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - user_to_unban_data =await get_user (user_id_to_unban );user_display_name =(user_to_unban_data ['first_name']or (f"@{user_to_unban_data['username']}"if user_to_unban_data .get ('username')else f"ID {user_id_to_unban}"))if user_to_unban_data else f"ID {user_id_to_unban}" - if not user_to_unban_data or not user_to_unban_data ['is_banned']: - await callback .answer (_ ("admin_user_not_banned",user_id_or_username =user_display_name ),show_alert =True ) - if callback .message :await show_user_card_handler (callback ,i18n_data ,settings ,panel_service ,state ,force_user_id =user_id_to_unban ,force_page =banned_list_page ) + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing") + await callback.answer("Language error.", show_alert=True) return - await set_user_ban_status_db (user_id_to_unban ,is_banned =False ) - panel_user_uuid =user_to_unban_data ['panel_user_uuid']if user_to_unban_data and 'panel_user_uuid'in user_to_unban_data .keys ()and user_to_unban_data ['panel_user_uuid']else None - if panel_user_uuid : - if not await panel_service .update_user_status_on_panel (panel_user_uuid ,enable =True ):logging .warning (f"Panel status update fail for unban {user_id_to_unban}") - await callback .answer (_ ("admin_user_unbanned_from_card",user_display =user_display_name ,user_id =user_id_to_unban ),show_alert =False ) - if callback .message :await show_user_card_handler (callback ,i18n_data ,settings ,panel_service ,state ,force_user_id =user_id_to_unban ,force_page =banned_list_page ) - -@router .callback_query (F .data .startswith ("admin_ban_do:")) -async def do_ban_user_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ,panel_service :PanelApiService ,state :FSMContext ): - try :_ ,user_id_str ,page_str =callback .data .split (":");user_id_to_ban =int (user_id_str );banned_list_page =int (page_str ) - except :await callback .answer ("Invalid data",show_alert =True );return - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :await callback .answer ("Language error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - user_to_ban_data =await get_user (user_id_to_ban );user_display_name =(user_to_ban_data ['first_name']or (f"@{user_to_ban_data['username']}"if user_to_ban_data .get ('username')else f"ID {user_id_to_ban}"))if user_to_ban_data else f"ID {user_id_to_ban}" - if not user_to_ban_data :await callback .answer (_ ("admin_user_not_found_in_bot_db",user_id =user_id_to_ban ),show_alert =True );return - if user_to_ban_data ['is_banned']:await callback .answer (_ ("admin_user_already_banned",user_id_or_username =user_display_name ),show_alert =True );return - if user_id_to_ban ==callback .from_user .id or user_id_to_ban in settings .ADMIN_IDS :await callback .answer (_ ("admin_cannot_ban_self_or_admin"),show_alert =True );return - - await set_user_ban_status_db (user_id_to_ban ,is_banned =True ) - panel_user_uuid =user_to_ban_data ['panel_user_uuid']if user_to_ban_data and 'panel_user_uuid'in user_to_ban_data .keys ()and user_to_ban_data ['panel_user_uuid']else None - if panel_user_uuid : - if not await panel_service .update_user_status_on_panel (panel_user_uuid ,enable =False ):logging .warning (f"Panel status update fail for ban {user_id_to_ban}") - await callback .answer (_ ("admin_user_banned_from_card",user_display =user_display_name ,user_id =user_id_to_ban ),show_alert =False ) - if callback .message :await show_user_card_handler (callback ,i18n_data ,settings ,panel_service ,state ,force_user_id =user_id_to_ban ,force_page =banned_list_page ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + prompt_text = _("admin_ban_user_prompt") + if callback.message: + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) + except Exception as e: + logging.warning(f"Edit failed: {e}") + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) + await callback.answer() + await state.set_state(AdminStates.waiting_for_user_id_to_ban) -@router .callback_query (F .data =="admin_action:main",AdminStates .waiting_for_user_id_to_ban ) -@router .callback_query (F .data =="admin_action:main",AdminStates .waiting_for_user_id_to_unban ) -async def cancel_user_management_input_state (callback :types .CallbackQuery ,state :FSMContext ,settings :Settings ,i18n_data :dict ,bot :Bot ): +@router.message(AdminStates.waiting_for_user_id_to_ban, F.text) +async def process_user_input_to_ban_handler(message: types.Message, + state: FSMContext, i18n_data: dict, + settings: Settings, + panel_service: PanelApiService): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :await callback .answer ("Language error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - if callback .message : - try :await callback .message .edit_text (_ ("admin_action_cancelled_default"),reply_markup =get_admin_panel_keyboard (i18n ,current_lang ,settings )) - except :await callback .message .answer (_ ("admin_action_cancelled_default"),reply_markup =get_admin_panel_keyboard (i18n ,current_lang ,settings )) - await callback .answer (_ ("admin_action_cancelled_default"));await state .clear () \ No newline at end of file + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing") + await message.reply("Language error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + input_text = message.text.strip() + user_to_ban_data: Optional[aiosqlite.Row] = None + user_id_or_username_for_msg = input_text + if input_text.isdigit(): + try: + user_id_to_ban_val = int(input_text) + user_to_ban_data = await get_user(user_id_to_ban_val) + except ValueError: + await message.answer(_("admin_invalid_user_id_format")) + return + elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]): + user_to_ban_data = await get_user_by_telegram_username(input_text[1:]) + elif USERNAME_REGEX.match(input_text): + user_to_ban_data = await get_user_by_telegram_username(input_text) + else: + await message.answer( + _("admin_invalid_user_id_format") + " " + + _("admin_invalid_username_format")) + return + if not user_to_ban_data: + await message.answer( + _("admin_user_not_found_in_bot_db", user_id=input_text)) + await state.clear() + return + user_id_to_ban = user_to_ban_data['user_id'] + user_id_or_username_for_msg = f"@{user_to_ban_data['username']}" if user_to_ban_data.get( + 'username') else str(user_id_to_ban) + if user_id_to_ban == message.from_user.id or user_id_to_ban in settings.ADMIN_IDS: + await message.answer(_("admin_cannot_ban_self_or_admin")) + await state.clear() + return + if user_to_ban_data['is_banned']: + await message.answer( + _("admin_user_already_banned", + user_id_or_username=user_id_or_username_for_msg)) + await state.clear() + return + panel_user_uuid = user_to_ban_data[ + 'panel_user_uuid'] if user_to_ban_data and 'panel_user_uuid' in user_to_ban_data.keys( + ) and user_to_ban_data['panel_user_uuid'] else None + await set_user_ban_status_db(user_id_to_ban, is_banned=True) + reply_markup_val = get_back_to_admin_panel_keyboard(current_lang, i18n) + if panel_user_uuid: + panel_ban_success = await panel_service.update_user_status_on_panel( + panel_user_uuid, enable=False) + if panel_ban_success: + await message.answer(_( + "admin_user_banned_success_panel_too", + user_id_or_username=user_id_or_username_for_msg), + reply_markup=reply_markup_val) + else: + await message.answer(_( + "admin_user_banned_local_panel_fail", + user_id_or_username=user_id_or_username_for_msg), + reply_markup=reply_markup_val) + else: + await message.answer(_( + "admin_user_banned_local_no_panel_uuid", + user_id_or_username=user_id_or_username_for_msg), + reply_markup=reply_markup_val) + await state.clear() + + +async def unban_user_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings): + + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing") + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + prompt_text = _("admin_unban_user_prompt") + if callback.message: + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) + except: + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) + await callback.answer() + await state.set_state(AdminStates.waiting_for_user_id_to_unban) + + +@router.message(AdminStates.waiting_for_user_id_to_unban, F.text) +async def process_user_input_to_unban_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + panel_service: PanelApiService): + + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing") + await message.reply("Language error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + input_text = message.text.strip() + user_to_unban_data: Optional[aiosqlite.Row] = None + user_id_or_username_for_msg = input_text + if input_text.isdigit(): + try: + user_id_to_unban_val = int(input_text) + user_to_unban_data = await get_user(user_id_to_unban_val) + except ValueError: + await message.answer(_("admin_invalid_user_id_format")) + return + elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]): + user_to_unban_data = await get_user_by_telegram_username(input_text[1:] + ) + elif USERNAME_REGEX.match(input_text): + user_to_unban_data = await get_user_by_telegram_username(input_text) + else: + await message.answer( + _("admin_invalid_user_id_format") + " " + + _("admin_invalid_username_format")) + return + if not user_to_unban_data: + await message.answer( + _("admin_user_not_found_in_bot_db", user_id=input_text)) + await state.clear() + return + user_id_to_unban = user_to_unban_data['user_id'] + user_id_or_username_for_msg = f"@{user_to_unban_data['username']}" if user_to_unban_data.get( + 'username') else str(user_id_to_unban) + if not user_to_unban_data['is_banned']: + await message.answer( + _("admin_user_not_banned", + user_id_or_username=user_id_or_username_for_msg)) + await state.clear() + return + panel_user_uuid = user_to_unban_data[ + 'panel_user_uuid'] if user_to_unban_data and 'panel_user_uuid' in user_to_unban_data.keys( + ) and user_to_unban_data['panel_user_uuid'] else None + await set_user_ban_status_db(user_id_to_unban, is_banned=False) + reply_markup_val = get_back_to_admin_panel_keyboard(current_lang, i18n) + if panel_user_uuid: + panel_unban_success = await panel_service.update_user_status_on_panel( + panel_user_uuid, enable=True) + if panel_unban_success: + await message.answer(_( + "admin_user_unbanned_success_panel_too", + user_id_or_username=user_id_or_username_for_msg), + reply_markup=reply_markup_val) + else: + await message.answer(_( + "admin_user_unbanned_local_panel_fail", + user_id_or_username=user_id_or_username_for_msg), + reply_markup=reply_markup_val) + else: + await message.answer(_( + "admin_user_unbanned_local_no_panel_uuid", + user_id_or_username=user_id_or_username_for_msg), + reply_markup=reply_markup_val) + await state.clear() + + +async def view_banned_users_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + state: FSMContext): + await state.clear() + current_page = 0 + if ":" in callback.data and callback.data.count(":") == 2: + try: + current_page = int(callback.data.split(":")[-1]) + except ValueError: + current_page = 0 + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + banned_users, total_banned = await get_banned_users_list_paginated( + limit=settings.LOGS_PAGE_SIZE, + offset=current_page * settings.LOGS_PAGE_SIZE) + + if not callback.message: + await callback.answer("Error.") + return + if total_banned == 0: + await callback.message.edit_text( + _("admin_no_banned_users"), + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + else: + total_pages = math.ceil( + total_banned / + settings.LOGS_PAGE_SIZE) if settings.LOGS_PAGE_SIZE > 0 else 1 + await callback.message.edit_text( + text=_("admin_banned_list_title", + current_page=current_page + 1, + total_pages=max(1, total_pages)), + reply_markup=get_banned_users_keyboard(banned_users, current_page, + total_banned, i18n, + current_lang, settings)) + await callback.answer() + + +@router.callback_query(F.data.startswith("admin_user_card:")) +async def show_user_card_handler(callback: types.CallbackQuery, + i18n_data: dict, + settings: Settings, + panel_service: PanelApiService, + state: FSMContext, + force_user_id: Optional[int] = None, + force_page: Optional[int] = None): + + await state.clear() + user_id_to_show = 0 + banned_list_page_to_return = 0 + if force_user_id is not None and force_page is not None: + user_id_to_show = force_user_id + banned_list_page_to_return = force_page + else: + try: + parts = callback.data.split(":") + user_id_to_show = int(parts[1]) + banned_list_page_to_return = int(parts[2]) if len(parts) > 2 else 0 + except (IndexError, ValueError): + await callback.answer("Invalid user card data.", show_alert=True) + return + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + user_data = await get_user(user_id_to_show) + if not callback.message: + await callback.answer("Error.") + return + if not user_data: + await callback.message.edit_text( + _("admin_user_not_found_in_bot_db", user_id=user_id_to_show), + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + await callback.answer() + return + user_display_name = user_data['first_name'] or ( + f"@{user_data['username']}" + if user_data.get('username') else f"ID: {user_id_to_show}") + sub_end_date_str = await get_user_active_subscription_end_date( + user_id_to_show) or _("user_card_sub_na") + reg_date_from_db = user_data[ + 'registration_date_str'] if 'registration_date_str' in user_data.keys( + ) else "N/A" + reg_date_display = reg_date_from_db[: + 10] if reg_date_from_db and reg_date_from_db != "N/A" else "N/A" + card_text = _( + "user_card_info", + user_id=user_data['user_id'], + username=user_data.get('username', "N/A"), + first_name=user_data.get('first_name', ""), + last_name=user_data.get('last_name', ""), + language_code=user_data.get('language_code', "N/A"), + panel_user_uuid=user_data.get('panel_user_uuid', "N/A"), + ban_status=_(key="user_card_banned") if user_data['is_banned'] else _( + key="user_card_active"), + reg_date=reg_date_display, + sub_end_date=sub_end_date_str) + await callback.message.edit_text( + text= + f"{_('admin_user_card_title', user_display=user_display_name)}\n\n{card_text}", + reply_markup=get_user_card_keyboard(user_id_to_show, + bool(user_data['is_banned']), i18n, + current_lang, + banned_list_page_to_return), + parse_mode="HTML") + if force_user_id is None: await callback.answer() + + +@router.callback_query(F.data.startswith("admin_unban_confirm:")) +async def confirm_unban_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings): + + try: + _, user_id_str, page_str = callback.data.split(":") + user_id = int(user_id_str) + banned_list_page = int(page_str) + except: + await callback.answer("Invalid data", show_alert=True) + return + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + user_data = await get_user(user_id) + user_display = (user_data['first_name'] or + (f"@{user_data['username']}" if user_data.get('username') + else f"ID {user_id}")) if user_data else f"ID {user_id}" + if callback.message: + await callback.message.edit_text( + text=_("admin_confirm_unban_prompt", + user_display=user_display, + user_id=user_id), + reply_markup=get_confirmation_keyboard( + yes_callback_data= + f"admin_unban_do:{user_id}:{banned_list_page}", + no_callback_data= + f"admin_user_card:{user_id}:{banned_list_page}", + i18n_instance=i18n, + lang=current_lang), + parse_mode="HTML") + await callback.answer() + + +@router.callback_query(F.data.startswith("admin_ban_confirm:")) +async def confirm_ban_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings): + try: + _, user_id_str, page_str = callback.data.split(":") + user_id = int(user_id_str) + banned_list_page = int(page_str) + except: + await callback.answer("Invalid data", show_alert=True) + return + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + user_data = await get_user(user_id) + user_display = (user_data['first_name'] or + (f"@{user_data['username']}" if user_data.get('username') + else f"ID {user_id}")) if user_data else f"ID {user_id}" + if callback.message: + await callback.message.edit_text( + text=_("admin_confirm_ban_prompt", + user_display=user_display, + user_id=user_id), + reply_markup=get_confirmation_keyboard( + yes_callback_data=f"admin_ban_do:{user_id}:{banned_list_page}", + no_callback_data= + f"admin_user_card:{user_id}:{banned_list_page}", + i18n_instance=i18n, + lang=current_lang), + parse_mode="HTML") + await callback.answer() + + +@router.callback_query(F.data.startswith("admin_unban_do:")) +async def do_unban_user_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, + panel_service: PanelApiService, + state: FSMContext): + + try: + _, user_id_str, page_str = callback.data.split(":") + user_id_to_unban = int(user_id_str) + banned_list_page = int(page_str) + except: + await callback.answer("Invalid data", show_alert=True) + return + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + user_to_unban_data = await get_user(user_id_to_unban) + user_display_name = ( + user_to_unban_data['first_name'] or + (f"@{user_to_unban_data['username']}" + if user_to_unban_data.get('username') else f"ID {user_id_to_unban}") + ) if user_to_unban_data else f"ID {user_id_to_unban}" + if not user_to_unban_data or not user_to_unban_data['is_banned']: + await callback.answer(_("admin_user_not_banned", + user_id_or_username=user_display_name), + show_alert=True) + if callback.message: + await show_user_card_handler(callback, + i18n_data, + settings, + panel_service, + state, + force_user_id=user_id_to_unban, + force_page=banned_list_page) + return + await set_user_ban_status_db(user_id_to_unban, is_banned=False) + panel_user_uuid = user_to_unban_data[ + 'panel_user_uuid'] if user_to_unban_data and 'panel_user_uuid' in user_to_unban_data.keys( + ) and user_to_unban_data['panel_user_uuid'] else None + if panel_user_uuid: + if not await panel_service.update_user_status_on_panel(panel_user_uuid, + enable=True): + logging.warning( + f"Panel status update fail for unban {user_id_to_unban}") + await callback.answer(_("admin_user_unbanned_from_card", + user_display=user_display_name, + user_id=user_id_to_unban), + show_alert=False) + if callback.message: + await show_user_card_handler(callback, + i18n_data, + settings, + panel_service, + state, + force_user_id=user_id_to_unban, + force_page=banned_list_page) + + +@router.callback_query(F.data.startswith("admin_ban_do:")) +async def do_ban_user_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, + panel_service: PanelApiService, + state: FSMContext): + try: + _, user_id_str, page_str = callback.data.split(":") + user_id_to_ban = int(user_id_str) + banned_list_page = int(page_str) + except: + await callback.answer("Invalid data", show_alert=True) + return + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + user_to_ban_data = await get_user(user_id_to_ban) + user_display_name = ( + user_to_ban_data['first_name'] or + (f"@{user_to_ban_data['username']}" + if user_to_ban_data.get('username') else f"ID {user_id_to_ban}") + ) if user_to_ban_data else f"ID {user_id_to_ban}" + if not user_to_ban_data: + await callback.answer(_("admin_user_not_found_in_bot_db", + user_id=user_id_to_ban), + show_alert=True) + return + if user_to_ban_data['is_banned']: + await callback.answer(_("admin_user_already_banned", + user_id_or_username=user_display_name), + show_alert=True) + return + if user_id_to_ban == callback.from_user.id or user_id_to_ban in settings.ADMIN_IDS: + await callback.answer(_("admin_cannot_ban_self_or_admin"), + show_alert=True) + return + + await set_user_ban_status_db(user_id_to_ban, is_banned=True) + panel_user_uuid = user_to_ban_data[ + 'panel_user_uuid'] if user_to_ban_data and 'panel_user_uuid' in user_to_ban_data.keys( + ) and user_to_ban_data['panel_user_uuid'] else None + if panel_user_uuid: + if not await panel_service.update_user_status_on_panel(panel_user_uuid, + enable=False): + logging.warning( + f"Panel status update fail for ban {user_id_to_ban}") + await callback.answer(_("admin_user_banned_from_card", + user_display=user_display_name, + user_id=user_id_to_ban), + show_alert=False) + if callback.message: + await show_user_card_handler(callback, + i18n_data, + settings, + panel_service, + state, + force_user_id=user_id_to_ban, + force_page=banned_list_page) + + +@router.callback_query(F.data == "admin_action:main", + AdminStates.waiting_for_user_id_to_ban) +@router.callback_query(F.data == "admin_action:main", + AdminStates.waiting_for_user_id_to_unban) +async def cancel_user_management_input_state(callback: types.CallbackQuery, + state: FSMContext, + settings: Settings, + i18n_data: dict, bot: Bot): + + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + if callback.message: + try: + await callback.message.edit_text( + _("admin_action_cancelled_default"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, + settings)) + except: + await callback.message.answer( + _("admin_action_cancelled_default"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, + settings)) + await callback.answer(_("admin_action_cancelled_default")) + await state.clear() diff --git a/bot/handlers/user/__init__.py b/bot/handlers/user/__init__.py index 0852840..2aa7de7 100644 --- a/bot/handlers/user/__init__.py +++ b/bot/handlers/user/__init__.py @@ -1,21 +1,15 @@ from aiogram import Router +from . import start +from . import subscription +from . import referral +from . import promo_user +from . import trial_handler -from .import start -from .import subscription -from .import referral -from .import promo_user -from .import trial_handler - - -user_router_aggregate =Router (name ="user_router_aggregate") - - - - -user_router_aggregate .include_router (promo_user .router ) -user_router_aggregate .include_router (trial_handler .router ) -user_router_aggregate .include_router (start .router ) -user_router_aggregate .include_router (subscription .router ) -user_router_aggregate .include_router (referral .router ) +user_router_aggregate = Router(name="user_router_aggregate") +user_router_aggregate.include_router(promo_user.router) +user_router_aggregate.include_router(trial_handler.router) +user_router_aggregate.include_router(start.router) +user_router_aggregate.include_router(subscription.router) +user_router_aggregate.include_router(referral.router) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index b6ab6c2..76e5333 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -2,165 +2,310 @@ import logging import json import aiosqlite import asyncio -from datetime import datetime ,timezone -from typing import Optional ,Dict ,Any +from datetime import datetime, timezone +from typing import Optional, Dict, Any from aiohttp import web from aiogram import Bot -from yookassa .domain .notification import WebhookNotification -from yookassa .domain .models import Amount +from yookassa.domain.notification import WebhookNotification +from yookassa.domain.models import Amount -from db .database import get_db_connection_manager ,_setup_db_connection ,get_user -from bot .services .subscription_service import SubscriptionService -from bot .services .referral_service import ReferralService -from bot .services .panel_api_service import PanelApiService -from bot .middlewares .i18n import JsonI18n -from config .settings import Settings -from bot .services .payment_service import YooKassaService +from db.database import get_db_connection_manager, _setup_db_connection, get_user +from bot.services.subscription_service import SubscriptionService +from bot.services.referral_service import ReferralService +from bot.services.panel_api_service import PanelApiService +from bot.middlewares.i18n import JsonI18n +from config.settings import Settings +from bot.services.payment_service import YooKassaService -payment_processing_lock =asyncio .Lock () +payment_processing_lock = asyncio.Lock() -YOOKASSA_EVENT_PAYMENT_SUCCEEDED ='payment.succeeded' -YOOKASSA_EVENT_PAYMENT_CANCELED ='payment.canceled' -YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE ='payment.waiting_for_capture' -YOOKASSA_EVENT_REFUND_SUCCEEDED ='refund.succeeded' +YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded' +YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled' +YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture' +YOOKASSA_EVENT_REFUND_SUCCEEDED = 'refund.succeeded' -async def process_successful_payment ( -bot :Bot ,payment_info_from_webhook :dict ,i18n :JsonI18n , -settings :Settings ,panel_service :PanelApiService ,yk_service :YooKassaService , -subscription_service :SubscriptionService , -referral_service :ReferralService -): - metadata =payment_info_from_webhook .get ("metadata",{}) - user_id_str =metadata .get ("user_id") - subscription_months_str =metadata .get ("subscription_months") - promo_code_id_str =metadata .get ("promo_code_id") - payment_db_id_str =metadata .get ("payment_db_id") - if not user_id_str or not subscription_months_str or not payment_db_id_str : - logging .error (f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}") +async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict, + i18n: JsonI18n, settings: Settings, + panel_service: PanelApiService, + yk_service: YooKassaService, + subscription_service: SubscriptionService, + referral_service: ReferralService): + metadata = payment_info_from_webhook.get("metadata", {}) + user_id_str = metadata.get("user_id") + subscription_months_str = metadata.get("subscription_months") + promo_code_id_str = metadata.get("promo_code_id") + payment_db_id_str = metadata.get("payment_db_id") + + if not user_id_str or not subscription_months_str or not payment_db_id_str: + logging.error( + f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" + ) return - try : - user_id =int (user_id_str );subscription_months =int (subscription_months_str ) - payment_db_id =int (payment_db_id_str ) - promo_code_id =int (promo_code_id_str )if promo_code_id_str and promo_code_id_str .isdigit ()else None - amount_data =payment_info_from_webhook .get ("amount",{}); - payment_value =float (amount_data .get ("value",0.0 )) - except (TypeError ,ValueError )as e : - logging .error (f"Invalid metadata format for payment processing: {metadata} - {e}") + try: + user_id = int(user_id_str) + subscription_months = int(subscription_months_str) + payment_db_id = int(payment_db_id_str) + promo_code_id = int( + promo_code_id_str + ) if promo_code_id_str and promo_code_id_str.isdigit() else None + amount_data = payment_info_from_webhook.get("amount", {}) + payment_value = float(amount_data.get("value", 0.0)) + except (TypeError, ValueError) as e: + logging.error( + f"Invalid metadata format for payment processing: {metadata} - {e}" + ) return - final_end_date_for_user :Optional [datetime ]=None - applied_referee_bonus_days :Optional [int ]=None - base_subscription_end_date :Optional [datetime ]=None + final_end_date_for_user: Optional[datetime] = None + applied_referee_bonus_days: Optional[int] = None + base_subscription_end_date: Optional[datetime] = None - async with get_db_connection_manager ()as db : - await _setup_db_connection (db ) - try : - await db .execute ("UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)",(payment_info_from_webhook .get ("status","succeeded"),payment_db_id ,payment_info_from_webhook .get ("id"))) + async with get_db_connection_manager() as db: + await _setup_db_connection(db) + try: + await db.execute( + "UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)", + (payment_info_from_webhook.get("status", "succeeded"), + payment_db_id, payment_info_from_webhook.get("id"))) - new_sub_details =await subscription_service .activate_subscription (user_id ,subscription_months ,payment_value ,payment_db_id ,db_conn =db ,promo_code_id =promo_code_id ) + new_sub_details = await subscription_service.activate_subscription( + user_id, + subscription_months, + payment_value, + payment_db_id, + db_conn=db, + promo_code_id=promo_code_id) - if new_sub_details and new_sub_details .get ('end_date'): - base_subscription_end_date =new_sub_details ['end_date'] - final_end_date_for_user =base_subscription_end_date + if new_sub_details and new_sub_details.get('end_date'): + base_subscription_end_date = new_sub_details['end_date'] + final_end_date_for_user = base_subscription_end_date - referral_bonus_info =await referral_service .apply_referral_bonuses_for_payment (user_id ,subscription_months ,db_conn =db ) + referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment( + user_id, subscription_months, db_conn=db) - if referral_bonus_info and referral_bonus_info .get ("referee_new_end_date"): - final_end_date_for_user =referral_bonus_info ["referee_new_end_date"] - applied_referee_bonus_days =referral_bonus_info .get ("referee_bonus_applied_days") + if referral_bonus_info and referral_bonus_info.get( + "referee_new_end_date"): + final_end_date_for_user = referral_bonus_info[ + "referee_new_end_date"] + applied_referee_bonus_days = referral_bonus_info.get( + "referee_bonus_applied_days") - await db .commit () + await db.commit() - user_lang =await subscription_service .get_user_language (user_id ) - _ =lambda key ,**kwargs :i18n .gettext (user_lang ,key ,**kwargs ) + user_lang = await subscription_service.get_user_language( + user_id) + _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs + ) - success_message ="" - if applied_referee_bonus_days and final_end_date_for_user : - referee_user_data =await get_user (user_id ) - inviter_name_for_msg =_ ("friend_placeholder") + success_message = "" + if applied_referee_bonus_days and final_end_date_for_user: + referee_user_data = await get_user(user_id) + inviter_name_for_msg = _("friend_placeholder") - if referee_user_data and referee_user_data ['referred_by_id']is not None : - inviter_user_data_for_msg =await get_user (referee_user_data ['referred_by_id']) - if inviter_user_data_for_msg and inviter_user_data_for_msg ['first_name']: - inviter_name_for_msg =inviter_user_data_for_msg ['first_name'] + if referee_user_data and referee_user_data[ + 'referred_by_id'] is not None: + inviter_user_data_for_msg = await get_user( + referee_user_data['referred_by_id']) + if inviter_user_data_for_msg and inviter_user_data_for_msg[ + 'first_name']: + inviter_name_for_msg = inviter_user_data_for_msg[ + 'first_name'] - success_message =_ ("payment_successful_with_referral_bonus", - months =subscription_months , - base_end_date =base_subscription_end_date .strftime ('%Y-%m-%d')if base_subscription_end_date else "N/A", - bonus_days =applied_referee_bonus_days , - final_end_date =final_end_date_for_user .strftime ('%Y-%m-%d'), - inviter_name =inviter_name_for_msg + success_message = _( + "payment_successful_with_referral_bonus", + months=subscription_months, + base_end_date=base_subscription_end_date.strftime( + '%Y-%m-%d') + if base_subscription_end_date else "N/A", + bonus_days=applied_referee_bonus_days, + final_end_date=final_end_date_for_user.strftime( + '%Y-%m-%d'), + inviter_name=inviter_name_for_msg) + elif final_end_date_for_user: + success_message = _( + "payment_successful", + months=subscription_months, + end_date=final_end_date_for_user.strftime('%Y-%m-%d')) + else: + logging.error( + f"Critical error: final_end_date_for_user is None for user {user_id}" ) - elif final_end_date_for_user : - success_message =_ ("payment_successful", - months =subscription_months , - end_date =final_end_date_for_user .strftime ('%Y-%m-%d') + success_message = _("payment_successful_error_details") + + try: + await bot.send_message(user_id, success_message) + except Exception as e: + logging.error( + f"Failed to send final payment success message to user {user_id}: {e}" ) - else : - logging .error (f"Critical error: final_end_date_for_user is None for user {user_id}") - success_message =_ ("payment_successful_error_details") - - try :await bot .send_message (user_id ,success_message ) - except Exception as e :logging .error (f"Failed to send final payment success message to user {user_id}: {e}") - else : - logging .error (f"Failed to activate subscription for user {user_id} after payment {payment_info_from_webhook.get('id')}") - await db .rollback () - except Exception as e : - logging .error (f"Error during process_successful_payment transaction for user {user_id}: {e}",exc_info =True ) - await db .rollback () - try : - user_lang_for_error =await subscription_service .get_user_language (user_id ) - _err =lambda key ,**kwargs :i18n .gettext (user_lang_for_error ,key ,**kwargs ) - await bot .send_message (user_id ,_err ("error_processing_your_payment")) - except Exception as notify_err :logging .error (f"Failed to send error notification to user {user_id}: {notify_err}") + else: + logging.error( + f"Failed to activate subscription for user {user_id} after payment {payment_info_from_webhook.get('id')}" + ) + await db.rollback() + except Exception as e: + logging.error( + f"Error during process_successful_payment transaction for user {user_id}: {e}", + exc_info=True) + await db.rollback() + try: + user_lang_for_error = await subscription_service.get_user_language( + user_id) + _err = lambda key, **kwargs: i18n.gettext( + user_lang_for_error, key, **kwargs) + await bot.send_message(user_id, + _err("error_processing_your_payment")) + except Exception as notify_err: + logging.error( + f"Failed to send error notification to user {user_id}: {notify_err}" + ) -async def process_cancelled_payment (bot :Bot ,payment_info_from_webhook :dict ,i18n :JsonI18n ,settings :Settings ): - metadata =payment_info_from_webhook .get ("metadata",{}) - user_id_str =metadata .get ("user_id");payment_db_id_str =metadata .get ("payment_db_id") - if not user_id_str or not payment_db_id_str :logging .warning (f"Missing metadata in cancelled payment: {payment_info_from_webhook.get('id')}");return - try :user_id =int (user_id_str );payment_db_id =int (payment_db_id_str ) - except ValueError :logging .error (f"Invalid metadata in cancelled payment: {metadata}");return - async with get_db_connection_manager ()as db : - await _setup_db_connection (db ) - await db .execute ("UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)",(payment_info_from_webhook .get ("status","canceled"),payment_db_id ,payment_info_from_webhook .get ("id"))) - await db .commit () - user_lang =getattr (settings ,'DEFAULT_LANGUAGE','en') - _ =lambda key ,**kwargs :i18n .gettext (user_lang ,key ,**kwargs ) - try :await bot .send_message (user_id ,_ ("payment_failed")) - except Exception as e :logging .error (f"Failed to send payment cancellation message to user {user_id}: {e}") +async def process_cancelled_payment(bot: Bot, payment_info_from_webhook: dict, + i18n: JsonI18n, settings: Settings): + metadata = payment_info_from_webhook.get("metadata", {}) + user_id_str = metadata.get("user_id") + payment_db_id_str = metadata.get("payment_db_id") + if not user_id_str or not payment_db_id_str: + logging.warning( + f"Missing metadata in cancelled payment: {payment_info_from_webhook.get('id')}" + ) + return + try: + user_id = int(user_id_str) + payment_db_id = int(payment_db_id_str) + except ValueError: + logging.error(f"Invalid metadata in cancelled payment: {metadata}") + return + async with get_db_connection_manager() as db: + await _setup_db_connection(db) + await db.execute( + "UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)", + (payment_info_from_webhook.get("status", "canceled"), + payment_db_id, payment_info_from_webhook.get("id"))) + await db.commit() + user_lang = getattr(settings, 'DEFAULT_LANGUAGE', 'en') + _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) + try: + await bot.send_message(user_id, _("payment_failed")) + except Exception as e: + logging.error( + f"Failed to send payment cancellation message to user {user_id}: {e}" + ) -async def yookassa_webhook_route (request :web .Request ): - logging .info (f"YooKassa Webhook Route: Available keys in request.app: {list(request.app.keys())}") - try : - bot :Bot =request .app ['bot'];i18n_instance :JsonI18n =request .app ['i18n'];settings :Settings =request .app ['settings'] - yk_service :YooKassaService =request .app ['yookassa_service'];panel_service :PanelApiService =request .app ['panel_service'] - subscription_service :SubscriptionService =request .app ['subscription_service'] - referral_service :ReferralService =request .app ['referral_service'] - except KeyError as e :logging .error (f"KeyError accessing app context in yookassa_webhook_route: {e}.",exc_info =True );return web .Response (status =500 ,text ="Internal Server Error: Missing app context") - try : - event_json =await request .json ();notification_object =WebhookNotification (event_json ) - payment_data_from_notification =notification_object .object - logging .info (f"YooKassa Webhook Parsed: Event='{notification_object.event}', PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'") - if not payment_data_from_notification or not hasattr (payment_data_from_notification ,'metadata')or payment_data_from_notification .metadata is None : - logging .error (f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata.");return web .Response (status =200 ,text ="ok_error_no_metadata") - payment_dict_for_processing ={} - if hasattr (payment_data_from_notification ,'model_dump'): - payment_dict_for_processing =payment_data_from_notification .model_dump (exclude_none =True ) - if 'amount'in payment_dict_for_processing and isinstance (payment_dict_for_processing ['amount'],Amount ):amount_obj =payment_dict_for_processing ['amount'];payment_dict_for_processing ['amount']={"value":str (amount_obj .value ),"currency":str (amount_obj .currency )} - elif 'amount'in payment_dict_for_processing and not isinstance (payment_dict_for_processing ['amount'],dict ):amount_obj_original =payment_data_from_notification .amount ;payment_dict_for_processing ['amount']={"value":str (amount_obj_original .value ),"currency":str (amount_obj_original .currency )}if hasattr (amount_obj_original ,'value')and hasattr (amount_obj_original ,'currency')else {"value":"0.0","currency":"RUB"} - elif hasattr (payment_data_from_notification ,'amount')and hasattr (payment_data_from_notification .amount ,'value')and hasattr (payment_data_from_notification .amount ,'currency'):amount_obj =payment_data_from_notification .amount ;payment_dict_for_processing ={"id":str (payment_data_from_notification .id ),"status":str (payment_data_from_notification .status ),"paid":bool (payment_data_from_notification .paid ),"amount":{"value":str (amount_obj .value ),"currency":str (amount_obj .currency )},"metadata":dict (payment_data_from_notification .metadata )if payment_data_from_notification .metadata else {},"description":str (payment_data_from_notification .description )if payment_data_from_notification .description else None } - else :logging .error (f"Could not serialize payment_data for payment {payment_data_from_notification.id}");return web .Response (status =200 ,text ="ok_error_serialization") - async with payment_processing_lock : - if notification_object .event ==YOOKASSA_EVENT_PAYMENT_SUCCEEDED : - if payment_dict_for_processing .get ("paid")and payment_dict_for_processing .get ("status")=="succeeded": - await process_successful_payment (bot ,payment_dict_for_processing ,i18n_instance ,settings ,panel_service ,yk_service ,subscription_service ,referral_service ) - else :logging .warning (f"Payment Succeeded event for {payment_dict_for_processing.get('id')} but data not ok: status='{payment_dict_for_processing.get('status')}', paid='{payment_dict_for_processing.get('paid')}'") - elif notification_object .event ==YOOKASSA_EVENT_PAYMENT_CANCELED : - await process_cancelled_payment (bot ,payment_dict_for_processing ,i18n_instance ,settings ) - return web .Response (status =200 ,text ="ok") - except json .JSONDecodeError :logging .error ("YooKassa Webhook: Invalid JSON.");return web .Response (status =200 ,text ="ok_invalid_json") - except KeyError as e :logging .error (f"KeyError in yookassa_webhook_route after initial context access: {e}.",exc_info =True );return web .Response (status =500 ,text ="Internal Server Error: Context error post-access") - except Exception as e :logging .error (f"YooKassa Webhook processing error: {e}",exc_info =True );return web .Response (status =200 ,text ="ok_internal_error") \ No newline at end of file + +async def yookassa_webhook_route(request: web.Request): + logging.info( + f"YooKassa Webhook Route: Available keys in request.app: {list(request.app.keys())}" + ) + try: + bot: Bot = request.app['bot'] + i18n_instance: JsonI18n = request.app['i18n'] + settings: Settings = request.app['settings'] + yk_service: YooKassaService = request.app['yookassa_service'] + panel_service: PanelApiService = request.app['panel_service'] + subscription_service: SubscriptionService = request.app[ + 'subscription_service'] + referral_service: ReferralService = request.app['referral_service'] + except KeyError as e: + logging.error( + f"KeyError accessing app context in yookassa_webhook_route: {e}.", + exc_info=True) + return web.Response(status=500, + text="Internal Server Error: Missing app context") + try: + event_json = await request.json() + notification_object = WebhookNotification(event_json) + payment_data_from_notification = notification_object.object + logging.info( + f"YooKassa Webhook Parsed: Event='{notification_object.event}', PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" + ) + if not payment_data_from_notification or not hasattr( + payment_data_from_notification, + 'metadata') or payment_data_from_notification.metadata is None: + logging.error( + f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata." + ) + return web.Response(status=200, text="ok_error_no_metadata") + payment_dict_for_processing = {} + if hasattr(payment_data_from_notification, 'model_dump'): + payment_dict_for_processing = payment_data_from_notification.model_dump( + exclude_none=True) + if 'amount' in payment_dict_for_processing and isinstance( + payment_dict_for_processing['amount'], Amount): + amount_obj = payment_dict_for_processing['amount'] + payment_dict_for_processing['amount'] = { + "value": str(amount_obj.value), + "currency": str(amount_obj.currency) + } + elif 'amount' in payment_dict_for_processing and not isinstance( + payment_dict_for_processing['amount'], dict): + amount_obj_original = payment_data_from_notification.amount + payment_dict_for_processing['amount'] = { + "value": str(amount_obj_original.value), + "currency": str(amount_obj_original.currency) + } if hasattr(amount_obj_original, 'value') and hasattr( + amount_obj_original, 'currency') else { + "value": "0.0", + "currency": "RUB" + } + elif hasattr(payment_data_from_notification, 'amount') and hasattr( + payment_data_from_notification.amount, 'value') and hasattr( + payment_data_from_notification.amount, 'currency'): + amount_obj = payment_data_from_notification.amount + payment_dict_for_processing = { + "id": + str(payment_data_from_notification.id), + "status": + str(payment_data_from_notification.status), + "paid": + bool(payment_data_from_notification.paid), + "amount": { + "value": str(amount_obj.value), + "currency": str(amount_obj.currency) + }, + "metadata": + dict(payment_data_from_notification.metadata) + if payment_data_from_notification.metadata else {}, + "description": + str(payment_data_from_notification.description) + if payment_data_from_notification.description else None + } + else: + logging.error( + f"Could not serialize payment_data for payment {payment_data_from_notification.id}" + ) + return web.Response(status=200, text="ok_error_serialization") + async with payment_processing_lock: + if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED: + if payment_dict_for_processing.get( + "paid") and payment_dict_for_processing.get( + "status") == "succeeded": + await process_successful_payment( + bot, payment_dict_for_processing, i18n_instance, + settings, panel_service, yk_service, + subscription_service, referral_service) + else: + logging.warning( + f"Payment Succeeded event for {payment_dict_for_processing.get('id')} but data not ok: status='{payment_dict_for_processing.get('status')}', paid='{payment_dict_for_processing.get('paid')}'" + ) + elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED: + await process_cancelled_payment(bot, + payment_dict_for_processing, + i18n_instance, settings) + return web.Response(status=200, text="ok") + except json.JSONDecodeError: + logging.error("YooKassa Webhook: Invalid JSON.") + return web.Response(status=200, text="ok_invalid_json") + except KeyError as e: + logging.error( + f"KeyError in yookassa_webhook_route after initial context access: {e}.", + exc_info=True) + return web.Response( + status=500, + text="Internal Server Error: Context error post-access") + except Exception as e: + logging.error(f"YooKassa Webhook processing error: {e}", exc_info=True) + return web.Response(status=200, text="ok_internal_error") diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index bc8699a..79af153 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -1,127 +1,160 @@ import logging import re -from aiogram import Router ,F ,types ,Bot -from aiogram .fsm .context import FSMContext +from aiogram import Router, F, types, Bot +from aiogram.fsm.context import FSMContext from typing import Optional -from config .settings import Settings -from bot .states .user_states import UserPromoStates -from bot .services .promo_code_service import PromoCodeService -from bot .services .subscription_service import SubscriptionService -from bot .keyboards .inline .user_keyboards import get_back_to_main_menu_markup -from bot .middlewares .i18n import JsonI18n -from aiogram .utils .markdown import hcode +from config.settings import Settings +from bot.states.user_states import UserPromoStates +from bot.services.promo_code_service import PromoCodeService +from bot.services.subscription_service import SubscriptionService +from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup +from bot.middlewares.i18n import JsonI18n +from aiogram.utils.markdown import hcode from .start import send_main_menu -router =Router (name ="user_promo_router") +router = Router(name="user_promo_router") -SUSPICIOUS_SQL_KEYWORDS_REGEX =re .compile ( -r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|;\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b", -re .IGNORECASE -) -SUSPICIOUS_CHARS_REGEX =re .compile (r"(--|#\s|;|\*\/|\/\*)") +SUSPICIOUS_SQL_KEYWORDS_REGEX = re.compile( + r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|;\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b", + re.IGNORECASE) +SUSPICIOUS_CHARS_REGEX = re.compile(r"(--|#\s|;|\*\/|\/\*)") -async def prompt_promo_code_input ( -callback :types .CallbackQuery , -state :FSMContext , -i18n_data :dict , -settings :Settings -): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :await callback .answer ("Language error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - if not callback .message : - logging .error ("CallbackQuery has no message in prompt_promo_code_input") - await callback .answer (_ ("error_occurred_processing_request"),show_alert =True ) +async def prompt_promo_code_input(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + if not callback.message: + logging.error( + "CallbackQuery has no message in prompt_promo_code_input") + await callback.answer(_("error_occurred_processing_request"), + show_alert=True) return - try : - await callback .message .edit_text ( - text =_ (key ="promo_code_prompt"), - reply_markup =get_back_to_main_menu_markup (current_lang ,i18n ) + try: + await callback.message.edit_text( + text=_(key="promo_code_prompt"), + reply_markup=get_back_to_main_menu_markup(current_lang, i18n)) + except Exception as e: + logging.warning(f"Failed to edit message for promo prompt: {e}") + await callback.message.answer( + text=_(key="promo_code_prompt"), + reply_markup=get_back_to_main_menu_markup(current_lang, i18n)) + + await callback.answer() + await state.set_state(UserPromoStates.waiting_for_promo_code) + logging.info( + f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. FSM state: {await state.get_state()}" + ) + + +@router.message(UserPromoStates.waiting_for_promo_code, F.text) +async def process_promo_code_input(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + promo_code_service: PromoCodeService, + bot: Bot): + logging.info( + f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'" + ) + + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + + if not i18n or not promo_code_service: + logging.error("Deps missing in process_promo_code_input") + await message.reply("Service error. Please try again.") + await state.clear() + return + + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + code_input = message.text.strip() + user = message.from_user + is_suspicious = False + if SUSPICIOUS_SQL_KEYWORDS_REGEX.search( + code_input) or SUSPICIOUS_CHARS_REGEX.search(code_input) or len( + code_input) > 100: + is_suspicious = True + logging.warning( + f"Suspicious input for promo by user {user.id} (len: {len(code_input)}): '{code_input}'" ) - except Exception as e : - logging .warning (f"Failed to edit message for promo prompt: {e}") - await callback .message .answer (text =_ (key ="promo_code_prompt"),reply_markup =get_back_to_main_menu_markup (current_lang ,i18n )) - await callback .answer () - await state .set_state (UserPromoStates .waiting_for_promo_code ) - logging .info (f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. FSM state: {await state.get_state()}") + response_to_user_text = "" + if is_suspicious: + admin_notify_key = "admin_suspicious_promo_attempt_notification_no_username" if not user.username else "admin_suspicious_promo_attempt_notification" + admin_lang = settings.DEFAULT_LANGUAGE + _admin = lambda k, **kw: i18n.gettext(admin_lang, k, **kw) + admin_notification_text = _admin(admin_notify_key, + user_id=user.id, + user_username=user.username or "N/A", + user_first_name=user.first_name + or "N/A", + promo_code_input=hcode(code_input)) + try: + await bot.send_message(settings.ADMIN_ID, + admin_notification_text, + parse_mode="HTML") + except Exception as e_admin_notify: + logging.error( + f"Failed to send suspicious promo notification to admin: {e_admin_notify}" + ) + response_to_user_text = _("promo_code_not_found", + code=code_input.upper()) + else: + success, response_text_from_service = await promo_code_service.apply_promo_code( + user.id, code_input, current_lang) + response_to_user_text = response_text_from_service + + await message.answer(response_to_user_text, + reply_markup=get_back_to_main_menu_markup( + current_lang, i18n)) + await state.clear() + logging.info( + f"Promo code '{code_input}' processing finished for user {message.from_user.id}. State cleared." + ) -@router .message (UserPromoStates .waiting_for_promo_code ,F .text ) -async def process_promo_code_input ( -message :types .Message , -state :FSMContext , -settings :Settings , -i18n_data :dict , -promo_code_service :PromoCodeService , -bot :Bot -): - logging .info (f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'") - - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - - if not i18n or not promo_code_service : - logging .error ("Deps missing in process_promo_code_input") - await message .reply ("Service error. Please try again.") - await state .clear ();return - - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - code_input =message .text .strip ();user =message .from_user - is_suspicious =False - if SUSPICIOUS_SQL_KEYWORDS_REGEX .search (code_input )or SUSPICIOUS_CHARS_REGEX .search (code_input )or len (code_input )>100 : - is_suspicious =True - logging .warning (f"Suspicious input for promo by user {user.id} (len: {len(code_input)}): '{code_input}'") - - response_to_user_text ="" - if is_suspicious : - admin_notify_key ="admin_suspicious_promo_attempt_notification_no_username"if not user .username else "admin_suspicious_promo_attempt_notification" - admin_lang =settings .DEFAULT_LANGUAGE - _admin =lambda k ,**kw :i18n .gettext (admin_lang ,k ,**kw ) - admin_notification_text =_admin (admin_notify_key ,user_id =user .id ,user_username =user .username or "N/A",user_first_name =user .first_name or "N/A",promo_code_input =hcode (code_input )) - try :await bot .send_message (settings .ADMIN_ID ,admin_notification_text ,parse_mode ="HTML") - except Exception as e_admin_notify :logging .error (f"Failed to send suspicious promo notification to admin: {e_admin_notify}") - response_to_user_text =_ ("promo_code_not_found",code =code_input .upper ()) - else : - success ,response_text_from_service =await promo_code_service .apply_promo_code (user .id ,code_input ,current_lang ) - response_to_user_text =response_text_from_service - - await message .answer (response_to_user_text ,reply_markup =get_back_to_main_menu_markup (current_lang ,i18n )) - await state .clear () - logging .info (f"Promo code '{code_input}' processing finished for user {message.from_user.id}. State cleared.") - - - -@router .callback_query (F .data =="main_action:back_to_main",UserPromoStates .waiting_for_promo_code ) -async def cancel_promo_input_via_button ( -callback :types .CallbackQuery , -state :FSMContext , -settings :Settings , -i18n_data :dict , -subscription_service :SubscriptionService -): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n missing in cancel_promo_input_via_button") - await callback .answer ("Language error",show_alert =True ) +@router.callback_query(F.data == "main_action:back_to_main", + UserPromoStates.waiting_for_promo_code) +async def cancel_promo_input_via_button( + callback: types.CallbackQuery, state: FSMContext, settings: Settings, + i18n_data: dict, subscription_service: SubscriptionService): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing in cancel_promo_input_via_button") + await callback.answer("Language error", show_alert=True) return - logging .info (f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state.") - await state .clear () - logging .info (f"State after clear for user {callback.from_user.id}: {await state.get_state()}") + logging.info( + f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state." + ) + await state.clear() + logging.info( + f"State after clear for user {callback.from_user.id}: {await state.get_state()}" + ) - if callback .message : - show_trial_button_on_back =False - if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (callback .from_user .id ): - show_trial_button_on_back =True + if callback.message: + show_trial_button_on_back = False + if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( + callback.from_user.id): + show_trial_button_on_back = True - await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =show_trial_button_on_back ,is_edit =True ) - else : + await send_main_menu(callback, + settings, + i18n_data, + show_trial_button_flag=show_trial_button_on_back, + is_edit=True) + else: - await callback .answer ("Promo code input cancelled.",show_alert =False ) \ No newline at end of file + await callback.answer("Promo code input cancelled.", show_alert=False) diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py index f2e9331..bf355b8 100644 --- a/bot/handlers/user/referral.py +++ b/bot/handlers/user/referral.py @@ -1,81 +1,100 @@ import logging -from aiogram import Router ,F ,types ,Bot -from aiogram .filters import Command -from typing import Optional ,Dict +from aiogram import Router, F, types, Bot +from aiogram.filters import Command +from typing import Optional, Dict -from config .settings import Settings -from bot .services .referral_service import ReferralService -from db .database import get_db_connection_manager -from bot .keyboards .inline .user_keyboards import get_referral_link_keyboard ,get_back_to_main_menu_markup -from bot .middlewares .i18n import JsonI18n +from config.settings import Settings +from bot.services.referral_service import ReferralService +from db.database import get_db_connection_manager +from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard, get_back_to_main_menu_markup +from bot.middlewares.i18n import JsonI18n -router =Router (name ="user_referral_router") +router = Router(name="user_referral_router") -async def referral_command_handler ( -event :types .Message |types .CallbackQuery , -settings :Settings , -i18n_data :dict , -referral_service :ReferralService , -bot :Bot -): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - target_message =event .message if isinstance (event ,types .CallbackQuery )else event - if not target_message : - logging .error ("Target message is None in referral_command_handler from callback.") - if isinstance (event ,types .CallbackQuery ):await event .answer ("Error displaying referral info.") +async def referral_command_handler(event: types.Message | types.CallbackQuery, + settings: Settings, i18n_data: dict, + referral_service: ReferralService, + bot: Bot): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + + target_message = event.message if isinstance( + event, types.CallbackQuery) else event + if not target_message: + logging.error( + "Target message is None in referral_command_handler from callback." + ) + if isinstance(event, types.CallbackQuery): + await event.answer("Error displaying referral info.") return - if not i18n or not referral_service : - logging .error ("Deps missing in referral_command_handler") - await target_message .answer ("Service error."if isinstance (event ,types .Message )else "Service error.",parse_mode =None ) - if isinstance (event ,types .CallbackQuery ):await event .answer () + if not i18n or not referral_service: + logging.error("Deps missing in referral_command_handler") + await target_message.answer("Service error." if isinstance( + event, types.Message) else "Service error.", + parse_mode=None) + if isinstance(event, types.CallbackQuery): await event.answer() return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - bot_info =await bot .get_me () - bot_username =bot_info .username - inviter_user_id =event .from_user .id - referral_link =referral_service .generate_referral_link (bot_username ,inviter_user_id ) - bonus_info_parts =[] + bot_info = await bot.get_me() + bot_username = bot_info.username + inviter_user_id = event.from_user.id + referral_link = referral_service.generate_referral_link( + bot_username, inviter_user_id) + bonus_info_parts = [] - if hasattr (settings ,'subscription_options')and settings .subscription_options : - for months_period in sorted (settings .subscription_options .keys ()): - inv_bonus =settings .referral_bonus_inviter .get (months_period ) - ref_bonus =settings .referral_bonus_referee .get (months_period ) - if inv_bonus is not None or ref_bonus is not None : - bonus_info_parts .append ( - _ ("referral_bonus_per_period", - months =months_period , - inviter_bonus_days =inv_bonus if inv_bonus is not None else _ ("no_bonus_days"), - referee_bonus_days =ref_bonus if ref_bonus is not None else _ ("no_bonus_days") - ) - ) - bonus_details_str ="\n".join (bonus_info_parts )if bonus_info_parts else _ ("referral_no_bonuses_configured") - text =_ ("referral_program_info_new",referral_link =referral_link ,bonus_details =bonus_details_str ) + if hasattr(settings, + 'subscription_options') and settings.subscription_options: + for months_period in sorted(settings.subscription_options.keys()): + inv_bonus = settings.referral_bonus_inviter.get(months_period) + ref_bonus = settings.referral_bonus_referee.get(months_period) + if inv_bonus is not None or ref_bonus is not None: + bonus_info_parts.append( + _("referral_bonus_per_period", + months=months_period, + inviter_bonus_days=inv_bonus + if inv_bonus is not None else _("no_bonus_days"), + referee_bonus_days=ref_bonus + if ref_bonus is not None else _("no_bonus_days"))) + bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _( + "referral_no_bonuses_configured") + text = _("referral_program_info_new", + referral_link=referral_link, + bonus_details=bonus_details_str) + + reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n) + + if isinstance(event, types.Message): + await event.answer(text, + reply_markup=reply_markup_val, + disable_web_page_preview=True) + elif isinstance(event, types.CallbackQuery): + try: + await event.message.edit_text(text, + reply_markup=reply_markup_val, + disable_web_page_preview=True) + except Exception as e: + logging.warning(f"Failed to edit message for referral info: {e}") + + await event.message.answer(text, + reply_markup=reply_markup_val, + disable_web_page_preview=True) + await event.answer() - - reply_markup_val =get_back_to_main_menu_markup (current_lang ,i18n ) - - if isinstance (event ,types .Message ): - await event .answer (text ,reply_markup =reply_markup_val ,disable_web_page_preview =True ) - elif isinstance (event ,types .CallbackQuery ): - try : - await event .message .edit_text (text ,reply_markup =reply_markup_val ,disable_web_page_preview =True ) - except Exception as e : - logging .warning (f"Failed to edit message for referral info: {e}") - - await event .message .answer (text ,reply_markup =reply_markup_val ,disable_web_page_preview =True ) - await event .answer () - - -@router .callback_query (F .data =="copy_referral_link_ack") -async def copy_referral_link_ack_callback_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :return await callback .answer ("Language service error.",show_alert =True ) - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - await callback .answer (text =_ (key ="referral_link_for_copying_reminder"),show_alert =False ) \ No newline at end of file +@router.callback_query(F.data == "copy_referral_link_ack") +async def copy_referral_link_ack_callback_handler( + callback: types.CallbackQuery, i18n_data: dict, settings: Settings): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + return await callback.answer("Language service error.", + show_alert=True) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + await callback.answer(text=_(key="referral_link_for_copying_reminder"), + show_alert=False) diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 735a440..8cbb8e3 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -1,165 +1,285 @@ import logging -from aiogram import Router ,F ,types ,Bot -from aiogram .filters import CommandStart ,Command -from aiogram .fsm .context import FSMContext -from typing import Optional ,Dict ,Any ,Callable ,Awaitable -from datetime import datetime ,timezone ,timedelta +from aiogram import Router, F, types, Bot +from aiogram.filters import CommandStart, Command +from aiogram.fsm.context import FSMContext +from typing import Optional, Dict, Any, Callable, Awaitable +from datetime import datetime, timezone, timedelta -from db .database import add_user_if_not_exists ,update_user_language_code -from bot .keyboards .inline .user_keyboards import get_main_menu_inline_keyboard ,get_language_selection_keyboard -from bot .services .subscription_service import SubscriptionService -from bot .services .panel_api_service import PanelApiService -from bot .services .referral_service import ReferralService -from bot .services .promo_code_service import PromoCodeService -from config .settings import Settings -from bot .middlewares .i18n import JsonI18n -from aiogram .types import InlineKeyboardMarkup +from db.database import add_user_if_not_exists, update_user_language_code +from bot.keyboards.inline.user_keyboards import get_main_menu_inline_keyboard, get_language_selection_keyboard +from bot.services.subscription_service import SubscriptionService +from bot.services.panel_api_service import PanelApiService +from bot.services.referral_service import ReferralService +from bot.services.promo_code_service import PromoCodeService +from config.settings import Settings +from bot.middlewares.i18n import JsonI18n +from aiogram.types import InlineKeyboardMarkup -router =Router (name ="user_start_router") +router = Router(name="user_start_router") -async def send_main_menu (message_or_callback :types .Message |types .CallbackQuery ,settings :Settings ,i18n_data :dict ,show_trial_button_flag :bool ,is_edit :bool =False ): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - answered_callback_internally =False - if not i18n : - logging .error ("i18n_instance missing in send_main_menu") - target_mc_for_error =message_or_callback if isinstance (message_or_callback ,types .Message )else message_or_callback .message - error_text_fallback ="Error: Language service unavailable." - if target_mc_for_error : - try :await target_mc_for_error .answer (error_text_fallback ) - except Exception as e_ans :logging .error (f"Failed to send error message in send_main_menu: {e_ans}") - if isinstance (message_or_callback ,types .CallbackQuery ):await message_or_callback .answer ();answered_callback_internally =True +async def send_main_menu(message_or_callback: types.Message + | types.CallbackQuery, + settings: Settings, + i18n_data: dict, + show_trial_button_flag: bool, + is_edit: bool = False): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + answered_callback_internally = False + + if not i18n: + logging.error("i18n_instance missing in send_main_menu") + target_mc_for_error = message_or_callback if isinstance( + message_or_callback, + types.Message) else message_or_callback.message + error_text_fallback = "Error: Language service unavailable." + if target_mc_for_error: + try: + await target_mc_for_error.answer(error_text_fallback) + except Exception as e_ans: + logging.error( + f"Failed to send error message in send_main_menu: {e_ans}") + if isinstance(message_or_callback, types.CallbackQuery): + await message_or_callback.answer() + answered_callback_internally = True return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - user_full_name =message_or_callback .from_user .full_name - text =_ (key ="main_menu_greeting",user_name =user_full_name ) - reply_markup :Optional [InlineKeyboardMarkup ]=get_main_menu_inline_keyboard (current_lang ,i18n ,settings ,show_trial_button_flag ) - target_message :Optional [types .Message ]=None - if isinstance (message_or_callback ,types .Message ):target_message =message_or_callback - elif isinstance (message_or_callback ,types .CallbackQuery ):target_message =message_or_callback .message + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + user_full_name = message_or_callback.from_user.full_name + text = _(key="main_menu_greeting", user_name=user_full_name) + reply_markup: Optional[ + InlineKeyboardMarkup] = get_main_menu_inline_keyboard( + current_lang, i18n, settings, show_trial_button_flag) + target_message: Optional[types.Message] = None + if isinstance(message_or_callback, types.Message): + target_message = message_or_callback + elif isinstance(message_or_callback, types.CallbackQuery): + target_message = message_or_callback.message - if not target_message : - logging .error (f"send_main_menu: target_message is None for event from user {message_or_callback.from_user.id}.") - if isinstance (message_or_callback ,types .CallbackQuery )and not answered_callback_internally :await message_or_callback .answer ("Error displaying menu.");answered_callback_internally =True + if not target_message: + logging.error( + f"send_main_menu: target_message is None for event from user {message_or_callback.from_user.id}." + ) + if isinstance( + message_or_callback, + types.CallbackQuery) and not answered_callback_internally: + await message_or_callback.answer("Error displaying menu.") + answered_callback_internally = True return - try : - if is_edit : - await target_message .edit_text (text ,reply_markup =reply_markup ) - else : - await target_message .answer (text ,reply_markup =reply_markup ) + try: + if is_edit: + await target_message.edit_text(text, reply_markup=reply_markup) + else: + await target_message.answer(text, reply_markup=reply_markup) - if isinstance (message_or_callback ,types .CallbackQuery )and not answered_callback_internally : - await message_or_callback .answer () - answered_callback_internally =True - except Exception as e_send_edit : - logging .warning (f"Failed to send/edit main menu (user: {message_or_callback.from_user.id}): {e_send_edit}.") - if is_edit : - try :await target_message .answer (text ,reply_markup =reply_markup ) - except Exception as e_send_new :logging .error (f"Also failed to send new main menu message: {e_send_new}") - if isinstance (message_or_callback ,types .CallbackQuery )and not answered_callback_internally : - await message_or_callback .answer () - answered_callback_internally =True + if isinstance( + message_or_callback, + types.CallbackQuery) and not answered_callback_internally: + await message_or_callback.answer() + answered_callback_internally = True + except Exception as e_send_edit: + logging.warning( + f"Failed to send/edit main menu (user: {message_or_callback.from_user.id}): {e_send_edit}." + ) + if is_edit: + try: + await target_message.answer(text, reply_markup=reply_markup) + except Exception as e_send_new: + logging.error( + f"Also failed to send new main menu message: {e_send_new}") + if isinstance( + message_or_callback, + types.CallbackQuery) and not answered_callback_internally: + await message_or_callback.answer() + answered_callback_internally = True + + if isinstance(message_or_callback, + types.CallbackQuery) and not answered_callback_internally: + logging.warning( + f"Callback {message_or_callback.id} was not answered in send_main_menu main logic paths." + ) + await message_or_callback.answer() - if isinstance (message_or_callback ,types .CallbackQuery )and not answered_callback_internally : - logging .warning (f"Callback {message_or_callback.id} was not answered in send_main_menu main logic paths.") - await message_or_callback .answer () +@router.message(CommandStart()) +async def start_command_handler(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + subscription_service: SubscriptionService, + bot: Bot): - -@router .message (CommandStart ()) -async def start_command_handler (message :types .Message ,state :FSMContext ,settings :Settings ,i18n_data :dict ,subscription_service :SubscriptionService ,bot :Bot ): - - await state .clear ();current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE ) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :logging .error ("i18n_instance not found");await message .answer ("Language service error.");return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - user_id =message .from_user .id ;referred_by_user_id :Optional [int ]=None ;args =message .text .split () - if len (args )>1 and args [0 ]=="/start": - try : - referral_param =args [1 ] - if referral_param .startswith ("ref_")and referral_param .split ("_")[1 ].isdigit (): - potential_referrer_id =int (referral_param .split ("_")[1 ]) - if potential_referrer_id !=user_id :referred_by_user_id =potential_referrer_id - except (ValueError ,IndexError )as e :logging .warning (f"Could not parse referral: '{args[1]}' - {e}") - db_op_success ,was_new_bot_user =await add_user_if_not_exists (user_id =user_id ,username =message .from_user .username ,first_name =message .from_user .first_name ,last_name =message .from_user .last_name ,lang_code =current_lang ,referred_by_id =referred_by_user_id ) - if not db_op_success :await message .answer (_ ("error_occurred_processing_request"));return - if referred_by_user_id :logging .info (f"User {user_id} started with referral from {referred_by_user_id}.") - await message .answer (_ (key ="welcome",user_name =message .from_user .full_name )) - show_trial_button_in_menu =False - if settings .TRIAL_ENABLED : - if not await subscription_service .has_had_any_subscription (user_id ): - show_trial_button_in_menu =True - logging .info (f"User {user_id} is eligible for a trial button.") - - else :logging .info (f"User {user_id} not eligible for trial button (already had a subscription).") - else :logging .info (f"Trial period is disabled in settings. No trial button.") - await send_main_menu (message ,settings ,i18n_data ,show_trial_button_flag =show_trial_button_in_menu ) - - -@router .message (Command ("language")) -@router .callback_query (F .data =="main_action:language") -async def language_command_handler (event :types .Message |types .CallbackQuery ,i18n_data :dict ,settings :Settings ): - - current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE );i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - target_message_for_reply :Optional [types .Message ]=None ;is_callback =isinstance (event ,types .CallbackQuery );answered_callback =False - if is_callback :await event .answer ();answered_callback =True ;target_message_for_reply =event .message - else :target_message_for_reply =event - if not i18n : - logging .error ("i18n instance is missing in language_command_handler.");error_message_text ="Language service error." - if target_message_for_reply :await target_message_for_reply .answer (error_message_text ) + await state.clear() + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n_instance not found") + await message.answer("Language service error.") return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );text_to_send =_ (key ="choose_language");reply_markup_to_send =get_language_selection_keyboard (i18n ,current_lang ) - if not target_message_for_reply :logging .warning ("language_command_handler: No target message.");return - if is_callback : - try :await target_message_for_reply .edit_text (text_to_send ,reply_markup =reply_markup_to_send ) - except Exception as e :logging .info (f"Could not edit for lang selection: {e}. Sending new.");await target_message_for_reply .answer (text_to_send ,reply_markup =reply_markup_to_send ) - else :await target_message_for_reply .answer (text_to_send ,reply_markup =reply_markup_to_send ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + user_id = message.from_user.id + referred_by_user_id: Optional[int] = None + args = message.text.split() + if len(args) > 1 and args[0] == "/start": + try: + referral_param = args[1] + if referral_param.startswith("ref_") and referral_param.split( + "_")[1].isdigit(): + potential_referrer_id = int(referral_param.split("_")[1]) + if potential_referrer_id != user_id: + referred_by_user_id = potential_referrer_id + except (ValueError, IndexError) as e: + logging.warning(f"Could not parse referral: '{args[1]}' - {e}") + db_op_success, was_new_bot_user = await add_user_if_not_exists( + user_id=user_id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + lang_code=current_lang, + referred_by_id=referred_by_user_id) + if not db_op_success: + await message.answer(_("error_occurred_processing_request")) + return + if referred_by_user_id: + logging.info( + f"User {user_id} started with referral from {referred_by_user_id}." + ) + await message.answer( + _(key="welcome", user_name=message.from_user.full_name)) + show_trial_button_in_menu = False + if settings.TRIAL_ENABLED: + if not await subscription_service.has_had_any_subscription(user_id): + show_trial_button_in_menu = True + logging.info(f"User {user_id} is eligible for a trial button.") + + else: + logging.info( + f"User {user_id} not eligible for trial button (already had a subscription)." + ) + else: + logging.info(f"Trial period is disabled in settings. No trial button.") + await send_main_menu(message, + settings, + i18n_data, + show_trial_button_flag=show_trial_button_in_menu) -@router .callback_query (F .data .startswith ("set_lang_")) -async def select_language_callback_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ,subscription_service :SubscriptionService ): - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n or not callback .message : - await callback .answer ("Language service error.",show_alert =True );return +@router.message(Command("language")) +@router.callback_query(F.data == "main_action:language") +async def language_command_handler(event: types.Message | types.CallbackQuery, + i18n_data: dict, settings: Settings): - lang_code =callback .data .split ("_")[2 ];user_id =callback .from_user .id - await update_user_language_code (user_id ,lang_code );i18n_data ["current_language"]=lang_code - _ =lambda key ,**kwargs :i18n .gettext (lang_code ,key ,**kwargs ) + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + target_message_for_reply: Optional[types.Message] = None + is_callback = isinstance(event, types.CallbackQuery) + answered_callback = False + if is_callback: + await event.answer() + answered_callback = True + target_message_for_reply = event.message + else: + target_message_for_reply = event + if not i18n: + logging.error("i18n instance is missing in language_command_handler.") + error_message_text = "Language service error." + if target_message_for_reply: + await target_message_for_reply.answer(error_message_text) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + text_to_send = _(key="choose_language") + reply_markup_to_send = get_language_selection_keyboard(i18n, current_lang) + if not target_message_for_reply: + logging.warning("language_command_handler: No target message.") + return + if is_callback: + try: + await target_message_for_reply.edit_text( + text_to_send, reply_markup=reply_markup_to_send) + except Exception as e: + logging.info( + f"Could not edit for lang selection: {e}. Sending new.") + await target_message_for_reply.answer( + text_to_send, reply_markup=reply_markup_to_send) + else: + await target_message_for_reply.answer( + text_to_send, reply_markup=reply_markup_to_send) - await callback .answer (_ (key ="language_set_alert")) +@router.callback_query(F.data.startswith("set_lang_")) +async def select_language_callback_handler( + callback: types.CallbackQuery, i18n_data: dict, settings: Settings, + subscription_service: SubscriptionService): + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Language service error.", show_alert=True) + return - show_trial_button_after_lang_change =False - if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (user_id ): - show_trial_button_after_lang_change =True + lang_code = callback.data.split("_")[2] + user_id = callback.from_user.id + await update_user_language_code(user_id, lang_code) + i18n_data["current_language"] = lang_code + _ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs) + + await callback.answer(_(key="language_set_alert")) + + show_trial_button_after_lang_change = False + if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( + user_id): + show_trial_button_after_lang_change = True + + await send_main_menu( + callback, + settings, + i18n_data, + show_trial_button_flag=show_trial_button_after_lang_change, + is_edit=True) - await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =show_trial_button_after_lang_change ,is_edit =True ) +@router.callback_query(F.data.startswith("main_action:")) +async def main_action_callback_handler( + callback: types.CallbackQuery, state: FSMContext, settings: Settings, + i18n_data: dict, bot: Bot, subscription_service: SubscriptionService, + referral_service: ReferralService, panel_service: PanelApiService, + promo_code_service: PromoCodeService): + action = callback.data.split(":")[1] + from . import subscription as user_subscription_handlers + from . import referral as user_referral_handlers + from . import promo_user as user_promo_handlers + from . import trial_handler as user_trial_handlers + if not callback.message: + logging.error(f"Callback {callback.id} no message for {action}") + await callback.answer("Error.") + return -@router .callback_query (F .data .startswith ("main_action:")) -async def main_action_callback_handler ( - -callback :types .CallbackQuery ,state :FSMContext ,settings :Settings ,i18n_data :dict ,bot :Bot , -subscription_service :SubscriptionService ,referral_service :ReferralService , -panel_service :PanelApiService ,promo_code_service :PromoCodeService -): - - action =callback .data .split (":")[1 ];from .import subscription as user_subscription_handlers ;from .import referral as user_referral_handlers ;from .import promo_user as user_promo_handlers ;from .import trial_handler as user_trial_handlers - if not callback .message :logging .error (f"Callback {callback.id} no message for {action}");await callback .answer ("Error.");return - - if action =="subscribe":await user_subscription_handlers .display_subscription_options (callback ,i18n_data ,settings ) - elif action =="my_subscription":await user_subscription_handlers .my_subscription_command_handler (callback ,i18n_data ,settings ,panel_service ,subscription_service ) - elif action =="referral":await user_referral_handlers .referral_command_handler (callback ,settings ,i18n_data ,referral_service ,bot ) - elif action =="apply_promo":await user_promo_handlers .prompt_promo_code_input (callback ,state ,i18n_data ,settings ) - elif action =="request_trial":await user_trial_handlers .request_trial_confirmation_handler (callback ,settings ,i18n_data ,subscription_service ) - elif action =="language":await language_command_handler (callback ,i18n_data ,settings ) - elif action =="back_to_main": - show_trial_button_on_back =False - if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (callback .from_user .id ): - show_trial_button_on_back =True - await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =show_trial_button_on_back ,is_edit =True ) - else :await callback .answer ("Unknown action.",show_alert =True ) \ No newline at end of file + if action == "subscribe": + await user_subscription_handlers.display_subscription_options( + callback, i18n_data, settings) + elif action == "my_subscription": + await user_subscription_handlers.my_subscription_command_handler( + callback, i18n_data, settings, panel_service, subscription_service) + elif action == "referral": + await user_referral_handlers.referral_command_handler( + callback, settings, i18n_data, referral_service, bot) + elif action == "apply_promo": + await user_promo_handlers.prompt_promo_code_input( + callback, state, i18n_data, settings) + elif action == "request_trial": + await user_trial_handlers.request_trial_confirmation_handler( + callback, settings, i18n_data, subscription_service) + elif action == "language": + await language_command_handler(callback, i18n_data, settings) + elif action == "back_to_main": + show_trial_button_on_back = False + if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( + callback.from_user.id): + show_trial_button_on_back = True + await send_main_menu(callback, + settings, + i18n_data, + show_trial_button_flag=show_trial_button_on_back, + is_edit=True) + else: + await callback.answer("Unknown action.", show_alert=True) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 6fccdf3..7c04237 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -1,207 +1,327 @@ import logging import aiosqlite -from aiogram import Router ,F ,types ,Bot -from aiogram .filters import Command -from aiogram .fsm .context import FSMContext -from typing import Optional ,Dict ,Any -from datetime import datetime ,timezone -from aiogram .utils .keyboard import InlineKeyboardBuilder -from aiogram .types import InlineKeyboardMarkup +from aiogram import Router, F, types, Bot +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from typing import Optional, Dict, Any +from datetime import datetime, timezone +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import InlineKeyboardMarkup -from config .settings import Settings -from db .database import add_payment_record ,get_db_connection_manager ,_setup_db_connection -from bot .keyboards .inline .user_keyboards import ( -get_subscription_options_keyboard , -get_confirm_subscription_keyboard , -get_payment_url_keyboard , -get_back_to_main_menu_markup -) -from bot .services .payment_service import YooKassaService -from bot .services .subscription_service import SubscriptionService -from bot .services .panel_api_service import PanelApiService -from bot .middlewares .i18n import JsonI18n +from config.settings import Settings +from db.database import add_payment_record, get_db_connection_manager, _setup_db_connection +from bot.keyboards.inline.user_keyboards import ( + get_subscription_options_keyboard, get_confirm_subscription_keyboard, + get_payment_url_keyboard, get_back_to_main_menu_markup) +from bot.services.payment_service import YooKassaService +from bot.services.subscription_service import SubscriptionService +from bot.services.panel_api_service import PanelApiService +from bot.middlewares.i18n import JsonI18n -router =Router (name ="user_subscription_router") +router = Router(name="user_subscription_router") -async def display_subscription_options (message_or_callback :types .Message |types .CallbackQuery ,i18n_data :dict ,settings :Settings ): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n missing in display_subscription_options") - target_msg =message_or_callback .message if isinstance (message_or_callback ,types .CallbackQuery )else message_or_callback - if target_msg :await target_msg .answer ("Language service error.") - if isinstance (message_or_callback ,types .CallbackQuery ):await message_or_callback .answer () +async def display_subscription_options(message_or_callback: types.Message + | types.CallbackQuery, i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing in display_subscription_options") + + target_msg = message_or_callback.message if isinstance( + message_or_callback, types.CallbackQuery) else message_or_callback + if target_msg: await target_msg.answer("Language service error.") + if isinstance(message_or_callback, types.CallbackQuery): + await message_or_callback.answer() return - get_translation =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - currency_symbol_val =settings .DEFAULT_CURRENCY_SYMBOL + get_translation = lambda key, **kwargs: i18n.gettext( + current_lang, key, **kwargs) + currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL - text =get_translation ("select_subscription_period")if settings .subscription_options else get_translation ("no_subscription_options_available") - reply_markup =get_subscription_options_keyboard (settings .subscription_options ,currency_symbol_val ,current_lang ,i18n )if settings .subscription_options else None + text = get_translation( + "select_subscription_period" + ) if settings.subscription_options else get_translation( + "no_subscription_options_available") + reply_markup = get_subscription_options_keyboard( + settings.subscription_options, currency_symbol_val, current_lang, + i18n) if settings.subscription_options else None - target_message =message_or_callback .message if isinstance (message_or_callback ,types .CallbackQuery )else message_or_callback - answered_callback =False + target_message = message_or_callback.message if isinstance( + message_or_callback, types.CallbackQuery) else message_or_callback + answered_callback = False - if isinstance (message_or_callback ,types .CallbackQuery ): + if isinstance(message_or_callback, types.CallbackQuery): - await message_or_callback .answer () - answered_callback =True + await message_or_callback.answer() + answered_callback = True - if target_message : - if isinstance (message_or_callback ,types .CallbackQuery ): - try : - await target_message .edit_text (text ,reply_markup =reply_markup ) - except Exception : - await target_message .answer (text ,reply_markup =reply_markup ) - else : - await target_message .answer (text ,reply_markup =reply_markup ) - elif isinstance (message_or_callback ,types .Message ): - await message_or_callback .answer (text ,reply_markup =reply_markup ) + if target_message: + if isinstance(message_or_callback, types.CallbackQuery): + try: + await target_message.edit_text(text, reply_markup=reply_markup) + except Exception: + await target_message.answer(text, reply_markup=reply_markup) + else: + await target_message.answer(text, reply_markup=reply_markup) + elif isinstance(message_or_callback, types.Message): + await message_or_callback.answer(text, reply_markup=reply_markup) + + if isinstance(message_or_callback, + types.CallbackQuery) and not answered_callback: + await message_or_callback.answer() - if isinstance (message_or_callback ,types .CallbackQuery )and not answered_callback : - await message_or_callback .answer () - - -@router .callback_query (F .data .startswith ("subscribe_period:")) -async def select_subscription_period_callback_handler (callback :types .CallbackQuery ,state :FSMContext ,settings :Settings ,i18n_data :dict ): - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n : - logging .error ("i18n missing in select_subscription_period_callback_handler") - await callback .answer ("Service error. Please try again.",show_alert =True ) +@router.callback_query(F.data.startswith("subscribe_period:")) +async def select_subscription_period_callback_handler( + callback: types.CallbackQuery, state: FSMContext, settings: Settings, + i18n_data: dict): + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error( + "i18n missing in select_subscription_period_callback_handler") + await callback.answer("Service error. Please try again.", + show_alert=True) return - get_translation =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) + get_translation = lambda key, **kwargs: i18n.gettext( + current_lang, key, **kwargs) - try :months =int (callback .data .split (":")[-1 ]) - except ValueError : - logging .error (f"Invalid sub period: {callback.data}") - await callback .answer (get_translation ("error_try_again"),show_alert =True );return + try: + months = int(callback.data.split(":")[-1]) + except ValueError: + logging.error(f"Invalid sub period: {callback.data}") + await callback.answer(get_translation("error_try_again"), + show_alert=True) + return - price =settings .subscription_options .get (months ) - if price is None : - logging .error (f"Price not found for {months} months subscription.") - await callback .answer (get_translation ("error_try_again"),show_alert =True );return + price = settings.subscription_options.get(months) + if price is None: + logging.error(f"Price not found for {months} months subscription.") + await callback.answer(get_translation("error_try_again"), + show_alert=True) + return - currency_symbol_val =settings .DEFAULT_CURRENCY_SYMBOL - confirmation_text =get_translation ("confirm_subscription_prompt",months =months ,price =price ,currency_symbol =currency_symbol_val ) - reply_markup =get_confirm_subscription_keyboard (months ,price ,currency_symbol_val ,current_lang ,i18n ) + currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL + confirmation_text = get_translation("confirm_subscription_prompt", + months=months, + price=price, + currency_symbol=currency_symbol_val) + reply_markup = get_confirm_subscription_keyboard(months, price, + currency_symbol_val, + current_lang, i18n) - if callback .message : - try :await callback .message .edit_text (confirmation_text ,reply_markup =reply_markup ) - except Exception as e :logging .warning (f"Edit failed: {e}");await callback .message .answer (confirmation_text ,reply_markup =reply_markup ) - await callback .answer () + if callback.message: + try: + await callback.message.edit_text(confirmation_text, + reply_markup=reply_markup) + except Exception as e: + logging.warning(f"Edit failed: {e}") + await callback.message.answer(confirmation_text, + reply_markup=reply_markup) + await callback.answer() -@router .callback_query (F .data .startswith ("confirm_sub:")) -async def confirm_subscription_callback_handler ( -callback :types .CallbackQuery ,state :FSMContext , -settings :Settings ,i18n_data :dict ,yookassa_service :YooKassaService -): - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - if not i18n :logging .error ("i18n missing");await callback .answer ("Language error.",show_alert =True );return - get_translation =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - if not yookassa_service or not yookassa_service .configured : - logging .error ("YooKassa service missing or not configured") - await callback .message .edit_text (get_translation ("payment_service_unavailable"))if callback .message else None - await callback .answer (get_translation ("payment_service_unavailable"),show_alert =True );return - try :_ ,data_payload =callback .data .split (":",1 );months_str ,price_str =data_payload .split (":");months =int (months_str );price =float (price_str ) - except ValueError :logging .error (f"Invalid confirm data: {callback.data}");await callback .answer (get_translation ("error_try_again"),show_alert =True );return +@router.callback_query(F.data.startswith("confirm_sub:")) +async def confirm_subscription_callback_handler( + callback: types.CallbackQuery, state: FSMContext, settings: Settings, + i18n_data: dict, yookassa_service: YooKassaService): + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + if not i18n: + logging.error("i18n missing") + await callback.answer("Language error.", show_alert=True) + return + get_translation = lambda key, **kwargs: i18n.gettext( + current_lang, key, **kwargs) + if not yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service missing or not configured") + await callback.message.edit_text( + get_translation("payment_service_unavailable") + ) if callback.message else None + await callback.answer(get_translation("payment_service_unavailable"), + show_alert=True) + return + try: + _, data_payload = callback.data.split(":", 1) + months_str, price_str = data_payload.split(":") + months = int(months_str) + price = float(price_str) + except ValueError: + logging.error(f"Invalid confirm data: {callback.data}") + await callback.answer(get_translation("error_try_again"), + show_alert=True) + return - user_id =callback .from_user .id - description =get_translation ("payment_description_subscription",months =months ) - currency =settings .DEFAULT_CURRENCY_SYMBOL - payment_metadata ={"user_id":str (user_id ),"subscription_months":str (months ),"description":description } - payment_db_id =await add_payment_record (user_id ,None ,None ,price ,currency ,"pending_creation",description ,months ,None ) - if not payment_db_id : - if callback .message :await callback .message .edit_text (get_translation ("error_creating_payment_record")) - await callback .answer (show_alert =True );return - payment_metadata ["payment_db_id"]=str (payment_db_id ) - payment_response =await yookassa_service .create_payment (price ,currency ,description ,payment_metadata ) + user_id = callback.from_user.id + description = get_translation("payment_description_subscription", + months=months) + currency = settings.DEFAULT_CURRENCY_SYMBOL + payment_metadata = { + "user_id": str(user_id), + "subscription_months": str(months), + "description": description + } + payment_db_id = await add_payment_record(user_id, None, None, price, + currency, "pending_creation", + description, months, None) + if not payment_db_id: + if callback.message: + await callback.message.edit_text( + get_translation("error_creating_payment_record")) + await callback.answer(show_alert=True) + return + payment_metadata["payment_db_id"] = str(payment_db_id) + payment_response = await yookassa_service.create_payment( + price, currency, description, payment_metadata) - if callback .message : - if payment_response and payment_response .get ("confirmation_url"): - async with get_db_connection_manager ()as db :await _setup_db_connection (db );await db .execute ("UPDATE payments SET yookassa_payment_id = ?, idempotence_key = ?, status = ? WHERE payment_id = ?",(payment_response ["id"],payment_response .get ("idempotence_key"),payment_response ["status"],payment_db_id ));await db .commit () - await callback .message .edit_text (get_translation (key ="payment_link_message",months =months ),reply_markup =get_payment_url_keyboard (payment_response ["confirmation_url"],current_lang ,i18n ),disable_web_page_preview =False ) - else : - async with get_db_connection_manager ()as db :await _setup_db_connection (db );await db .execute ("UPDATE payments SET status = ? WHERE payment_id = ?",("failed_creation",payment_db_id ));await db .commit () - await callback .message .edit_text (get_translation ("error_payment_gateway")) - await callback .answer () + if callback.message: + if payment_response and payment_response.get("confirmation_url"): + async with get_db_connection_manager() as db: + await _setup_db_connection(db) + await db.execute( + "UPDATE payments SET yookassa_payment_id = ?, idempotence_key = ?, status = ? WHERE payment_id = ?", + (payment_response["id"], + payment_response.get("idempotence_key"), + payment_response["status"], payment_db_id)) + await db.commit() + await callback.message.edit_text( + get_translation(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + payment_response["confirmation_url"], current_lang, i18n), + disable_web_page_preview=False) + else: + async with get_db_connection_manager() as db: + await _setup_db_connection(db) + await db.execute( + "UPDATE payments SET status = ? WHERE payment_id = ?", + ("failed_creation", payment_db_id)) + await db.commit() + await callback.message.edit_text( + get_translation("error_payment_gateway")) + await callback.answer() -@router .callback_query (F .data =="main_action:subscribe") -async def reshow_subscription_options_callback (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ): - await display_subscription_options (callback ,i18n_data ,settings ) +@router.callback_query(F.data == "main_action:subscribe") +async def reshow_subscription_options_callback(callback: types.CallbackQuery, + i18n_data: dict, + settings: Settings): + await display_subscription_options(callback, i18n_data, settings) +async def my_subscription_command_handler( + message_event: types.Message | types.CallbackQuery, i18n_data: dict, + settings: Settings, panel_service: PanelApiService, + subscription_service: SubscriptionService): + target_message = message_event.message if isinstance( + message_event, types.CallbackQuery) else message_event + user = message_event.from_user + if isinstance(message_event, types.CallbackQuery): + await message_event.answer() -async def my_subscription_command_handler ( -message_event :types .Message |types .CallbackQuery , -i18n_data :dict , -settings :Settings , -panel_service :PanelApiService , -subscription_service :SubscriptionService -): - target_message =message_event .message if isinstance (message_event ,types .CallbackQuery )else message_event - user =message_event .from_user - if isinstance (message_event ,types .CallbackQuery ):await message_event .answer () + current_lang = i18n_data.get("current_language", + getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + logging.error("i18n missing") + await target_message.answer("Lang error") + return + get_translation = lambda key, **kwargs: i18n.gettext( + current_lang, key, **kwargs) + if not panel_service or not subscription_service: + logging.error("Services missing") + await target_message.answer( + get_translation("error_service_unavailable")) + return - current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en')) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n :logging .error ("i18n missing");await target_message .answer ("Lang error");return - get_translation =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - if not panel_service or not subscription_service :logging .error ("Services missing");await target_message .answer (get_translation ("error_service_unavailable"));return + active_sub = await subscription_service.get_active_subscription(user.id) + sub_info_text = "" + if active_sub: + end_date_obj = active_sub.get('end_date') + if isinstance(end_date_obj, str): + try: + end_date_obj = datetime.fromisoformat( + end_date_obj.replace("Z", "+00:00")) + except ValueError: + logging.warning( + f"Could not parse date string '{end_date_obj}'.") + end_date_obj = datetime.now(timezone.utc) - active_sub =await subscription_service .get_active_subscription (user .id ) - sub_info_text ="" - if active_sub : - end_date_obj =active_sub .get ('end_date') - if isinstance (end_date_obj ,str ): - try :end_date_obj =datetime .fromisoformat (end_date_obj .replace ("Z","+00:00")) - except ValueError :logging .warning (f"Could not parse date string '{end_date_obj}'.");end_date_obj =datetime .now (timezone .utc ) + if not isinstance(end_date_obj, datetime): + end_date_obj = datetime.now(timezone.utc) + if end_date_obj.tzinfo is None: + end_date_obj = end_date_obj.replace(tzinfo=timezone.utc) - if not isinstance (end_date_obj ,datetime ):end_date_obj =datetime .now (timezone .utc ) - if end_date_obj .tzinfo is None :end_date_obj =end_date_obj .replace (tzinfo =timezone .utc ) + today_date_utc = datetime.now(timezone.utc).date() + end_date_only = end_date_obj.date() + days_left = (end_date_only - today_date_utc).days + + actual_config_link = get_translation("config_link_not_available") + panel_user_uuid = active_sub.get('panel_user_uuid') + if panel_user_uuid: + panel_user_data = await panel_service.get_user_by_uuid( + panel_user_uuid) + if panel_user_data: + if panel_user_data.get('subscriptionUrl'): + actual_config_link = panel_user_data['subscriptionUrl'] + elif panel_user_data.get('shortUuid'): + link = await panel_service.get_subscription_link( + panel_user_data['shortUuid']) + if link: actual_config_link = link + + traffic_limit_gb = get_translation("traffic_unlimited") + traffic_used_gb = get_translation("traffic_na") + if active_sub.get('traffic_limit_bytes' + ) and active_sub['traffic_limit_bytes'] > 0: + traffic_limit_gb = f"{active_sub['traffic_limit_bytes'] / (1024**3):.2f} GB" + if active_sub.get('traffic_used_bytes') is not None: + traffic_used_gb = f"{active_sub['traffic_used_bytes'] / (1024**3):.2f} GB" + + sub_info_text = get_translation( + "my_subscription_details", + end_date=end_date_obj.strftime("%Y-%m-%d"), + days_left=max(0, days_left), + status=active_sub.get( + 'status_from_panel', + get_translation('status_active')).capitalize(), + config_link=actual_config_link, + traffic_limit=traffic_limit_gb, + traffic_used=traffic_used_gb) + else: + sub_info_text = get_translation("subscription_not_active") + + reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n) + if isinstance(message_event, + types.CallbackQuery) and message_event.message: + try: + await message_event.message.edit_text( + sub_info_text, + reply_markup=reply_markup_val, + parse_mode="HTML", + disable_web_page_preview=True) + except Exception as e: + logging.warning(f"Edit 'my_sub' failed: {e}") + await target_message.answer(sub_info_text, + reply_markup=reply_markup_val, + parse_mode="HTML", + disable_web_page_preview=True) + else: + await target_message.answer(sub_info_text, + reply_markup=reply_markup_val, + parse_mode="HTML", + disable_web_page_preview=True) - today_date_utc =datetime .now (timezone .utc ).date () - end_date_only =end_date_obj .date () - days_left =(end_date_only -today_date_utc ).days - - actual_config_link =get_translation ("config_link_not_available");panel_user_uuid =active_sub .get ('panel_user_uuid') - if panel_user_uuid : - panel_user_data =await panel_service .get_user_by_uuid (panel_user_uuid ) - if panel_user_data : - if panel_user_data .get ('subscriptionUrl'):actual_config_link =panel_user_data ['subscriptionUrl'] - elif panel_user_data .get ('shortUuid'): - link =await panel_service .get_subscription_link (panel_user_data ['shortUuid']) - if link :actual_config_link =link - - traffic_limit_gb =get_translation ("traffic_unlimited");traffic_used_gb =get_translation ("traffic_na") - if active_sub .get ('traffic_limit_bytes')and active_sub ['traffic_limit_bytes']>0 :traffic_limit_gb =f"{active_sub['traffic_limit_bytes'] / (1024**3):.2f} GB" - if active_sub .get ('traffic_used_bytes')is not None :traffic_used_gb =f"{active_sub['traffic_used_bytes'] / (1024**3):.2f} GB" - - sub_info_text =get_translation ("my_subscription_details",end_date =end_date_obj .strftime ("%Y-%m-%d"),days_left =max (0 ,days_left ),status =active_sub .get ('status_from_panel',get_translation ('status_active')).capitalize (),config_link =actual_config_link ,traffic_limit =traffic_limit_gb ,traffic_used =traffic_used_gb ) - else : - sub_info_text =get_translation ("subscription_not_active") - - reply_markup_val =get_back_to_main_menu_markup (current_lang ,i18n ) - if isinstance (message_event ,types .CallbackQuery )and message_event .message : - try :await message_event .message .edit_text (sub_info_text ,reply_markup =reply_markup_val ,parse_mode ="HTML",disable_web_page_preview =True ) - except Exception as e :logging .warning (f"Edit 'my_sub' failed: {e}");await target_message .answer (sub_info_text ,reply_markup =reply_markup_val ,parse_mode ="HTML",disable_web_page_preview =True ) - else :await target_message .answer (sub_info_text ,reply_markup =reply_markup_val ,parse_mode ="HTML",disable_web_page_preview =True ) - - -@router .message (Command ("connect")) -async def connect_command_handler ( -message :types .Message , -i18n_data :dict , -settings :Settings , -panel_service :PanelApiService , -subscription_service :SubscriptionService -): +@router.message(Command("connect")) +async def connect_command_handler(message: types.Message, i18n_data: dict, + settings: Settings, + panel_service: PanelApiService, + subscription_service: SubscriptionService): """Handles the /connect command, showing subscription info.""" - logging .info (f"User {message.from_user.id} used /connect command.") - await my_subscription_command_handler (message ,i18n_data ,settings ,panel_service ,subscription_service ) \ No newline at end of file + logging.info(f"User {message.from_user.id} used /connect command.") + await my_subscription_command_handler(message, i18n_data, settings, + panel_service, subscription_service) diff --git a/bot/handlers/user/trial_handler.py b/bot/handlers/user/trial_handler.py index b3b46bf..247af23 100644 --- a/bot/handlers/user/trial_handler.py +++ b/bot/handlers/user/trial_handler.py @@ -1,113 +1,164 @@ import logging -from aiogram import Router ,F ,types ,Bot +from aiogram import Router, F, types, Bot -from typing import Optional ,Dict ,Any -from datetime import datetime ,timedelta ,timezone +from typing import Optional, Dict, Any +from datetime import datetime, timedelta, timezone -from config .settings import Settings -from bot .services .subscription_service import SubscriptionService -from bot .services .panel_api_service import PanelApiService -from bot .keyboards .inline .user_keyboards import get_trial_confirmation_keyboard ,get_main_menu_inline_keyboard -from bot .middlewares .i18n import JsonI18n +from config.settings import Settings +from bot.services.subscription_service import SubscriptionService +from bot.services.panel_api_service import PanelApiService +from bot.keyboards.inline.user_keyboards import get_trial_confirmation_keyboard, get_main_menu_inline_keyboard +from bot.middlewares.i18n import JsonI18n from .start import send_main_menu -router =Router (name ="user_trial_router") +router = Router(name="user_trial_router") -async def request_trial_confirmation_handler (callback :types .CallbackQuery ,settings :Settings ,i18n_data :dict ,subscription_service :SubscriptionService ): - user_id =callback .from_user .id ;current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE );i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - if not i18n or not callback .message :await callback .answer ("Error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - show_trial_btn_if_fail =False ; - if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (user_id ):show_trial_btn_if_fail =True - if not settings .TRIAL_ENABLED :await callback .message .edit_text (_ ("trial_feature_disabled"),reply_markup =get_main_menu_inline_keyboard (current_lang ,i18n ,settings ,show_trial_btn_if_fail ));await callback .answer ();return - if await subscription_service .has_had_any_subscription (user_id ):await callback .message .edit_text (_ ("trial_already_had_subscription_or_trial"),reply_markup =get_main_menu_inline_keyboard (current_lang ,i18n ,settings ,False ));await callback .answer ();return - traffic_gb_display =str (settings .TRIAL_TRAFFIC_LIMIT_GB )if settings .TRIAL_TRAFFIC_LIMIT_GB and settings .TRIAL_TRAFFIC_LIMIT_GB >0 else _ ("traffic_unlimited") - await callback .message .edit_text (text =_ ("trial_confirm_prompt",days =settings .TRIAL_DURATION_DAYS ,traffic_gb =traffic_gb_display ),reply_markup =get_trial_confirmation_keyboard (current_lang ,i18n ));await callback .answer () - - -@router .callback_query (F .data =="trial_action:confirm_activate") -async def confirm_activate_trial_handler ( -callback :types .CallbackQuery , -settings :Settings , -i18n_data :dict , -subscription_service :SubscriptionService , -panel_service :PanelApiService , -bot :Bot -): - user_id =callback .from_user .id - current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE ) - i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance") - - if not i18n or not callback .message :await callback .answer ("Error.",show_alert =True );return - _ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs ) - - show_trial_button_after_action =False - if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (user_id ): - show_trial_button_after_action =True - - - if not settings .TRIAL_ENABLED : - await callback .answer (_ ("trial_feature_disabled"),show_alert =True ) - await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =False ,is_edit =True ) +async def request_trial_confirmation_handler( + callback: types.CallbackQuery, settings: Settings, i18n_data: dict, + subscription_service: SubscriptionService): + user_id = callback.from_user.id + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error.", show_alert=True) return - if await subscription_service .has_had_any_subscription (user_id ): - await callback .answer (_ ("trial_already_had_subscription_or_trial"),show_alert =True ) - await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =False ,is_edit =True ) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + show_trial_btn_if_fail = False + if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( + user_id): + show_trial_btn_if_fail = True + if not settings.TRIAL_ENABLED: + await callback.message.edit_text( + _("trial_feature_disabled"), + reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, + settings, + show_trial_btn_if_fail)) + await callback.answer() + return + if await subscription_service.has_had_any_subscription(user_id): + await callback.message.edit_text( + _("trial_already_had_subscription_or_trial"), + reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, + settings, False)) + await callback.answer() + return + traffic_gb_display = str( + settings.TRIAL_TRAFFIC_LIMIT_GB + ) if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0 else _( + "traffic_unlimited") + await callback.message.edit_text( + text=_("trial_confirm_prompt", + days=settings.TRIAL_DURATION_DAYS, + traffic_gb=traffic_gb_display), + reply_markup=get_trial_confirmation_keyboard(current_lang, i18n)) + await callback.answer() + + +@router.callback_query(F.data == "trial_action:confirm_activate") +async def confirm_activate_trial_handler( + callback: types.CallbackQuery, settings: Settings, i18n_data: dict, + subscription_service: SubscriptionService, + panel_service: PanelApiService, bot: Bot): + user_id = callback.from_user.id + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + + if not i18n or not callback.message: + await callback.answer("Error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + show_trial_button_after_action = False + if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( + user_id): + show_trial_button_after_action = True + + if not settings.TRIAL_ENABLED: + await callback.answer(_("trial_feature_disabled"), show_alert=True) + await send_main_menu(callback, + settings, + i18n_data, + show_trial_button_flag=False, + is_edit=True) + return + if await subscription_service.has_had_any_subscription(user_id): + await callback.answer(_("trial_already_had_subscription_or_trial"), + show_alert=True) + await send_main_menu(callback, + settings, + i18n_data, + show_trial_button_flag=False, + is_edit=True) return - activation_result =await subscription_service .activate_trial_subscription (user_id ) - final_message_text_in_chat ="" + activation_result = await subscription_service.activate_trial_subscription( + user_id) + final_message_text_in_chat = "" - if activation_result and activation_result .get ("activated"): - await callback .answer (_ ("trial_activated_alert"),show_alert =True ) + if activation_result and activation_result.get("activated"): + await callback.answer(_("trial_activated_alert"), show_alert=True) - end_date =activation_result .get ("end_date") - config_link_for_trial =_ ("config_link_not_available") + end_date = activation_result.get("end_date") + config_link_for_trial = _("config_link_not_available") + + if activation_result.get("subscription_url"): + config_link_for_trial = activation_result["subscription_url"] + elif activation_result.get("panel_short_uuid"): + link = await panel_service.get_subscription_link( + activation_result["panel_short_uuid"]) + if link: config_link_for_trial = link + + traffic_gb_val = activation_result.get("traffic_gb", + settings.TRIAL_TRAFFIC_LIMIT_GB) + traffic_display = f"{traffic_gb_val} GB" if traffic_gb_val and traffic_gb_val > 0 else _( + "traffic_unlimited") + + final_message_text_in_chat = _( + "trial_activated_details_message", + days=activation_result.get("days", settings.TRIAL_DURATION_DAYS), + end_date=end_date.strftime('%Y-%m-%d') if isinstance( + end_date, datetime) else "N/A", + config_link=config_link_for_trial, + traffic_gb=traffic_display) + show_trial_button_after_action = False + else: + message_key = activation_result.get( + "message_key", "trial_activation_failed" + ) if activation_result else "trial_activation_failed" + final_message_text_in_chat = _(message_key) + await callback.answer(final_message_text_in_chat, show_alert=True) + + if callback.message: + try: + await callback.message.edit_text( + final_message_text_in_chat, + parse_mode="HTML", + reply_markup=get_main_menu_inline_keyboard( + current_lang, i18n, settings, + show_trial_button_after_action), + disable_web_page_preview=True) + except Exception as e_edit: + logging.warning(f"Could not edit trial result message: {e_edit}") + await callback.message.answer( + final_message_text_in_chat, + parse_mode="HTML", + reply_markup=get_main_menu_inline_keyboard( + current_lang, i18n, settings, + show_trial_button_after_action), + disable_web_page_preview=True) - - if activation_result .get ("subscription_url"): - config_link_for_trial =activation_result ["subscription_url"] - elif activation_result .get ("panel_short_uuid"): - link =await panel_service .get_subscription_link (activation_result ["panel_short_uuid"]) - if link :config_link_for_trial =link - - traffic_gb_val =activation_result .get ("traffic_gb",settings .TRIAL_TRAFFIC_LIMIT_GB ) - traffic_display =f"{traffic_gb_val} GB"if traffic_gb_val and traffic_gb_val >0 else _ ("traffic_unlimited") - - final_message_text_in_chat =_ ( - "trial_activated_details_message", - days =activation_result .get ("days",settings .TRIAL_DURATION_DAYS ), - end_date =end_date .strftime ('%Y-%m-%d')if isinstance (end_date ,datetime )else "N/A", - config_link =config_link_for_trial , - traffic_gb =traffic_display - ) - show_trial_button_after_action =False - else : - message_key =activation_result .get ("message_key","trial_activation_failed")if activation_result else "trial_activation_failed" - final_message_text_in_chat =_ (message_key ) - await callback .answer (final_message_text_in_chat ,show_alert =True ) - - - if callback .message : - try : - await callback .message .edit_text (final_message_text_in_chat , - parse_mode ="HTML", - reply_markup =get_main_menu_inline_keyboard (current_lang ,i18n ,settings ,show_trial_button_after_action ), - disable_web_page_preview =True ) - except Exception as e_edit : - logging .warning (f"Could not edit trial result message: {e_edit}") - await callback .message .answer (final_message_text_in_chat , - parse_mode ="HTML", - reply_markup =get_main_menu_inline_keyboard (current_lang ,i18n ,settings ,show_trial_button_after_action ), - disable_web_page_preview =True ) - - - -@router .callback_query (F .data =="main_action:cancel_trial") -async def cancel_trial_activation (callback :types .CallbackQuery ,settings :Settings ,i18n_data :dict ,subscription_service :SubscriptionService ): - show_trial_button_on_back =False - if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (callback .from_user .id ): - show_trial_button_on_back =True - await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =show_trial_button_on_back ,is_edit =True ) \ No newline at end of file +@router.callback_query(F.data == "main_action:cancel_trial") +async def cancel_trial_activation(callback: types.CallbackQuery, + settings: Settings, i18n_data: dict, + subscription_service: SubscriptionService): + show_trial_button_on_back = False + if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( + callback.from_user.id): + show_trial_button_on_back = True + await send_main_menu(callback, + settings, + i18n_data, + show_trial_button_flag=show_trial_button_on_back, + is_edit=True) diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 16f4cde..34871b6 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -1,82 +1,172 @@ -from aiogram .utils .keyboard import InlineKeyboardBuilder ,InlineKeyboardButton -from aiogram .types import InlineKeyboardMarkup ,WebAppInfo -from typing import Optional ,List ,Any +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from aiogram.types import InlineKeyboardMarkup, WebAppInfo +from typing import Optional, List, Any import math -from config .settings import Settings +from config.settings import Settings -def get_admin_panel_keyboard (i18n_instance ,lang :str ,settings :Settings )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () - builder .button (text =_ (key ="admin_stats_button"),callback_data ="admin_action:stats") - builder .button (text =_ (key ="admin_broadcast_button"),callback_data ="admin_action:broadcast") - builder .button (text =_ (key ="admin_create_promo_button"),callback_data ="admin_action:create_promo") - builder .button (text =_ (key ="admin_view_promos_button"),callback_data ="admin_action:view_promos") - builder .button (text =_ (key ="admin_ban_user_button"),callback_data ="admin_action:ban_user_prompt") - builder .button (text =_ (key ="admin_unban_user_button"),callback_data ="admin_action:unban_user_prompt") - builder .button (text =_ (key ="admin_view_banned_users_button"),callback_data ="admin_action:view_banned:0") - builder .button (text =_ (key ="admin_view_logs_menu_button"),callback_data ="admin_action:view_logs_menu") - builder .button (text =_ (key ="admin_sync_panel_button"),callback_data ="admin_action:sync_panel") - builder .adjust (2 ,2 ,2 ,2 ,1 ) - return builder .as_markup () -def get_logs_menu_keyboard (i18n_instance ,lang :str )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () - builder .button (text =_ (key ="admin_view_all_logs_button"),callback_data ="admin_logs:view_all:0") - builder .button (text =_ (key ="admin_view_user_logs_prompt_button"),callback_data ="admin_logs:prompt_user") - builder .row (InlineKeyboardButton (text =_ (key ="back_to_admin_panel_button"),callback_data ="admin_action:main")) - return builder .as_markup () +def get_admin_panel_keyboard(i18n_instance, lang: str, + settings: Settings) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="admin_stats_button"), + callback_data="admin_action:stats") + builder.button(text=_(key="admin_broadcast_button"), + callback_data="admin_action:broadcast") + builder.button(text=_(key="admin_create_promo_button"), + callback_data="admin_action:create_promo") + builder.button(text=_(key="admin_view_promos_button"), + callback_data="admin_action:view_promos") + builder.button(text=_(key="admin_ban_user_button"), + callback_data="admin_action:ban_user_prompt") + builder.button(text=_(key="admin_unban_user_button"), + callback_data="admin_action:unban_user_prompt") + builder.button(text=_(key="admin_view_banned_users_button"), + callback_data="admin_action:view_banned:0") + builder.button(text=_(key="admin_view_logs_menu_button"), + callback_data="admin_action:view_logs_menu") + builder.button(text=_(key="admin_sync_panel_button"), + callback_data="admin_action:sync_panel") + builder.adjust(2, 2, 2, 2, 1) + return builder.as_markup() -def get_logs_pagination_keyboard (current_page :int ,total_pages :int ,base_callback_data :str ,i18n_instance ,lang :str ,back_to_logs_menu :bool =False )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () - row_buttons =[] - if current_page >0 : - row_buttons .append (InlineKeyboardButton (text ="⬅️ "+_ ("prev_page_button",default ="Prev"),callback_data =f"{base_callback_data}:{current_page - 1}")) - if current_page InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="admin_view_all_logs_button"), + callback_data="admin_logs:view_all:0") + builder.button(text=_(key="admin_view_user_logs_prompt_button"), + callback_data="admin_logs:prompt_user") + builder.row( + InlineKeyboardButton(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main")) + return builder.as_markup() - if back_to_logs_menu : - builder .row (InlineKeyboardButton (text =_ (key ="admin_logs_menu_title"),callback_data ="admin_action:view_logs_menu")) - else : - builder .row (InlineKeyboardButton (text =_ (key ="back_to_admin_panel_button"),callback_data ="admin_action:main")) - return builder .as_markup () -def get_banned_users_keyboard (users :List [Any ],current_page :int ,total_users_banned :int ,i18n_instance ,lang :str ,settings :Settings )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs );builder =InlineKeyboardBuilder () - page_size =settings .LOGS_PAGE_SIZE - total_pages =math .ceil (total_users_banned /page_size )if page_size >0 else 1 - if total_users_banned ==0 :total_pages =1 - for user_row in users : - user_display =user_row ['first_name']or f"ID: {user_row['user_id']}"; - if user_row ['username']:user_display +=f" (@{user_row['username']})" - else :user_display =f"ID: {user_row['user_id']}"if not user_row ['first_name']else user_display - builder .button (text =user_display ,callback_data =f"admin_user_card:{user_row['user_id']}:{current_page}") - pagination_row =[] - if current_page >0 :pagination_row .append (InlineKeyboardButton (text ="⬅️ "+_ ("prev_page_button",default ="Prev"),callback_data =f"admin_action:view_banned:{current_page - 1}")) - if (current_page +1 )*page_size InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + row_buttons = [] + if current_page > 0: + row_buttons.append( + InlineKeyboardButton( + text="⬅️ " + _("prev_page_button", default="Prev"), + callback_data=f"{base_callback_data}:{current_page - 1}")) + if current_page < total_pages - 1: + row_buttons.append( + InlineKeyboardButton( + text=_("next_page_button", default="Next") + " ➡️", + callback_data=f"{base_callback_data}:{current_page + 1}")) -def get_user_card_keyboard (user_id :int ,is_banned :bool ,i18n_instance ,lang :str ,banned_list_page :int =0 )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs );builder =InlineKeyboardBuilder () - if is_banned :builder .button (text =_ (key ="user_card_unban_button"),callback_data =f"admin_unban_confirm:{user_id}:{banned_list_page}") - else :builder .button (text =_ (key ="user_card_ban_button"),callback_data =f"admin_ban_confirm:{user_id}:{banned_list_page}") - builder .button (text =_ (key ="user_card_back_to_banned_list_button"),callback_data =f"admin_action:view_banned:{banned_list_page}") - builder .button (text =_ (key ="back_to_admin_panel_button"),callback_data ="admin_action:main");builder .adjust (1 );return builder .as_markup () + if row_buttons: builder.row(*row_buttons) -def get_confirmation_keyboard (yes_callback_data :str ,no_callback_data :str ,i18n_instance ,lang :str )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs );builder =InlineKeyboardBuilder () - builder .button (text =_ (key ="yes_button"),callback_data =yes_callback_data );builder .button (text =_ (key ="no_button"),callback_data =no_callback_data );return builder .as_markup () + if back_to_logs_menu: + builder.row( + InlineKeyboardButton(text=_(key="admin_logs_menu_title"), + callback_data="admin_action:view_logs_menu")) + else: + builder.row( + InlineKeyboardButton(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main")) + return builder.as_markup() -def get_broadcast_confirmation_keyboard (lang :str ,i18n_instance )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs );builder =InlineKeyboardBuilder () - builder .button (text =_ (key ="confirm_broadcast_send_button"),callback_data ="broadcast_final_action:send") - builder .button (text =_ (key ="cancel_broadcast_button"),callback_data ="broadcast_final_action:cancel");builder .adjust (2 );return builder .as_markup () -def get_back_to_admin_panel_keyboard (lang :str ,i18n_instance )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs );builder =InlineKeyboardBuilder () - builder .button (text =_ (key ="back_to_admin_panel_button"),callback_data ="admin_action:main");return builder .as_markup () \ No newline at end of file +def get_banned_users_keyboard(users: List[Any], current_page: int, + total_users_banned: int, i18n_instance, + lang: str, + settings: Settings) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + page_size = settings.LOGS_PAGE_SIZE + total_pages = math.ceil(total_users_banned / + page_size) if page_size > 0 else 1 + if total_users_banned == 0: total_pages = 1 + for user_row in users: + user_display = user_row['first_name'] or f"ID: {user_row['user_id']}" + if user_row['username']: user_display += f" (@{user_row['username']})" + else: + user_display = f"ID: {user_row['user_id']}" if not user_row[ + 'first_name'] else user_display + builder.button(text=user_display, + callback_data= + f"admin_user_card:{user_row['user_id']}:{current_page}") + pagination_row = [] + if current_page > 0: + pagination_row.append( + InlineKeyboardButton( + text="⬅️ " + _("prev_page_button", default="Prev"), + callback_data=f"admin_action:view_banned:{current_page - 1}")) + if (current_page + 1) * page_size < total_users_banned: + pagination_row.append( + InlineKeyboardButton( + text=_("next_page_button", default="Next") + " ➡️", + callback_data=f"admin_action:view_banned:{current_page + 1}")) + if pagination_row: builder.row(*pagination_row) + builder.row( + InlineKeyboardButton(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main")) + builder.adjust(1) + return builder.as_markup() + + +def get_user_card_keyboard(user_id: int, + is_banned: bool, + i18n_instance, + lang: str, + banned_list_page: int = 0) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + if is_banned: + builder.button( + text=_(key="user_card_unban_button"), + callback_data=f"admin_unban_confirm:{user_id}:{banned_list_page}") + else: + builder.button( + text=_(key="user_card_ban_button"), + callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}") + builder.button( + text=_(key="user_card_back_to_banned_list_button"), + callback_data=f"admin_action:view_banned:{banned_list_page}") + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(1) + return builder.as_markup() + + +def get_confirmation_keyboard(yes_callback_data: str, no_callback_data: str, + i18n_instance, + lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="yes_button"), callback_data=yes_callback_data) + builder.button(text=_(key="no_button"), callback_data=no_callback_data) + return builder.as_markup() + + +def get_broadcast_confirmation_keyboard(lang: str, + i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="confirm_broadcast_send_button"), + callback_data="broadcast_final_action:send") + builder.button(text=_(key="cancel_broadcast_button"), + callback_data="broadcast_final_action:cancel") + builder.adjust(2) + return builder.as_markup() + + +def get_back_to_admin_panel_keyboard(lang: str, + i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + return builder.as_markup() diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 56b9d1e..6451ca2 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -1,119 +1,159 @@ -from aiogram .utils .keyboard import InlineKeyboardBuilder ,InlineKeyboardButton -from aiogram .types import InlineKeyboardMarkup -from typing import Dict ,Optional ,List +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from aiogram.types import InlineKeyboardMarkup +from typing import Dict, Optional, List -from config .settings import Settings +from config.settings import Settings -def get_main_menu_inline_keyboard ( -lang :str , -i18n_instance , -settings :Settings , -show_trial_button :bool =False -)->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () +def get_main_menu_inline_keyboard( + lang: str, + i18n_instance, + settings: Settings, + show_trial_button: bool = False) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + if show_trial_button and settings.TRIAL_ENABLED: + builder.row( + InlineKeyboardButton(text=_(key="menu_activate_trial_button"), + callback_data="main_action:request_trial")) + + builder.row( + InlineKeyboardButton(text=_(key="menu_subscribe_inline"), + callback_data="main_action:subscribe")) + + builder.row( + InlineKeyboardButton(text=_(key="menu_my_subscription_inline"), + callback_data="main_action:my_subscription")) + + referral_button = InlineKeyboardButton( + text=_(key="menu_referral_inline"), + callback_data="main_action:referral") + promo_button = InlineKeyboardButton( + text=_(key="menu_apply_promo_button"), + callback_data="main_action:apply_promo") + builder.row(referral_button, promo_button) + + language_button = InlineKeyboardButton( + text=_(key="menu_language_settings_inline"), + callback_data="main_action:language") + status_button_list = [] + if settings.SERVER_STATUS_URL: + status_button_list.append( + InlineKeyboardButton(text=_(key="menu_server_status_button"), + url=settings.SERVER_STATUS_URL)) + + if status_button_list: + builder.row(language_button, *status_button_list) + else: + builder.row(language_button) + + if settings.SUPPORT_LINK: + builder.row( + InlineKeyboardButton(text=_(key="menu_support_button"), + url=settings.SUPPORT_LINK)) + + return builder.as_markup() - if show_trial_button and settings .TRIAL_ENABLED : - builder .row (InlineKeyboardButton (text =_ (key ="menu_activate_trial_button"),callback_data ="main_action:request_trial")) +def get_language_selection_keyboard(i18n_instance, + current_lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs + ) + builder = InlineKeyboardBuilder() + builder.button(text=f"🇬🇧 English {'✅' if current_lang == 'en' else ''}", + callback_data="set_lang_en") + builder.button(text=f"🇷🇺 Русский {'✅' if current_lang == 'ru' else ''}", + callback_data="set_lang_ru") + builder.button(text=_(key="back_to_main_menu_button"), + callback_data="main_action:back_to_main") + builder.adjust(1) + return builder.as_markup() - builder .row (InlineKeyboardButton (text =_ (key ="menu_subscribe_inline"),callback_data ="main_action:subscribe")) +def get_trial_confirmation_keyboard(lang: str, + i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="trial_confirm_activate_button"), + callback_data="trial_action:confirm_activate") + builder.button(text=_(key="cancel_button"), + callback_data="main_action:back_to_main") + builder.adjust(1) + return builder.as_markup() - builder .row (InlineKeyboardButton (text =_ (key ="menu_my_subscription_inline"),callback_data ="main_action:my_subscription")) +def get_subscription_options_keyboard(subscription_options: Dict[ + int, Optional[int]], currency_symbol_val: str, lang: str, + i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + if subscription_options: + for months, price in subscription_options.items(): + if price is not None: + button_text = _("subscribe_for_months_button", + months=months, + price=price, + currency_symbol=currency_symbol_val) + builder.button(text=button_text, + callback_data=f"subscribe_period:{months}") + builder.adjust(1) + builder.row( + InlineKeyboardButton(text=_(key="back_to_main_menu_button"), + callback_data="main_action:back_to_main")) + return builder.as_markup() - referral_button =InlineKeyboardButton (text =_ (key ="menu_referral_inline"),callback_data ="main_action:referral") - promo_button =InlineKeyboardButton (text =_ (key ="menu_apply_promo_button"),callback_data ="main_action:apply_promo") - builder .row (referral_button ,promo_button ) +def get_confirm_subscription_keyboard(months: int, price: float, + currency_symbol_val: str, lang: str, + i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + confirm_text = _(key="confirm_payment_button", + price=price, + currency_symbol=currency_symbol_val) + builder.button(text=confirm_text, + callback_data=f"confirm_sub:{months}:{price}") + builder.button(text=_(key="cancel_button"), + callback_data="main_action:subscribe") + builder.adjust(1) + return builder.as_markup() - language_button =InlineKeyboardButton (text =_ (key ="menu_language_settings_inline"),callback_data ="main_action:language") - status_button_list =[] - if settings .SERVER_STATUS_URL : - status_button_list .append (InlineKeyboardButton (text =_ (key ="menu_server_status_button"),url =settings .SERVER_STATUS_URL )) - - if status_button_list : - builder .row (language_button ,*status_button_list ) - else : - builder .row (language_button ) +def get_payment_url_keyboard(payment_url: str, lang: str, + i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="pay_button"), url=payment_url) + builder.button(text=_(key="back_to_main_menu_button"), + callback_data="main_action:back_to_main") + builder.adjust(1) + return builder.as_markup() - if settings .SUPPORT_LINK : - builder .row (InlineKeyboardButton (text =_ (key ="menu_support_button"),url =settings .SUPPORT_LINK )) +def get_referral_link_keyboard(lang: str, + i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="back_to_main_menu_button"), + callback_data="main_action:back_to_main") + return builder.as_markup() +def get_back_to_main_menu_markup(lang: str, + i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="back_to_main_menu_button"), + callback_data="main_action:back_to_main") + return builder.as_markup() - - return builder .as_markup () - - - -def get_language_selection_keyboard (i18n_instance ,current_lang :str )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (current_lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () - builder .button (text =f"🇬🇧 English {'✅' if current_lang == 'en' else ''}",callback_data ="set_lang_en") - builder .button (text =f"🇷🇺 Русский {'✅' if current_lang == 'ru' else ''}",callback_data ="set_lang_ru") - builder .button (text =_ (key ="back_to_main_menu_button"),callback_data ="main_action:back_to_main") - builder .adjust (1 ) - return builder .as_markup () - -def get_trial_confirmation_keyboard (lang :str ,i18n_instance )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () - builder .button (text =_ (key ="trial_confirm_activate_button"),callback_data ="trial_action:confirm_activate") - builder .button (text =_ (key ="cancel_button"),callback_data ="main_action:back_to_main") - builder .adjust (1 ) - return builder .as_markup () - -def get_subscription_options_keyboard (subscription_options :Dict [int ,Optional [int ]],currency_symbol_val :str ,lang :str ,i18n_instance )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () - if subscription_options : - for months ,price in subscription_options .items (): - if price is not None : - button_text =_ ("subscribe_for_months_button",months =months ,price =price ,currency_symbol =currency_symbol_val ) - builder .button (text =button_text ,callback_data =f"subscribe_period:{months}") - builder .adjust (1 ) - builder .row (InlineKeyboardButton (text =_ (key ="back_to_main_menu_button"),callback_data ="main_action:back_to_main")) - return builder .as_markup () - -def get_confirm_subscription_keyboard (months :int ,price :float ,currency_symbol_val :str ,lang :str ,i18n_instance )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () - confirm_text =_ (key ="confirm_payment_button",price =price ,currency_symbol =currency_symbol_val ) - builder .button (text =confirm_text ,callback_data =f"confirm_sub:{months}:{price}") - builder .button (text =_ (key ="cancel_button"),callback_data ="main_action:subscribe") - builder .adjust (1 ) - return builder .as_markup () - -def get_payment_url_keyboard (payment_url :str ,lang :str ,i18n_instance )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () - builder .button (text =_ (key ="pay_button"),url =payment_url ) - builder .button (text =_ (key ="back_to_main_menu_button"),callback_data ="main_action:back_to_main") - builder .adjust (1 ) - return builder .as_markup () - -def get_referral_link_keyboard (lang :str ,i18n_instance )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () - builder .button (text =_ (key ="back_to_main_menu_button"),callback_data ="main_action:back_to_main") - return builder .as_markup () - -def get_back_to_main_menu_markup (lang :str ,i18n_instance )->InlineKeyboardMarkup : - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () - builder .button (text =_ (key ="back_to_main_menu_button"),callback_data ="main_action:back_to_main") - return builder .as_markup () - -def get_user_banned_keyboard (support_link :Optional [str ],lang :str ,i18n_instance )->Optional [InlineKeyboardMarkup ]: - if not support_link : +def get_user_banned_keyboard(support_link: Optional[str], lang: str, + i18n_instance) -> Optional[InlineKeyboardMarkup]: + if not support_link: return None - _ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs ) - builder =InlineKeyboardBuilder () - builder .button (text =_ (key ="contact_support_button"),url =support_link ) - return builder .as_markup () \ No newline at end of file + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="contact_support_button"), url=support_link) + return builder.as_markup() diff --git a/bot/main_bot.py b/bot/main_bot.py index 9641ae9..a171cf2 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -1,171 +1,294 @@ import logging import asyncio -from aiogram import Bot ,Dispatcher ,Router ,F -from aiogram .enums import ParseMode -from aiogram .filters import CommandStart ,Command -from aiogram .client .default import DefaultBotProperties -from aiogram .webhook .aiohttp_server import SimpleRequestHandler ,setup_application -from aiogram .fsm .storage .memory import MemoryStorage +from aiogram import Bot, Dispatcher, Router, F +from aiogram.enums import ParseMode +from aiogram.filters import CommandStart, Command +from aiogram.client.default import DefaultBotProperties +from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application +from aiogram.fsm.storage.memory import MemoryStorage from aiohttp import web -from apscheduler .schedulers .asyncio import AsyncIOScheduler +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from config.settings import Settings, get_settings +from .middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n +from .middlewares.ban_check_middleware import BanCheckMiddleware +from .middlewares.action_logger_middleware import ActionLoggerMiddleware + +from .handlers.user import user_router_aggregate +from .handlers.user import payment as user_payment_webhook_module + +from .handlers.admin import admin_router_aggregate +from .filters.admin_filter import AdminFilter + +from db.database import get_db_connection_manager + +from .services.notification_service import schedule_subscription_notifications +from .services.payment_service import YooKassaService +from .services.panel_api_service import PanelApiService +from .services.subscription_service import SubscriptionService +from .services.referral_service import ReferralService +from .services.promo_code_service import PromoCodeService -from config .settings import Settings ,get_settings -from .middlewares .i18n import I18nMiddleware ,get_i18n_instance ,JsonI18n -from .middlewares .ban_check_middleware import BanCheckMiddleware -from .middlewares .action_logger_middleware import ActionLoggerMiddleware - -from .handlers .user import user_router_aggregate -from .handlers .user import payment as user_payment_webhook_module - -from .handlers .admin import admin_router_aggregate -from .filters .admin_filter import AdminFilter - -from db .database import get_db_connection_manager - -from .services .notification_service import schedule_subscription_notifications -from .services .payment_service import YooKassaService -from .services .panel_api_service import PanelApiService -from .services .subscription_service import SubscriptionService -from .services .referral_service import ReferralService -from .services .promo_code_service import PromoCodeService +async def register_all_routers(dp: Dispatcher, settings: Settings): + dp.include_router(user_router_aggregate) + admin_filtered_router_wrapper = Router( + name="admin_filtered_router_wrapper") + admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS) + admin_filtered_router_wrapper.message.filter(admin_filter_instance) + admin_filtered_router_wrapper.callback_query.filter(admin_filter_instance) + admin_filtered_router_wrapper.include_router(admin_router_aggregate) + dp.include_router(admin_filtered_router_wrapper) + logging.info("All application routers registered.") -async def register_all_routers (dp :Dispatcher ,settings :Settings ): - dp .include_router (user_router_aggregate ) - admin_filtered_router_wrapper =Router (name ="admin_filtered_router_wrapper") - admin_filter_instance =AdminFilter (admin_ids =settings .ADMIN_IDS ) - admin_filtered_router_wrapper .message .filter (admin_filter_instance ) - admin_filtered_router_wrapper .callback_query .filter (admin_filter_instance ) - admin_filtered_router_wrapper .include_router (admin_router_aggregate ) - dp .include_router (admin_filtered_router_wrapper ) - logging .info ("All application routers registered.") +async def on_startup_configured(dispatcher: Dispatcher): -async def on_startup_configured (dispatcher :Dispatcher ): - - bot :Bot =dispatcher ["bot_instance"];settings :Settings =dispatcher ["settings"];i18n_instance :JsonI18n =dispatcher ["i18n_instance"] - logging .info ("STARTUP: on_startup_configured executing...") - scheduler =AsyncIOScheduler (timezone ="UTC") - try :await schedule_subscription_notifications (bot ,settings ,i18n_instance ,scheduler );scheduler .start ();dispatcher ["scheduler"]=scheduler ;logging .info ("STARTUP: APScheduler started.") - except Exception as e :logging .error (f"STARTUP: Failed to start APScheduler: {e}",exc_info =True ) - telegram_webhook_url_to_set =getattr (settings ,'TELEGRAM_WEBHOOK_BASE_URL',None ) - if telegram_webhook_url_to_set : - if settings .BOT_TOKEN in telegram_webhook_url_to_set :logging .error (f"CRITICAL: Bot token in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}').");full_telegram_webhook_url ="ERROR_URL" - else :full_telegram_webhook_url =f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}" - logging .info (f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url}") - try : - current_webhook_info_before =await bot .get_webhook_info ();logging .info (f"STARTUP: Current webhook info BEFORE: {current_webhook_info_before.model_dump_json(exclude_none=True, indent=2)}") - if full_telegram_webhook_url !="ERROR_URL": - set_success =await bot .set_webhook (url =full_telegram_webhook_url ,drop_pending_updates =True ,allowed_updates =dispatcher .resolve_used_update_types ()) - if set_success :logging .info (f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True).") - else :logging .error (f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False).") - new_webhook_info =await bot .get_webhook_info ();logging .info (f"STARTUP: Webhook info AFTER: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}") - if not new_webhook_info .url :logging .error ("STARTUP: CRITICAL - Webhook URL EMPTY after set attempt.") - else :logging .error ("STARTUP: Skipped setting webhook due to URL config error.") - except Exception as e_setwebhook :logging .error (f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",exc_info =True ) - else : - logging .info ("STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set. Attempting to delete webhook.");await bot .delete_webhook (drop_pending_updates =True ) - logging .info ("STARTUP: Bot on_startup_configured completed.") + bot: Bot = dispatcher["bot_instance"] + settings: Settings = dispatcher["settings"] + i18n_instance: JsonI18n = dispatcher["i18n_instance"] + logging.info("STARTUP: on_startup_configured executing...") + scheduler = AsyncIOScheduler(timezone="UTC") + try: + await schedule_subscription_notifications(bot, settings, i18n_instance, + scheduler) + scheduler.start() + dispatcher["scheduler"] = scheduler + logging.info("STARTUP: APScheduler started.") + except Exception as e: + logging.error(f"STARTUP: Failed to start APScheduler: {e}", + exc_info=True) + telegram_webhook_url_to_set = getattr(settings, + 'TELEGRAM_WEBHOOK_BASE_URL', None) + if telegram_webhook_url_to_set: + if settings.BOT_TOKEN in telegram_webhook_url_to_set: + logging.error( + f"CRITICAL: Bot token in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}')." + ) + full_telegram_webhook_url = "ERROR_URL" + else: + full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}" + logging.info( + f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url}" + ) + try: + current_webhook_info_before = await bot.get_webhook_info() + logging.info( + f"STARTUP: Current webhook info BEFORE: {current_webhook_info_before.model_dump_json(exclude_none=True, indent=2)}" + ) + if full_telegram_webhook_url != "ERROR_URL": + set_success = await bot.set_webhook( + url=full_telegram_webhook_url, + drop_pending_updates=True, + allowed_updates=dispatcher.resolve_used_update_types()) + if set_success: + logging.info( + f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)." + ) + else: + logging.error( + f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False)." + ) + new_webhook_info = await bot.get_webhook_info() + logging.info( + f"STARTUP: Webhook info AFTER: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}" + ) + if not new_webhook_info.url: + logging.error( + "STARTUP: CRITICAL - Webhook URL EMPTY after set attempt." + ) + else: + logging.error( + "STARTUP: Skipped setting webhook due to URL config error." + ) + except Exception as e_setwebhook: + logging.error( + f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}", + exc_info=True) + else: + logging.info( + "STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set. Attempting to delete webhook." + ) + await bot.delete_webhook(drop_pending_updates=True) + logging.info("STARTUP: Bot on_startup_configured completed.") -async def on_shutdown_configured (dispatcher :Dispatcher ): +async def on_shutdown_configured(dispatcher: Dispatcher): - logging .warning ("SHUTDOWN: on_shutdown_configured executing...");scheduler :AsyncIOScheduler =dispatcher .get ("scheduler") - if scheduler and scheduler .running : - try :scheduler .shutdown (wait =False );logging .info ("SHUTDOWN: APScheduler shut down.") - except Exception as e :logging .error (f"SHUTDOWN: Error APScheduler: {e}",exc_info =True ) - panel_service :PanelApiService =dispatcher .get ("panel_service") - if panel_service and panel_service ._session and not panel_service ._session .closed :await panel_service .close_session () - bot :Bot =dispatcher .get ("bot_instance") - if bot and bot .session and not bot .session .closed :await bot .session .close () - logging .info ("SHUTDOWN: Bot on_shutdown_configured completed.") + logging.warning("SHUTDOWN: on_shutdown_configured executing...") + scheduler: AsyncIOScheduler = dispatcher.get("scheduler") + if scheduler and scheduler.running: + try: + scheduler.shutdown(wait=False) + logging.info("SHUTDOWN: APScheduler shut down.") + except Exception as e: + logging.error(f"SHUTDOWN: Error APScheduler: {e}", exc_info=True) + panel_service: PanelApiService = dispatcher.get("panel_service") + if panel_service and panel_service._session and not panel_service._session.closed: + await panel_service.close_session() + bot: Bot = dispatcher.get("bot_instance") + if bot and bot.session and not bot.session.closed: + await bot.session.close() + logging.info("SHUTDOWN: Bot on_shutdown_configured completed.") -async def run_bot (settings_param :Settings ): - storage =MemoryStorage () - default_props =DefaultBotProperties (parse_mode =ParseMode .HTML ) - bot =Bot (token =settings_param .BOT_TOKEN ,default =default_props ) +async def run_bot(settings_param: Settings): + storage = MemoryStorage() + default_props = DefaultBotProperties(parse_mode=ParseMode.HTML) + bot = Bot(token=settings_param.BOT_TOKEN, default=default_props) - dp =Dispatcher (storage =storage ,settings =settings_param ,bot_instance =bot ) + dp = Dispatcher(storage=storage, settings=settings_param, bot_instance=bot) - actual_bot_username ="your_bot_username" - try : - bot_info =await bot .get_me () - actual_bot_username =bot_info .username - logging .info (f"Bot username: @{actual_bot_username}") - except Exception as e : - logging .error (f"Failed to get bot info: {e}. Using fallback username: {actual_bot_username}") + actual_bot_username = "your_bot_username" + try: + bot_info = await bot.get_me() + actual_bot_username = bot_info.username + logging.info(f"Bot username: @{actual_bot_username}") + except Exception as e: + logging.error( + f"Failed to get bot info: {e}. Using fallback username: {actual_bot_username}" + ) - default_lang =settings_param .DEFAULT_LANGUAGE - i18n_instance =get_i18n_instance (path ="locales",default =default_lang ) + default_lang = settings_param.DEFAULT_LANGUAGE + i18n_instance = get_i18n_instance(path="locales", default=default_lang) + yookassa_service = YooKassaService( + shop_id=settings_param.YOOKASSA_SHOP_ID, + secret_key=settings_param.YOOKASSA_SECRET_KEY, + configured_return_url=settings_param.YOOKASSA_RETURN_URL, + bot_username_for_default=actual_bot_username, + settings_obj=settings_param) - yookassa_service =YooKassaService ( - shop_id =settings_param .YOOKASSA_SHOP_ID , - secret_key =settings_param .YOOKASSA_SECRET_KEY , - configured_return_url =settings_param .YOOKASSA_RETURN_URL , - bot_username_for_default =actual_bot_username , - settings_obj =settings_param + panel_service = PanelApiService(settings_param) + subscription_service = SubscriptionService(get_db_connection_manager, + settings_param, panel_service) + referral_service = ReferralService(get_db_connection_manager, + settings_param, subscription_service, + bot, i18n_instance) + promo_code_service = PromoCodeService(get_db_connection_manager, + settings_param, subscription_service, + bot, i18n_instance) + + dp["i18n_instance"] = i18n_instance + dp["i18n_data"] = { + "i18n_instance": i18n_instance, + "current_language": default_lang + } + dp["yookassa_service"] = yookassa_service + dp["panel_service"] = panel_service + dp["subscription_service"] = subscription_service + dp["referral_service"] = referral_service + dp["promo_code_service"] = promo_code_service + + dp.update.outer_middleware( + I18nMiddleware(i18n=i18n_instance, settings=settings_param)) + dp.update.outer_middleware( + BanCheckMiddleware(settings=settings_param, + i18n_instance=i18n_instance)) + dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings_param)) + + dp.startup.register(on_startup_configured) + dp.shutdown.register(on_shutdown_configured) + await register_all_routers(dp, settings_param) + + tg_webhook_base = getattr(settings_param, 'TELEGRAM_WEBHOOK_BASE_URL', + None) + yk_webhook_base = getattr(settings_param, 'YOOKASSA_WEBHOOK_BASE_URL', + None) + logging.info(f"--- Determining Run Mode ---") + logging.info(f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}'") + logging.info(f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}'") + should_run_aiohttp = bool( + yk_webhook_base + and settings_param.yookassa_webhook_path) or bool(tg_webhook_base) + telegram_uses_webhook = bool(tg_webhook_base) + telegram_should_poll = not telegram_uses_webhook + logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp}") + logging.info(f"Decision: Telegram uses webhook: {telegram_uses_webhook}") + logging.info(f"Decision: Telegram should poll: {telegram_should_poll}") + logging.info(f"--- End Run Mode Decision ---") + web_app_runner = None + main_tasks_to_await = [] + if should_run_aiohttp: + app = web.Application() + app['bot'] = bot + app['dp'] = dp + app['settings'] = settings_param + app['i18n'] = i18n_instance + app['yookassa_service'] = yookassa_service + app['panel_service'] = panel_service + app['subscription_service'] = subscription_service + app['referral_service'] = referral_service + app['promo_code_service'] = promo_code_service + setup_application(app, dp, bot=bot) + logging.info( + "AIOHTTP app context populated and dispatcher lifecycle linked.") + if telegram_uses_webhook: + telegram_webhook_path = f"/{settings_param.BOT_TOKEN}" + app.router.add_post(telegram_webhook_path, + SimpleRequestHandler(dispatcher=dp, bot=bot)) + logging.info(f"Telegram webhook route: {telegram_webhook_path}.") + if yk_webhook_base and settings_param.yookassa_webhook_path: + app.router.add_post( + settings_param.yookassa_webhook_path, + user_payment_webhook_module.yookassa_webhook_route) + logging.info( + f"YK webhook route: {settings_param.yookassa_webhook_path}") + web_app_runner = web.AppRunner(app) + await web_app_runner.setup() + site = web.TCPSite(web_app_runner, + host=settings_param.WEB_SERVER_HOST, + port=settings_param.WEB_SERVER_PORT) + + async def web_server_task_wrapper(): + await site.start() + logging.info( + f"AIOHTTP server started on {settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}." + ) + await asyncio.Event().wait( + ) if not telegram_should_poll else await asyncio.sleep(31536000) + + main_tasks_to_await.append( + asyncio.create_task(web_server_task_wrapper(), + name="AIOHTTPServerWrapperTask")) + if telegram_should_poll: + logging.info("TG polling task created.") + main_tasks_to_await.append( + asyncio.create_task(dp.start_polling( + bot, allowed_updates=dp.resolve_used_update_types()), + name="TelegramPollingTask")) + if not main_tasks_to_await: + logging.error("Bot not configured for any mode.") + await dp.emit_shutdown() + return + logging.info( + f"Starting bot with main tasks: {[task.get_name() for task in main_tasks_to_await]}" ) - - panel_service =PanelApiService (settings_param ) - subscription_service =SubscriptionService (get_db_connection_manager ,settings_param ,panel_service ) - referral_service =ReferralService (get_db_connection_manager ,settings_param ,subscription_service ,bot ,i18n_instance ) - promo_code_service =PromoCodeService (get_db_connection_manager ,settings_param ,subscription_service ,bot ,i18n_instance ) - - - dp ["i18n_instance"]=i18n_instance - dp ["i18n_data"]={"i18n_instance":i18n_instance ,"current_language":default_lang } - dp ["yookassa_service"]=yookassa_service - dp ["panel_service"]=panel_service - dp ["subscription_service"]=subscription_service - dp ["referral_service"]=referral_service - dp ["promo_code_service"]=promo_code_service - - - - dp .update .outer_middleware (I18nMiddleware (i18n =i18n_instance ,settings =settings_param )) - dp .update .outer_middleware (BanCheckMiddleware (settings =settings_param ,i18n_instance =i18n_instance )) - dp .update .outer_middleware (ActionLoggerMiddleware (settings =settings_param )) - - dp .startup .register (on_startup_configured ) - dp .shutdown .register (on_shutdown_configured ) - await register_all_routers (dp ,settings_param ) - - - tg_webhook_base =getattr (settings_param ,'TELEGRAM_WEBHOOK_BASE_URL',None );yk_webhook_base =getattr (settings_param ,'YOOKASSA_WEBHOOK_BASE_URL',None ) - logging .info (f"--- Determining Run Mode ---");logging .info (f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}'");logging .info (f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}'") - should_run_aiohttp =bool (yk_webhook_base and settings_param .yookassa_webhook_path )or bool (tg_webhook_base ) - telegram_uses_webhook =bool (tg_webhook_base );telegram_should_poll =not telegram_uses_webhook - logging .info (f"Decision: Run AIOHTTP server: {should_run_aiohttp}");logging .info (f"Decision: Telegram uses webhook: {telegram_uses_webhook}");logging .info (f"Decision: Telegram should poll: {telegram_should_poll}");logging .info (f"--- End Run Mode Decision ---") - web_app_runner =None ;main_tasks_to_await =[] - if should_run_aiohttp : - app =web .Application ();app ['bot']=bot ;app ['dp']=dp ;app ['settings']=settings_param ;app ['i18n']=i18n_instance - app ['yookassa_service']=yookassa_service ;app ['panel_service']=panel_service ;app ['subscription_service']=subscription_service - app ['referral_service']=referral_service ;app ['promo_code_service']=promo_code_service - setup_application (app ,dp ,bot =bot );logging .info ("AIOHTTP app context populated and dispatcher lifecycle linked.") - if telegram_uses_webhook :telegram_webhook_path =f"/{settings_param.BOT_TOKEN}";app .router .add_post (telegram_webhook_path ,SimpleRequestHandler (dispatcher =dp ,bot =bot ));logging .info (f"Telegram webhook route: {telegram_webhook_path}.") - if yk_webhook_base and settings_param .yookassa_webhook_path :app .router .add_post (settings_param .yookassa_webhook_path ,user_payment_webhook_module .yookassa_webhook_route );logging .info (f"YK webhook route: {settings_param.yookassa_webhook_path}") - web_app_runner =web .AppRunner (app );await web_app_runner .setup ();site =web .TCPSite (web_app_runner ,host =settings_param .WEB_SERVER_HOST ,port =settings_param .WEB_SERVER_PORT ) - async def web_server_task_wrapper ():await site .start ();logging .info (f"AIOHTTP server started on {settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}.");await asyncio .Event ().wait ()if not telegram_should_poll else await asyncio .sleep (31536000 ) - main_tasks_to_await .append (asyncio .create_task (web_server_task_wrapper (),name ="AIOHTTPServerWrapperTask")) - if telegram_should_poll :logging .info ("TG polling task created.");main_tasks_to_await .append (asyncio .create_task (dp .start_polling (bot ,allowed_updates =dp .resolve_used_update_types ()),name ="TelegramPollingTask")) - if not main_tasks_to_await :logging .error ("Bot not configured for any mode.");await dp .emit_shutdown ();return - logging .info (f"Starting bot with main tasks: {[task.get_name() for task in main_tasks_to_await]}") - try : - if main_tasks_to_await :await asyncio .gather (*main_tasks_to_await ) - except (KeyboardInterrupt ,SystemExit ,asyncio .CancelledError )as e :logging .info (f"Main loop interrupted: {type(e).__name__}") - finally : - logging .info ("Initiating final shutdown sequence..."); - for task in main_tasks_to_await : - if task and not task .done ():task .cancel (); - try :await task - except asyncio .CancelledError :logging .info (f"Task '{task.get_name()}' cancelled.") - except Exception as e_cancel :logging .error (f"Error cancelling task '{task.get_name()}': {e_cancel}",exc_info =True ) - if web_app_runner :await web_app_runner .cleanup ();logging .info ("AIOHTTP AppRunner cleaned up.") - if not (telegram_should_poll and len (main_tasks_to_await )==1 and any (t .get_name ()=="TelegramPollingTask"for t in main_tasks_to_await if t and not t .done ())): - logging .info ("Explicitly calling dp.emit_shutdown() in run_bot finally.") - await dp .emit_shutdown () - if bot .session and not bot .session .closed :await bot .session .close () - logging .info ("Bot run_bot function finished.") \ No newline at end of file + try: + if main_tasks_to_await: await asyncio.gather(*main_tasks_to_await) + except (KeyboardInterrupt, SystemExit, asyncio.CancelledError) as e: + logging.info(f"Main loop interrupted: {type(e).__name__}") + finally: + logging.info("Initiating final shutdown sequence...") + for task in main_tasks_to_await: + if task and not task.done(): task.cancel() + try: + await task + except asyncio.CancelledError: + logging.info(f"Task '{task.get_name()}' cancelled.") + except Exception as e_cancel: + logging.error( + f"Error cancelling task '{task.get_name()}': {e_cancel}", + exc_info=True) + if web_app_runner: + await web_app_runner.cleanup() + logging.info("AIOHTTP AppRunner cleaned up.") + if not (telegram_should_poll and len(main_tasks_to_await) == 1 + and any(t.get_name() == "TelegramPollingTask" + for t in main_tasks_to_await if t and not t.done())): + logging.info( + "Explicitly calling dp.emit_shutdown() in run_bot finally.") + await dp.emit_shutdown() + if bot.session and not bot.session.closed: await bot.session.close() + logging.info("Bot run_bot function finished.") diff --git a/bot/middlewares/action_logger_middleware.py b/bot/middlewares/action_logger_middleware.py index 28d255e..ee75b2e 100644 --- a/bot/middlewares/action_logger_middleware.py +++ b/bot/middlewares/action_logger_middleware.py @@ -1,94 +1,90 @@ import logging import json -from typing import Callable ,Dict ,Any ,Awaitable ,Union ,Optional +from typing import Callable, Dict, Any, Awaitable, Union, Optional from aiogram import BaseMiddleware -from aiogram .types import Update ,Message ,CallbackQuery ,User +from aiogram.types import Update, Message, CallbackQuery, User -from db .database import log_user_action -from config .settings import Settings - -class ActionLoggerMiddleware (BaseMiddleware ): - def __init__ (self ,settings :Settings ): - super ().__init__ () - self .settings =settings - - async def __call__ ( - self , - handler :Callable [[Update ,Dict [str ,Any ]],Awaitable [Any ]], - event :Update , - data :Dict [str ,Any ] - )->Any : - - event_user :Optional [User ]=data .get ("event_from_user") - bot :Optional [Bot ]=data .get ("bot") - - user_id :Optional [int ]=None - telegram_username :Optional [str ]=None - telegram_first_name :Optional [str ]=None - event_type :str =event .event_type - content :Optional [str ]=None - is_admin_event_flag :bool =False - - if event_user : - user_id =event_user .id - telegram_username =event_user .username - telegram_first_name =event_user .first_name - if user_id ==self .settings .ADMIN_IDS : - is_admin_event_flag =True - - raw_update_snippet =None - try : - - raw_update_snippet =event .model_dump_json (exclude_none =True ,indent =None )[:1000 ] - except Exception : - raw_update_snippet =str (event )[:1000 ] +from db.database import log_user_action +from config.settings import Settings - if event .message : - msg =event .message - if msg .text : - content =msg .text - if msg .text .startswith ('/'): - event_type ="command" - elif msg .caption : - content =f"[{msg.content_type}] {msg.caption}" - else : - content =f"[{msg.content_type}]" +class ActionLoggerMiddleware(BaseMiddleware): - elif event .callback_query : - cb =event .callback_query - event_type ="callback_query" - content =cb .data + def __init__(self, settings: Settings): + super().__init__() + self.settings = settings + async def __call__(self, handler: Callable[[Update, Dict[str, Any]], + Awaitable[Any]], event: Update, + data: Dict[str, Any]) -> Any: + event_user: Optional[User] = data.get("event_from_user") + bot: Optional[Bot] = data.get("bot") + user_id: Optional[int] = None + telegram_username: Optional[str] = None + telegram_first_name: Optional[str] = None + event_type: str = event.event_type + content: Optional[str] = None + is_admin_event_flag: bool = False + if event_user: + user_id = event_user.id + telegram_username = event_user.username + telegram_first_name = event_user.first_name + if user_id == self.settings.ADMIN_IDS: + is_admin_event_flag = True + raw_update_snippet = None + try: + raw_update_snippet = event.model_dump_json(exclude_none=True, + indent=None)[:1000] + except Exception: + raw_update_snippet = str(event)[:1000] + if event.message: + msg = event.message + if msg.text: + content = msg.text + if msg.text.startswith('/'): + event_type = "command" + elif msg.caption: + content = f"[{msg.content_type}] {msg.caption}" + else: + content = f"[{msg.content_type}]" + elif event.callback_query: + cb = event.callback_query + event_type = "callback_query" + content = cb.data + if user_id and event_type and content: + try: + await log_user_action(user_id=user_id, + telegram_username=telegram_username, + telegram_first_name=telegram_first_name, + event_type=event_type, + content=content[:1000], + raw_update_preview=raw_update_snippet, + is_admin_event=is_admin_event_flag) + except Exception as e_log: + logging.error( + f"ActionLoggerMiddleware: Failed to log event for user {user_id}: {e_log}", + exc_info=True) + elif user_id and event_type: + try: + await log_user_action(user_id=user_id, + telegram_username=telegram_username, + telegram_first_name=telegram_first_name, + event_type=event_type, + content="N/A", + raw_update_preview=raw_update_snippet, + is_admin_event=is_admin_event_flag) + except Exception as e_log: + logging.error( + f"ActionLoggerMiddleware: Failed to log event (no content) for user {user_id}: {e_log}", + exc_info=True) - if user_id and event_type and content : - try : - await log_user_action ( - user_id =user_id , - telegram_username =telegram_username , - telegram_first_name =telegram_first_name , - event_type =event_type , - content =content [:1000 ], - raw_update_preview =raw_update_snippet , - is_admin_event =is_admin_event_flag - ) - except Exception as e_log : - logging .error (f"ActionLoggerMiddleware: Failed to log event for user {user_id}: {e_log}",exc_info =True ) - elif user_id and event_type : - try : - await log_user_action (user_id =user_id ,telegram_username =telegram_username ,telegram_first_name =telegram_first_name ,event_type =event_type ,content ="N/A",raw_update_preview =raw_update_snippet ,is_admin_event =is_admin_event_flag ) - except Exception as e_log : - logging .error (f"ActionLoggerMiddleware: Failed to log event (no content) for user {user_id}: {e_log}",exc_info =True ) - - - - return await handler (event ,data ) \ No newline at end of file + return await handler(event, data) diff --git a/bot/middlewares/ban_check_middleware.py b/bot/middlewares/ban_check_middleware.py index 1bdc530..625e05f 100644 --- a/bot/middlewares/ban_check_middleware.py +++ b/bot/middlewares/ban_check_middleware.py @@ -1,132 +1,212 @@ import logging -from typing import Callable ,Dict ,Any ,Awaitable ,Union ,Optional +from typing import Callable, Dict, Any, Awaitable, Union, Optional -from aiogram import BaseMiddleware ,Bot -from aiogram .types import Message ,CallbackQuery ,User ,InlineKeyboardMarkup ,Update -from aiogram .utils .keyboard import InlineKeyboardBuilder -from aiogram .exceptions import TelegramAPIError ,TelegramForbiddenError ,TelegramBadRequest ,AiogramError +from aiogram import BaseMiddleware, Bot +from aiogram.types import Message, CallbackQuery, User, InlineKeyboardMarkup, Update +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.exceptions import TelegramAPIError, TelegramForbiddenError, TelegramBadRequest, AiogramError -from config .settings import Settings -from db .database import get_user -from bot .middlewares .i18n import JsonI18n -from bot .keyboards .inline .user_keyboards import get_user_banned_keyboard - -class BanCheckMiddleware (BaseMiddleware ): - def __init__ (self ,settings :Settings ,i18n_instance :JsonI18n ): - super ().__init__ () - self .settings =settings - self .i18n_main_instance =i18n_instance - - async def __call__ ( - self , - handler :Callable [[Update ,Dict [str ,Any ]],Awaitable [Any ]], - event :Update , - data :Dict [str ,Any ] - )->Any : - event_user :Optional [User ]=data .get ("event_from_user") - if not event_user :return await handler (event ,data ) +from config.settings import Settings +from db.database import get_user +from bot.middlewares.i18n import JsonI18n +from bot.keyboards.inline.user_keyboards import get_user_banned_keyboard - if event_user .id in self .settings .ADMIN_IDS : - return await handler (event ,data ) +class BanCheckMiddleware(BaseMiddleware): - try : - db_user_data =await get_user (user_id =event_user .id ) - except Exception as e_db : - logging .error (f"BanCheckMiddleware: DB error fetching user {event_user.id}: {e_db}",exc_info =True ) - return await handler (event ,data ) + def __init__(self, settings: Settings, i18n_instance: JsonI18n): + super().__init__() + self.settings = settings + self.i18n_main_instance = i18n_instance - if db_user_data and db_user_data ['is_banned']==1 : - logging .info (f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access and preparing notification.") + async def __call__(self, handler: Callable[[Update, Dict[str, Any]], + Awaitable[Any]], event: Update, + data: Dict[str, Any]) -> Any: + event_user: Optional[User] = data.get("event_from_user") + if not event_user: return await handler(event, data) - ban_message_text ="You are blocked. Please contact support." - keyboard :Optional [InlineKeyboardMarkup ]=None - current_lang =self .settings .DEFAULT_LANGUAGE - i18n_to_use :Optional [JsonI18n ]=None - actual_event_object :Optional [Union [Message ,CallbackQuery ]]=None + if event_user.id in self.settings.ADMIN_IDS: + return await handler(event, data) + try: + db_user_data = await get_user(user_id=event_user.id) + except Exception as e_db: + logging.error( + f"BanCheckMiddleware: DB error fetching user {event_user.id}: {e_db}", + exc_info=True) + return await handler(event, data) - if event .message : - actual_event_object =event .message - elif event .callback_query : - actual_event_object =event .callback_query + if db_user_data and db_user_data['is_banned'] == 1: + logging.info( + f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access and preparing notification." + ) + ban_message_text = "You are blocked. Please contact support." + keyboard: Optional[InlineKeyboardMarkup] = None + current_lang = self.settings.DEFAULT_LANGUAGE + i18n_to_use: Optional[JsonI18n] = None + actual_event_object: Optional[Union[Message, CallbackQuery]] = None - if not actual_event_object : - logging .warning (f"BanCheck: Could not determine specific event type (Message/CallbackQuery) for banned user {event_user.id} from Update object. Update type: {event.type}") + if event.message: + actual_event_object = event.message + elif event.callback_query: + actual_event_object = event.callback_query - try : - bot_instance :Bot =data ["bot"] - await bot_instance .send_message (event_user .id ,ban_message_text ) - except Exception as e_direct_send : - logging .error (f"BanCheck: Failed to send direct ban message to {event_user.id}: {e_direct_send}") + if not actual_event_object: + logging.warning( + f"BanCheck: Could not determine specific event type (Message/CallbackQuery) for banned user {event_user.id} from Update object. Update type: {event.type}" + ) + + try: + bot_instance: Bot = data["bot"] + await bot_instance.send_message(event_user.id, + ban_message_text) + except Exception as e_direct_send: + logging.error( + f"BanCheck: Failed to send direct ban message to {event_user.id}: {e_direct_send}" + ) return - try : - logging .debug ("BanCheck: [A] Inside main try block for banned user notification.") - i18n_data_from_event =data .get ("i18n_data",{}) - current_lang =i18n_data_from_event .get ("current_language",self .settings .DEFAULT_LANGUAGE ) - i18n_to_use =i18n_data_from_event .get ("i18n_instance") - logging .debug (f"BanCheck: [B] i18n_instance from event_data: {type(i18n_to_use)}. Current lang: {current_lang}") + try: + logging.debug( + "BanCheck: [A] Inside main try block for banned user notification." + ) + i18n_data_from_event = data.get("i18n_data", {}) + current_lang = i18n_data_from_event.get( + "current_language", self.settings.DEFAULT_LANGUAGE) + i18n_to_use = i18n_data_from_event.get("i18n_instance") + logging.debug( + f"BanCheck: [B] i18n_instance from event_data: {type(i18n_to_use)}. Current lang: {current_lang}" + ) - if not i18n_to_use : - i18n_to_use =self .i18n_main_instance - logging .warning (f"BanCheck: [B_fallback] Using fallback i18n instance for banned user {event_user.id}. Type: {type(i18n_to_use)}") + if not i18n_to_use: + i18n_to_use = self.i18n_main_instance + logging.warning( + f"BanCheck: [B_fallback] Using fallback i18n instance for banned user {event_user.id}. Type: {type(i18n_to_use)}" + ) - if i18n_to_use : - _ =lambda k ,**kw :i18n_to_use .gettext (current_lang ,k ,**kw ) - logging .debug ("BanCheck: [D] Attempting to get 'user_is_banned' text.") - ban_message_text =_ ("user_is_banned") - logging .debug (f"BanCheck: [E] Ban message text: '{ban_message_text}'") - if self .settings .SUPPORT_LINK : - logging .debug ("BanCheck: [F] Support link found. Attempting to get user_banned_keyboard.") - keyboard =get_user_banned_keyboard (self .settings .SUPPORT_LINK ,current_lang ,i18n_to_use ) - logging .debug (f"BanCheck: [G] Keyboard created: {keyboard is not None}") - else :logging .debug ("BanCheck: [F_alt] No support link configured.") - else : - logging .error (f"BanCheck: [CRITICAL] No i18n instance for user {event_user.id}. Using hardcoded text.") - if self .settings .SUPPORT_LINK : - kb_temp =InlineKeyboardBuilder ();kb_temp .button (text ="Support",url =self .settings .SUPPORT_LINK );keyboard =kb_temp .as_markup () - logging .debug ("BanCheck: [G_alt] Fallback keyboard created due to no i18n.") + if i18n_to_use: + _ = lambda k, **kw: i18n_to_use.gettext( + current_lang, k, **kw) + logging.debug( + "BanCheck: [D] Attempting to get 'user_is_banned' text." + ) + ban_message_text = _("user_is_banned") + logging.debug( + f"BanCheck: [E] Ban message text: '{ban_message_text}'" + ) + if self.settings.SUPPORT_LINK: + logging.debug( + "BanCheck: [F] Support link found. Attempting to get user_banned_keyboard." + ) + keyboard = get_user_banned_keyboard( + self.settings.SUPPORT_LINK, current_lang, + i18n_to_use) + logging.debug( + f"BanCheck: [G] Keyboard created: {keyboard is not None}" + ) + else: + logging.debug( + "BanCheck: [F_alt] No support link configured.") + else: + logging.error( + f"BanCheck: [CRITICAL] No i18n instance for user {event_user.id}. Using hardcoded text." + ) + if self.settings.SUPPORT_LINK: + kb_temp = InlineKeyboardBuilder() + kb_temp.button(text="Support", + url=self.settings.SUPPORT_LINK) + keyboard = kb_temp.as_markup() + logging.debug( + "BanCheck: [G_alt] Fallback keyboard created due to no i18n." + ) - logging .debug (f"BanCheck: [H] Final pre-send check. Message: '{ban_message_text}', Keyboard: {keyboard is not None}") + logging.debug( + f"BanCheck: [H] Final pre-send check. Message: '{ban_message_text}', Keyboard: {keyboard is not None}" + ) + if isinstance(actual_event_object, Message): + logging.debug( + f"BanCheck: [I_Msg] Attempting actual_event_object.answer for Message to user {event_user.id}" + ) + await actual_event_object.answer(ban_message_text, + reply_markup=keyboard) + logging.info( + f"BanCheck: [J_Msg] Ban notification 'actual_event_object.answer' attempted for user {event_user.id} (Message)." + ) + elif isinstance(actual_event_object, CallbackQuery): + logging.debug( + f"BanCheck: [I_CB] Attempting actual_event_object.answer (alert) for CallbackQuery to user {event_user.id}" + ) + await actual_event_object.answer(ban_message_text, + show_alert=True) + logging.info( + f"BanCheck: [J_CB] Ban alert 'actual_event_object.answer' attempted for user {event_user.id} (CallbackQuery)." + ) - if isinstance (actual_event_object ,Message ): - logging .debug (f"BanCheck: [I_Msg] Attempting actual_event_object.answer for Message to user {event_user.id}") - await actual_event_object .answer (ban_message_text ,reply_markup =keyboard ) - logging .info (f"BanCheck: [J_Msg] Ban notification 'actual_event_object.answer' attempted for user {event_user.id} (Message).") - elif isinstance (actual_event_object ,CallbackQuery ): - logging .debug (f"BanCheck: [I_CB] Attempting actual_event_object.answer (alert) for CallbackQuery to user {event_user.id}") - await actual_event_object .answer (ban_message_text ,show_alert =True ) - logging .info (f"BanCheck: [J_CB] Ban alert 'actual_event_object.answer' attempted for user {event_user.id} (CallbackQuery).") + target_message_obj = actual_event_object.message + if target_message_obj: + target_chat_id = target_message_obj.chat.id + try: + logging.debug( + f"BanCheck: [K_CB_Edit] Attempting target_message_obj.edit_text for user {event_user.id}" + ) + await target_message_obj.edit_text( + ban_message_text, reply_markup=keyboard) + logging.info( + f"BanCheck: [L_CB_Edit] Ban msg 'target_message_obj.edit_text' attempted for user {event_user.id} (Callback)." + ) + except Exception as e_edit: + logging.warning( + f"BanCheck: [M_CB_EditFail] Failed to edit message for banned user {event_user.id}: {type(e_edit).__name__} - {e_edit}. Sending new message." + ) + await actual_event_object.bot.send_message( + target_chat_id, + ban_message_text, + reply_markup=keyboard) + logging.info( + f"BanCheck: [N_CB_NewMsg] Ban msg 'actual_event_object.bot.send_message' (after edit fail) attempted for user {event_user.id} (Callback)." + ) + else: + logging.warning( + f"BanCheck: [K_CB_NoMsg] CallbackQuery from {event_user.id} has no .message attribute. Sending new message directly." + ) + await actual_event_object.bot.send_message( + actual_event_object.from_user.id, + ban_message_text, + reply_markup=keyboard) + logging.info( + f"BanCheck: [L_CB_NoMsg_NewMsg] Ban msg 'actual_event_object.bot.send_message' (no .message) attempted for user {event_user.id} (Callback)." + ) + else: + logging.error( + f"BanCheck: [UNHANDLED_EVENT_TYPE_INTERNAL] actual_event_object type {type(actual_event_object)} was not Message or CallbackQuery." + ) - target_message_obj =actual_event_object .message - if target_message_obj : - target_chat_id =target_message_obj .chat .id - try : - logging .debug (f"BanCheck: [K_CB_Edit] Attempting target_message_obj.edit_text for user {event_user.id}") - await target_message_obj .edit_text (ban_message_text ,reply_markup =keyboard ) - logging .info (f"BanCheck: [L_CB_Edit] Ban msg 'target_message_obj.edit_text' attempted for user {event_user.id} (Callback).") - except Exception as e_edit : - logging .warning (f"BanCheck: [M_CB_EditFail] Failed to edit message for banned user {event_user.id}: {type(e_edit).__name__} - {e_edit}. Sending new message.") - await actual_event_object .bot .send_message (target_chat_id ,ban_message_text ,reply_markup =keyboard ) - logging .info (f"BanCheck: [N_CB_NewMsg] Ban msg 'actual_event_object.bot.send_message' (after edit fail) attempted for user {event_user.id} (Callback).") - else : - logging .warning (f"BanCheck: [K_CB_NoMsg] CallbackQuery from {event_user.id} has no .message attribute. Sending new message directly.") - await actual_event_object .bot .send_message (actual_event_object .from_user .id ,ban_message_text ,reply_markup =keyboard ) - logging .info (f"BanCheck: [L_CB_NoMsg_NewMsg] Ban msg 'actual_event_object.bot.send_message' (no .message) attempted for user {event_user.id} (Callback).") - else : - logging .error (f"BanCheck: [UNHANDLED_EVENT_TYPE_INTERNAL] actual_event_object type {type(actual_event_object)} was not Message or CallbackQuery.") + except TelegramForbiddenError as e_forbidden: + logging.warning( + f"BanCheck: TelegramForbiddenError sending ban msg to {event_user.id}: {e_forbidden}" + ) + except TelegramBadRequest as e_bad_req: + logging.error( + f"BanCheck: TelegramBadRequest sending ban msg to {event_user.id}: {e_bad_req}", + exc_info=True) + except TelegramAPIError as e_api: + logging.error( + f"BanCheck: TelegramAPIError sending ban msg to {event_user.id}: {e_api}", + exc_info=True) + except AiogramError as e_aio: + logging.error( + f"BanCheck: AiogramError sending ban msg to {event_user.id}: {e_aio}", + exc_info=True) + except Exception as e_general: + logging.error( + f"BanCheck: Generic failure preparing or sending ban notification to user {event_user.id}: {e_general}", + exc_info=True) - except TelegramForbiddenError as e_forbidden :logging .warning (f"BanCheck: TelegramForbiddenError sending ban msg to {event_user.id}: {e_forbidden}") - except TelegramBadRequest as e_bad_req :logging .error (f"BanCheck: TelegramBadRequest sending ban msg to {event_user.id}: {e_bad_req}",exc_info =True ) - except TelegramAPIError as e_api :logging .error (f"BanCheck: TelegramAPIError sending ban msg to {event_user.id}: {e_api}",exc_info =True ) - except AiogramError as e_aio :logging .error (f"BanCheck: AiogramError sending ban msg to {event_user.id}: {e_aio}",exc_info =True ) - except Exception as e_general : - logging .error (f"BanCheck: Generic failure preparing or sending ban notification to user {event_user.id}: {e_general}",exc_info =True ) - - logging .debug (f"BanCheck: [Z] End of ban processing for user {event_user.id}. Returning to stop further handlers.") + logging.debug( + f"BanCheck: [Z] End of ban processing for user {event_user.id}. Returning to stop further handlers." + ) return - return await handler (event ,data ) \ No newline at end of file + return await handler(event, data) diff --git a/bot/middlewares/i18n.py b/bot/middlewares/i18n.py index 587672d..116f95e 100644 --- a/bot/middlewares/i18n.py +++ b/bot/middlewares/i18n.py @@ -2,112 +2,155 @@ import logging import json import os import aiosqlite -from typing import Any ,Awaitable ,Callable ,Dict ,Optional +from typing import Any, Awaitable, Callable, Dict, Optional from aiogram import BaseMiddleware -from aiogram .types import TelegramObject ,User ,Update +from aiogram.types import TelegramObject, User, Update + +from db.database import get_user, get_db_connection_manager, _setup_db_connection +from config.settings import Settings -from db .database import get_user ,get_db_connection_manager ,_setup_db_connection -from config .settings import Settings +class JsonI18n: + + def __init__(self, path: str, default: str = "en", domain: str = "bot"): + self.domain = domain + self.path = path + self.default_lang = default + self.locales_data: Dict[str, Dict[str, str]] = {} + self._load_locales() + logging.info( + f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" + ) + + def _load_locales(self): + if not os.path.isdir(self.path): + logging.error(f"Locales path not found: {self.path}") + return + for item in os.listdir(self.path): + if item.endswith(".json"): + lang_code = item.split(".")[0] + file_path = os.path.join(self.path, item) + try: + with open(file_path, "r", encoding="utf-8") as f: + self.locales_data[lang_code] = json.load(f) + except Exception as e: + logging.error( + f"Error loading locale {lang_code} from {file_path}: {e}" + ) + + def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str: + effective_lang_code = lang_code + if not effective_lang_code or effective_lang_code not in self.locales_data: + effective_lang_code = self.default_lang + lang_data = self.locales_data.get(effective_lang_code) + if lang_data is None: + logging.warning( + f"No language data for '{effective_lang_code}' (default: '{self.default_lang}'). Key '{key}'." + ) + return key.format(**kwargs) if kwargs else key + text = lang_data.get(key) + if text is None: + if effective_lang_code != self.default_lang: + default_lang_data = self.locales_data.get( + self.default_lang, {}) + text = default_lang_data.get(key) + if text is None: return key.format(**kwargs) if kwargs else key + try: + return text.format(**kwargs) if kwargs else text + except KeyError as e: + logging.warning( + f"Missing format key {e} for key '{key}' (lang: {effective_lang_code}). Text: '{text}'" + ) + return text + except Exception as e: + logging.error( + f"Error formatting key '{key}' (lang: {effective_lang_code}): {e}. Text: '{text}'" + ) + return text +_i18n_instance: Optional[JsonI18n] = None -class JsonI18n : - def __init__ (self ,path :str ,default :str ="en",domain :str ="bot"): - self .domain =domain - self .path =path - self .default_lang =default - self .locales_data :Dict [str ,Dict [str ,str ]]={} - self ._load_locales () - logging .info (f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}") - - def _load_locales (self ): - if not os .path .isdir (self .path ):logging .error (f"Locales path not found: {self.path}");return - for item in os .listdir (self .path ): - if item .endswith (".json"): - lang_code =item .split (".")[0 ];file_path =os .path .join (self .path ,item ) - try : - with open (file_path ,"r",encoding ="utf-8")as f :self .locales_data [lang_code ]=json .load (f ) - except Exception as e :logging .error (f"Error loading locale {lang_code} from {file_path}: {e}") - - def gettext (self ,lang_code :Optional [str ],key :str ,**kwargs )->str : - effective_lang_code =lang_code - if not effective_lang_code or effective_lang_code not in self .locales_data : - effective_lang_code =self .default_lang - lang_data =self .locales_data .get (effective_lang_code ) - if lang_data is None : - logging .warning (f"No language data for '{effective_lang_code}' (default: '{self.default_lang}'). Key '{key}'.");return key .format (**kwargs )if kwargs else key - text =lang_data .get (key ) - if text is None : - if effective_lang_code !=self .default_lang : - default_lang_data =self .locales_data .get (self .default_lang ,{});text =default_lang_data .get (key ) - if text is None :return key .format (**kwargs )if kwargs else key - try :return text .format (**kwargs )if kwargs else text - except KeyError as e :logging .warning (f"Missing format key {e} for key '{key}' (lang: {effective_lang_code}). Text: '{text}'");return text - except Exception as e :logging .error (f"Error formatting key '{key}' (lang: {effective_lang_code}): {e}. Text: '{text}'");return text - -_i18n_instance :Optional [JsonI18n ]=None -def get_i18n_instance (path :str ="locales",default :str ="en",domain :str ="bot")->JsonI18n : +def get_i18n_instance(path: str = "locales", + default: str = "en", + domain: str = "bot") -> JsonI18n: global _i18n_instance - if _i18n_instance is None :_i18n_instance =JsonI18n (path =path ,default =default ,domain =domain ) + if _i18n_instance is None: + _i18n_instance = JsonI18n(path=path, default=default, domain=domain) return _i18n_instance -class I18nMiddleware (BaseMiddleware ): - def __init__ (self ,i18n :JsonI18n ,settings :Settings ): - super ().__init__ () - self .i18n =i18n - self .settings =settings +class I18nMiddleware(BaseMiddleware): - async def __call__ ( - self , - handler :Callable [[Update ,Dict [str ,Any ]],Awaitable [Any ]], - event :Update , - data :Dict [str ,Any ] - )->Any : - event_user :Optional [User ]=data .get ("event_from_user") + def __init__(self, i18n: JsonI18n, settings: Settings): + super().__init__() + self.i18n = i18n + self.settings = settings - current_language =self .i18n .default_lang + async def __call__(self, handler: Callable[[Update, Dict[str, Any]], + Awaitable[Any]], event: Update, + data: Dict[str, Any]) -> Any: + event_user: Optional[User] = data.get("event_from_user") + current_language = self.i18n.default_lang - if event_user : - logging .debug (f"I18nMiddleware: Processing for user {event_user.id}") + if event_user: + logging.debug( + f"I18nMiddleware: Processing for user {event_user.id}") - try : + try: - user_db_data =await get_user (event_user .id ) - if user_db_data and user_db_data ['language_code']and user_db_data ['language_code']in self .i18n .locales_data : - current_language =user_db_data ['language_code'] - logging .debug (f"I18nMiddleware: User {event_user.id} language loaded from DB: {current_language}") - else : + user_db_data = await get_user(event_user.id) + if user_db_data and user_db_data[ + 'language_code'] and user_db_data[ + 'language_code'] in self.i18n.locales_data: + current_language = user_db_data['language_code'] + logging.debug( + f"I18nMiddleware: User {event_user.id} language loaded from DB: {current_language}" + ) + else: - if event_user .language_code : - lang_prefix =event_user .language_code .split ('-')[0 ] - if lang_prefix in self .i18n .locales_data : - current_language =lang_prefix - logging .debug (f"I18nMiddleware: User {event_user.id} language set from Telegram client (prefix): {current_language}") - elif event_user .language_code in self .i18n .locales_data : - current_language =event_user .language_code - logging .debug (f"I18nMiddleware: User {event_user.id} language set from Telegram client (full): {current_language}") - else : + if event_user.language_code: + lang_prefix = event_user.language_code.split('-')[0] + if lang_prefix in self.i18n.locales_data: + current_language = lang_prefix + logging.debug( + f"I18nMiddleware: User {event_user.id} language set from Telegram client (prefix): {current_language}" + ) + elif event_user.language_code in self.i18n.locales_data: + current_language = event_user.language_code + logging.debug( + f"I18nMiddleware: User {event_user.id} language set from Telegram client (full): {current_language}" + ) + else: - logging .debug (f"I18nMiddleware: User {event_user.id} Telegram client language '{event_user.language_code}' not supported. Using default: {current_language}") - else : - logging .debug (f"I18nMiddleware: User {event_user.id} has no language_code from Telegram. Using default: {current_language}") - except Exception as e_db_lang : + logging.debug( + f"I18nMiddleware: User {event_user.id} Telegram client language '{event_user.language_code}' not supported. Using default: {current_language}" + ) + else: + logging.debug( + f"I18nMiddleware: User {event_user.id} has no language_code from Telegram. Using default: {current_language}" + ) + except Exception as e_db_lang: - logging .error (f"I18nMiddleware: Error fetching user language from DB for user {event_user.id}: {e_db_lang}. Falling back.") - if event_user .language_code : - lang_prefix =event_user .language_code .split ('-')[0 ] - if lang_prefix in self .i18n .locales_data :current_language =lang_prefix - elif event_user .language_code in self .i18n .locales_data :current_language =event_user .language_code + logging.error( + f"I18nMiddleware: Error fetching user language from DB for user {event_user.id}: {e_db_lang}. Falling back." + ) + if event_user.language_code: + lang_prefix = event_user.language_code.split('-')[0] + if lang_prefix in self.i18n.locales_data: + current_language = lang_prefix + elif event_user.language_code in self.i18n.locales_data: + current_language = event_user.language_code - data ["i18n_data"]={ - "i18n_instance":self .i18n , - "current_language":current_language + data["i18n_data"] = { + "i18n_instance": self.i18n, + "current_language": current_language } - logging .debug (f"I18nMiddleware: Final current_language for event: {current_language}") + logging.debug( + f"I18nMiddleware: Final current_language for event: {current_language}" + ) - return await handler (event ,data ) \ No newline at end of file + return await handler(event, data) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 73e6941..cb59069 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -1,97 +1,114 @@ import logging import asyncio from aiogram import Bot -from apscheduler .schedulers .asyncio import AsyncIOScheduler +from apscheduler.schedulers.asyncio import AsyncIOScheduler from datetime import datetime -from config .settings import Settings +from config.settings import Settings from .subscription_service import SubscriptionService -from db .database import get_db_connection_manager -from bot .middlewares .i18n import JsonI18n +from db.database import get_db_connection_manager +from bot.middlewares.i18n import JsonI18n from .panel_api_service import PanelApiService -async def send_expiration_warnings (bot :Bot ,settings :Settings ,i18n :JsonI18n ,panel_service :PanelApiService ): +async def send_expiration_warnings(bot: Bot, settings: Settings, + i18n: JsonI18n, + panel_service: PanelApiService): """ Sends expiration warnings to users whose subscriptions are ending soon. This function is called by the scheduler. """ - logging .info (f"Scheduler job: Checking for expiring subscriptions at {datetime.now()}...") + logging.info( + f"Scheduler job: Checking for expiring subscriptions at {datetime.now()}..." + ) + sub_service = SubscriptionService(get_db_connection_manager, settings, + panel_service) - sub_service =SubscriptionService (get_db_connection_manager ,settings ,panel_service ) + expiring_subs = await sub_service.get_subscriptions_ending_soon( + settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS) - expiring_subs =await sub_service .get_subscriptions_ending_soon (settings .SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS ) - - if not expiring_subs : - logging .info ("No subscriptions found ending soon for notification.") + if not expiring_subs: + logging.info("No subscriptions found ending soon for notification.") return - logging .info (f"Found {len(expiring_subs)} subscriptions ending soon for notification.") - for sub_info in expiring_subs : - user_id =sub_info ['user_id'] - if not user_id : - logging .warning (f"Skipping notification for subscription without user_id: {sub_info}") + logging.info( + f"Found {len(expiring_subs)} subscriptions ending soon for notification." + ) + for sub_info in expiring_subs: + user_id = sub_info['user_id'] + if not user_id: + logging.warning( + f"Skipping notification for subscription without user_id: {sub_info}" + ) continue - user_lang =sub_info .get ('language_code')if sub_info .get ('language_code')else getattr (settings ,'DEFAULT_LANGUAGE','en') - first_name =sub_info .get ('first_name','User') - end_date_str =sub_info ['end_date_str'] + user_lang = sub_info.get('language_code') if sub_info.get( + 'language_code') else getattr(settings, 'DEFAULT_LANGUAGE', 'en') + first_name = sub_info.get('first_name', 'User') + end_date_str = sub_info['end_date_str'] - days_left_float =sub_info .get ('days_left') - days_left_display ='N/A' - if days_left_float is not None : + days_left_float = sub_info.get('days_left') + days_left_display = 'N/A' + if days_left_float is not None: - days_left_display =max (0 ,int (round (days_left_float ))) + days_left_display = max(0, int(round(days_left_float))) - _ =lambda key ,**kwargs :i18n .gettext (user_lang ,key ,**kwargs ) - message_text =_ ( - "subscription_ending_soon_notification", - user_name =first_name , - end_date =end_date_str , - days_left =days_left_display - ) - try : - await bot .send_message (user_id ,message_text ) + _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) + message_text = _("subscription_ending_soon_notification", + user_name=first_name, + end_date=end_date_str, + days_left=days_left_display) + try: + await bot.send_message(user_id, message_text) - await sub_service .update_last_notification_sent (user_id ,end_date_str ) - logging .info (f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}") - except Exception as e : + await sub_service.update_last_notification_sent( + user_id, end_date_str) + logging.info( + f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}" + ) + except Exception as e: - logging .error (f"Failed to send expiration warning to user {user_id}: {e}") - await asyncio .sleep (0.1 ) + logging.error( + f"Failed to send expiration warning to user {user_id}: {e}") + await asyncio.sleep(0.1) -async def schedule_subscription_notifications (bot :Bot ,settings :Settings ,i18n :JsonI18n ,scheduler :AsyncIOScheduler ): +async def schedule_subscription_notifications(bot: Bot, settings: Settings, + i18n: JsonI18n, + scheduler: AsyncIOScheduler): """Schedules the daily job to send expiration warnings.""" + async def job_wrapper(): + panel_service = PanelApiService(settings) + try: - async def job_wrapper (): - panel_service =PanelApiService (settings ) - try : + await send_expiration_warnings(bot, settings, i18n, panel_service) + except Exception as e: + logging.error( + f"Error in scheduled job 'send_expiration_warnings': {e}", + exc_info=True) + finally: + await panel_service.close_session() - await send_expiration_warnings (bot ,settings ,i18n ,panel_service ) - except Exception as e : - logging .error (f"Error in scheduled job 'send_expiration_warnings': {e}",exc_info =True ) - finally : - await panel_service .close_session () + try: + notification_hour = int(settings.SUBSCRIPTION_NOTIFICATION_HOUR_UTC) + notification_minute = int( + settings.SUBSCRIPTION_NOTIFICATION_MINUTE_UTC) + except (ValueError, TypeError): + logging.warning( + "SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid. Defaulting to 9:00 UTC." + ) + notification_hour = 9 + notification_minute = 0 - - try : - notification_hour =int (settings .SUBSCRIPTION_NOTIFICATION_HOUR_UTC ) - notification_minute =int (settings .SUBSCRIPTION_NOTIFICATION_MINUTE_UTC ) - except (ValueError ,TypeError ): - logging .warning ("SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid. Defaulting to 9:00 UTC.") - notification_hour =9 - notification_minute =0 - - scheduler .add_job ( - job_wrapper , - 'cron', - hour =notification_hour , - minute =notification_minute , - name ="daily_subscription_expiration_warnings", - misfire_grace_time =60 *15 + scheduler.add_job(job_wrapper, + 'cron', + hour=notification_hour, + minute=notification_minute, + name="daily_subscription_expiration_warnings", + misfire_grace_time=60 * 15) + logging.info( + f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC." ) - logging .info (f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC.") \ No newline at end of file diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index c7b0393..fcdc5b0 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -1,183 +1,259 @@ import aiohttp import logging -from typing import Optional ,List ,Dict ,Any -from datetime import datetime ,timedelta ,timezone +from typing import Optional, List, Dict, Any +from datetime import datetime, timedelta, timezone -from config .settings import Settings +from config.settings import Settings -class PanelApiService : - def __init__ (self ,settings :Settings ): - self .settings =settings - self .base_url =settings .PANEL_API_URL - self .api_key =settings .PANEL_API_KEY - self ._session :Optional [aiohttp .ClientSession ]=None - self .default_client_ip ="127.0.0.1" - async def _get_session (self )->aiohttp .ClientSession : - if self ._session is None or self ._session .closed : - self ._session =aiohttp .ClientSession () - return self ._session +class PanelApiService: - async def close_session (self ): - if self ._session and not self ._session .closed : - await self ._session .close () - self ._session =None - logging .info ("Panel API service session closed.") + def __init__(self, settings: Settings): + self.settings = settings + self.base_url = settings.PANEL_API_URL + self.api_key = settings.PANEL_API_KEY + self._session: Optional[aiohttp.ClientSession] = None + self.default_client_ip = "127.0.0.1" - async def _prepare_headers (self )->Dict [str ,str ]: - headers ={ - "Content-Type":"application/json", - "X-Forwarded-Proto":"https", - "X-Forwarded-For":self .default_client_ip , - "X-Real-IP":self .default_client_ip , + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def close_session(self): + if self._session and not self._session.closed: + await self._session.close() + self._session = None + logging.info("Panel API service session closed.") + + async def _prepare_headers(self) -> Dict[str, str]: + headers = { + "Content-Type": "application/json", + "X-Forwarded-Proto": "https", + "X-Forwarded-For": self.default_client_ip, + "X-Real-IP": self.default_client_ip, } - if self .api_key : - headers ["Authorization"]=f"Bearer {self.api_key}" - + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" return headers - async def _request (self ,method :str ,endpoint :str ,**kwargs )->Optional [Dict [str ,Any ]]: - if not self .base_url : - logging .error ("Panel API URL not configured.") - return {"error":True ,"status_code":0 ,"message":"Panel API URL not configured."} + async def _request(self, method: str, endpoint: str, + **kwargs) -> Optional[Dict[str, Any]]: + if not self.base_url: + logging.error("Panel API URL not configured.") + return { + "error": True, + "status_code": 0, + "message": "Panel API URL not configured." + } - session =await self ._get_session () - headers =await self ._prepare_headers () + session = await self._get_session() + headers = await self._prepare_headers() - if "Authorization"not in headers and self .api_key : - logging .warning (f"Authorization header missing for panel endpoint {endpoint} despite API key being set.") + if "Authorization" not in headers and self.api_key: + logging.warning( + f"Authorization header missing for panel endpoint {endpoint} despite API key being set." + ) - url =f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" - json_payload_for_log =kwargs .get ('json')if method in ["POST","PATCH","PUT"]else None - log_prefix =f"Panel API {method} {url}" - if json_payload_for_log :log_prefix +=f" Payload: {json_payload_for_log}" + url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" + json_payload_for_log = kwargs.get('json') if method in [ + "POST", "PATCH", "PUT" + ] else None + log_prefix = f"Panel API {method} {url}" + if json_payload_for_log: + log_prefix += f" Payload: {json_payload_for_log}" - try : - async with session .request (method ,url ,headers =headers ,**kwargs )as response : - if 200 <=response .status <300 : - try : - data =await response .json () - logging .debug (f"{log_prefix} - Success ({response.status})") + try: + async with session.request(method, url, headers=headers, + **kwargs) as response: + if 200 <= response.status < 300: + try: + data = await response.json() + logging.debug( + f"{log_prefix} - Success ({response.status})") return data - except aiohttp .ContentTypeError : - logging .debug (f"{log_prefix} - Success ({response.status}) with non-JSON response.") - return {"status":"success","code":response .status ,"data_text":await response .text ()} - else : - try : - error_json =await response .json () - logging .error (f"{log_prefix} - Failed ({response.status}): {error_json}") - return {"error":True ,"status_code":response .status ,"response":error_json , - "message":error_json .get ("message"),"errorCode":error_json .get ("errorCode")} - except aiohttp .ContentTypeError : - error_text =await response .text () - logging .error (f"{log_prefix} - Failed ({response.status}): {error_text}") - return {"error":True ,"status_code":response .status ,"message":error_text } - except aiohttp .ClientError as e : - logging .error (f"Panel API client request error to {url}: {e}") - return {"error":True ,"status_code":-1 ,"message":str (e )} - except Exception as e : - logging .error (f"Unexpected Panel API request error to {url}: {e}",exc_info =True ) - return {"error":True ,"status_code":-2 ,"message":f"Unexpected error: {str(e)}"} + except aiohttp.ContentTypeError: + logging.debug( + f"{log_prefix} - Success ({response.status}) with non-JSON response." + ) + return { + "status": "success", + "code": response.status, + "data_text": await response.text() + } + else: + try: + error_json = await response.json() + logging.error( + f"{log_prefix} - Failed ({response.status}): {error_json}" + ) + return { + "error": True, + "status_code": response.status, + "response": error_json, + "message": error_json.get("message"), + "errorCode": error_json.get("errorCode") + } + except aiohttp.ContentTypeError: + error_text = await response.text() + logging.error( + f"{log_prefix} - Failed ({response.status}): {error_text}" + ) + return { + "error": True, + "status_code": response.status, + "message": error_text + } + except aiohttp.ClientError as e: + logging.error(f"Panel API client request error to {url}: {e}") + return {"error": True, "status_code": -1, "message": str(e)} + except Exception as e: + logging.error(f"Unexpected Panel API request error to {url}: {e}", + exc_info=True) + return { + "error": True, + "status_code": -2, + "message": f"Unexpected error: {str(e)}" + } - async def get_users_by_filter (self ,username :Optional [str ]=None )->Optional [List [Dict [str ,Any ]]]: + async def get_users_by_filter( + self, + username: Optional[str] = None) -> Optional[List[Dict[str, Any]]]: """Fetches users from panel by username.""" - if not username : - logging .warning ("get_users_by_filter called without username.") + if not username: + logging.warning("get_users_by_filter called without username.") return None - params ={"username":username } - response_data =await self ._request ("GET","/users",params =params ) + params = {"username": username} + response_data = await self._request("GET", "/users", params=params) - if response_data and not response_data .get ("error"): - users_list =response_data .get ("response",{}).get ("users",[]) - logging .info (f"Found {len(users_list)} panel users matching filter: {params}") + if response_data and not response_data.get("error"): + users_list = response_data.get("response", {}).get("users", []) + logging.info( + f"Found {len(users_list)} panel users matching filter: {params}" + ) return users_list - logging .error (f"Failed to fetch panel users with filter {params}. Response: {response_data}") + logging.error( + f"Failed to fetch panel users with filter {params}. Response: {response_data}" + ) return None - async def create_panel_user ( - self , - username :str , - telegram_id :Optional [int ]=None , - email :Optional [str ]=None , - default_expire_days :int =1 , - default_traffic_limit_bytes :int =0 , - default_traffic_limit_strategy :str ="NO_RESET", - specific_inbound_uuids :Optional [List [str ]]=None , - activate_all_inbounds_default_flag :bool =True - )->Optional [Dict [str ,Any ]]: + async def create_panel_user( + self, + username: str, + telegram_id: Optional[int] = None, + email: Optional[str] = None, + default_expire_days: int = 1, + default_traffic_limit_bytes: int = 0, + default_traffic_limit_strategy: str = "NO_RESET", + specific_inbound_uuids: Optional[List[str]] = None, + activate_all_inbounds_default_flag: bool = True + ) -> Optional[Dict[str, Any]]: - if not (6 <=len (username )<=34 and username .replace ('_','').replace ('-','').isalnum ()): - msg =f"Username '{username}' for panel does not meet requirements (6-34 chars, alphanumeric, _, -)." - logging .error (msg ) - return {"error":True ,"status_code":400 ,"message":msg ,"response":{"message":msg ,"errorCode":"VALIDATION_ERROR"}} + if not (6 <= len(username) <= 34 + and username.replace('_', '').replace('-', '').isalnum()): + msg = f"Username '{username}' for panel does not meet requirements (6-34 chars, alphanumeric, _, -)." + logging.error(msg) + return { + "error": True, + "status_code": 400, + "message": msg, + "response": { + "message": msg, + "errorCode": "VALIDATION_ERROR" + } + } - now =datetime .now (timezone .utc ) - expire_at_dt =now +timedelta (days =default_expire_days ) - expire_at_iso =expire_at_dt .isoformat (timespec ='milliseconds').replace ('+00:00','Z') + now = datetime.now(timezone.utc) + expire_at_dt = now + timedelta(days=default_expire_days) + expire_at_iso = expire_at_dt.isoformat( + timespec='milliseconds').replace('+00:00', 'Z') - payload :Dict [str ,Any ]={ - "username":username , - "expireAt":expire_at_iso , - "trafficLimitStrategy":default_traffic_limit_strategy , - "trafficLimitBytes":default_traffic_limit_bytes , + payload: Dict[str, Any] = { + "username": username, + "expireAt": expire_at_iso, + "trafficLimitStrategy": default_traffic_limit_strategy, + "trafficLimitBytes": default_traffic_limit_bytes, } - if specific_inbound_uuids : - payload ["activeUserInbounds"]=specific_inbound_uuids + if specific_inbound_uuids: + payload["activeUserInbounds"] = specific_inbound_uuids + payload["activateAllInbounds"] = False + else: + payload["activateAllInbounds"] = activate_all_inbounds_default_flag + if telegram_id is not None: payload["telegramId"] = telegram_id + if email: payload["email"] = email - payload ["activateAllInbounds"]=False - else : - payload ["activateAllInbounds"]=activate_all_inbounds_default_flag + return await self._request("POST", "/users", json=payload) - if telegram_id is not None :payload ["telegramId"]=telegram_id - if email :payload ["email"]=email + async def update_user_details_on_panel( + self, user_uuid: str, + update_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if 'uuid' not in update_payload: update_payload['uuid'] = user_uuid - return await self ._request ("POST","/users",json =payload ) + update_payload.pop('activateAllInbounds', None) - async def update_user_details_on_panel (self ,user_uuid :str ,update_payload :Dict [str ,Any ])->Optional [Dict [str ,Any ]]: - if 'uuid'not in update_payload :update_payload ['uuid']=user_uuid - - - - - update_payload .pop ('activateAllInbounds',None ) - - full_response =await self ._request ("PATCH","/users",json =update_payload ) - if full_response and not full_response .get ("error")and full_response .get ("response"): - logging .info (f"User {user_uuid} details updated on panel.") - return full_response .get ("response") - logging .error (f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Resp: {full_response}") + full_response = await self._request("PATCH", + "/users", + json=update_payload) + if full_response and not full_response.get( + "error") and full_response.get("response"): + logging.info(f"User {user_uuid} details updated on panel.") + return full_response.get("response") + logging.error( + f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Resp: {full_response}" + ) return None - async def get_all_panel_users (self ,page_size :int =100 )->Optional [List [Dict [str ,Any ]]]: - all_users =[];start_offset =0 - while True : - params ={"size":page_size ,"start":start_offset } - response_data =await self ._request ("GET","/users",params =params ) - if not response_data or response_data .get ("error"):logging .error (f"Failed to fetch panel users batch: {response_data}");return None - users_batch =response_data .get ("response",{}).get ("users",[]) - if not users_batch :break - all_users .extend (users_batch ) - if len (users_batch ) Optional[List[Dict[str, Any]]]: + all_users = [] + start_offset = 0 + while True: + params = {"size": page_size, "start": start_offset} + response_data = await self._request("GET", "/users", params=params) + if not response_data or response_data.get("error"): + logging.error( + f"Failed to fetch panel users batch: {response_data}") + return None + users_batch = response_data.get("response", {}).get("users", []) + if not users_batch: break + all_users.extend(users_batch) + if len(users_batch) < page_size: break + start_offset += page_size + logging.info(f"Fetched {len(all_users)} users from panel API.") + return all_users - async def get_user_by_uuid (self ,user_uuid :str )->Optional [Dict [str ,Any ]]: - full_response =await self ._request ("GET",f"/users/{user_uuid}") - if full_response and not full_response .get ("error")and full_response .get ("response"): - return full_response .get ("response") + async def get_user_by_uuid(self, + user_uuid: str) -> Optional[Dict[str, Any]]: + full_response = await self._request("GET", f"/users/{user_uuid}") + if full_response and not full_response.get( + "error") and full_response.get("response"): + return full_response.get("response") return None - async def update_user_status_on_panel (self ,user_uuid :str ,enable :bool )->bool : - endpoint =f"/users/{user_uuid}/actions/{'enable' if enable else 'disable'}" - response_data =await self ._request ("POST",endpoint ) - if response_data and not response_data .get ("error")and (response_data .get ("response")or response_data .get ("status")=="success"): - logging .info (f"User {user_uuid} status on panel -> {'enabled' if enable else 'disabled'}.");return True - logging .error (f"Failed to update user {user_uuid} status on panel. Resp: {response_data}");return False + async def update_user_status_on_panel(self, user_uuid: str, + enable: bool) -> bool: + endpoint = f"/users/{user_uuid}/actions/{'enable' if enable else 'disable'}" + response_data = await self._request("POST", endpoint) + if response_data and not response_data.get("error") and ( + response_data.get("response") + or response_data.get("status") == "success"): + logging.info( + f"User {user_uuid} status on panel -> {'enabled' if enable else 'disabled'}." + ) + return True + logging.error( + f"Failed to update user {user_uuid} status on panel. Resp: {response_data}" + ) + return False - async def get_subscription_link (self ,short_uuid_or_sub_uuid :str ,client_type :Optional [str ]=None )->Optional [str ]: - if not self .settings .PANEL_API_URL :return None - return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}" \ No newline at end of file + async def get_subscription_link( + self, + short_uuid_or_sub_uuid: str, + client_type: Optional[str] = None) -> Optional[str]: + if not self.settings.PANEL_API_URL: return None + return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}" diff --git a/bot/services/payment_service.py b/bot/services/payment_service.py index f60ac8a..1f48088 100644 --- a/bot/services/payment_service.py +++ b/bot/services/payment_service.py @@ -1,144 +1,188 @@ import uuid import logging -from yookassa import Configuration ,Payment -from yookassa .domain .request .payment_request_builder import PaymentRequestBuilder +from yookassa import Configuration, Payment +from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder + +from typing import Optional, Dict, Any, List + +from config.settings import Settings +class YooKassaService: + def __init__(self, + shop_id: Optional[str], + secret_key: Optional[str], + configured_return_url: Optional[str], + bot_username_for_default: Optional[str] = None, + settings_obj: Optional[Settings] = None): + self.settings = settings_obj + if not shop_id or not secret_key: + logging.warning( + "YooKassa SHOP_ID or SECRET_KEY not configured. Payment functionality will be disabled." + ) + self.configured = False + else: + Configuration.account_id = shop_id + Configuration.secret_key = secret_key + self.configured = True + logging.info(f"YooKassa configured for shop_id: {shop_id}") + if configured_return_url: + self.return_url = configured_return_url + elif bot_username_for_default: + self.return_url = f"https://t.me/{bot_username_for_default}" + logging.info( + f"YOOKASSA_RETURN_URL not set, using dynamic default: {self.return_url}" + ) + else: + self.return_url = "https://example.com/payment_error_no_return_url" + logging.warning( + f"YOOKASSA_RETURN_URL not set AND bot username not provided. Using placeholder: {self.return_url}" + ) + logging.info( + f"YooKassa Service effective return_url: {self.return_url}") - -from typing import Optional ,Dict ,Any ,List - -from config .settings import Settings - -class YooKassaService : - def __init__ ( - self , - shop_id :Optional [str ], - secret_key :Optional [str ], - configured_return_url :Optional [str ], - bot_username_for_default :Optional [str ]=None , - settings_obj :Optional [Settings ]=None - ): - self .settings =settings_obj - - if not shop_id or not secret_key : - logging .warning ("YooKassa SHOP_ID or SECRET_KEY not configured. Payment functionality will be disabled.") - self .configured =False - else : - Configuration .account_id =shop_id - Configuration .secret_key =secret_key - self .configured =True - logging .info (f"YooKassa configured for shop_id: {shop_id}") - - if configured_return_url : - self .return_url =configured_return_url - elif bot_username_for_default : - self .return_url =f"https://t.me/{bot_username_for_default}" - logging .info (f"YOOKASSA_RETURN_URL not set, using dynamic default: {self.return_url}") - else : - self .return_url ="https://example.com/payment_error_no_return_url" - logging .warning (f"YOOKASSA_RETURN_URL not set AND bot username not provided. Using placeholder: {self.return_url}") - logging .info (f"YooKassa Service effective return_url: {self.return_url}") - - - async def create_payment (self ,amount :float ,currency :str ,description :str ,metadata :Dict [str ,Any ])->Optional [Dict [str ,Any ]]: - if not self .configured : - logging .error ("YooKassa is not configured. Cannot create payment.") + async def create_payment( + self, amount: float, currency: str, description: str, + metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not self.configured: + logging.error("YooKassa is not configured. Cannot create payment.") return None - if not self .settings : - logging .error ("YooKassaService: Settings object not available for receipt creation.") - return {"error":True ,"internal_message":"Service settings not initialized."} - - if not self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL : - logging .error ("CRITICAL: YOOKASSA_DEFAULT_RECEIPT_EMAIL is not configured. YooKassa payment will fail due to missing receipt customer contact.") - return {"error":True ,"internal_message":"YooKassa receipt email not configured by admin."} - - try : - builder =PaymentRequestBuilder () - builder .set_amount ({"value":str (round (amount ,2 )),"currency":currency .upper ()}) - builder .set_capture (True ) - builder .set_confirmation ({"type":"redirect","return_url":self .return_url }) - builder .set_description (description ) - builder .set_metadata (metadata ) - - - receipt_items :List [Dict [str ,Any ]]=[ - { - "description":description , - "quantity":"1.00", - "amount":{ - "value":str (round (amount ,2 )), - "currency":currency .upper () - }, - "vat_code":str (self .settings .YOOKASSA_VAT_CODE ), - "payment_mode":self .settings .YOOKASSA_PAYMENT_MODE , - "payment_subject":self .settings .YOOKASSA_PAYMENT_SUBJECT - } - ] - - receipt_customer :Dict [str ,str ]={} - if self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL : - receipt_customer ["email"]=self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL - - - - - if not receipt_customer : - logging .error ("YooKassa: No customer contact (email/phone) for receipt.") - return {"error":True ,"internal_message":"Receipt customer contact missing."} - - receipt_payload :Dict [str ,Any ]={ - "customer":receipt_customer , - "items":receipt_items + if not self.settings: + logging.error( + "YooKassaService: Settings object not available for receipt creation." + ) + return { + "error": True, + "internal_message": "Service settings not initialized." } + if not self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL: + logging.error( + "CRITICAL: YOOKASSA_DEFAULT_RECEIPT_EMAIL is not configured. YooKassa payment will fail due to missing receipt customer contact." + ) + return { + "error": + True, + "internal_message": + "YooKassa receipt email not configured by admin." + } - builder .set_receipt (receipt_payload ) + try: + builder = PaymentRequestBuilder() + builder.set_amount({ + "value": str(round(amount, 2)), + "currency": currency.upper() + }) + builder.set_capture(True) + builder.set_confirmation({ + "type": "redirect", + "return_url": self.return_url + }) + builder.set_description(description) + builder.set_metadata(metadata) + receipt_items: List[Dict[str, Any]] = [{ + "description": + description, + "quantity": + "1.00", + "amount": { + "value": str(round(amount, 2)), + "currency": currency.upper() + }, + "vat_code": + str(self.settings.YOOKASSA_VAT_CODE), + "payment_mode": + self.settings.YOOKASSA_PAYMENT_MODE, + "payment_subject": + self.settings.YOOKASSA_PAYMENT_SUBJECT + }] + receipt_customer: Dict[str, str] = {} + if self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL: + receipt_customer[ + "email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL + if not receipt_customer: + logging.error( + "YooKassa: No customer contact (email/phone) for receipt.") + return { + "error": True, + "internal_message": "Receipt customer contact missing." + } + receipt_payload: Dict[str, Any] = { + "customer": receipt_customer, + "items": receipt_items + } + builder.set_receipt(receipt_payload) - idempotence_key =str (uuid .uuid4 ()) - payment_request =builder .build () + idempotence_key = str(uuid.uuid4()) + payment_request = builder.build() - logging .info (f"Creating YooKassa payment (IDK: {idempotence_key}) with receipt. Email: {self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL}") + logging.info( + f"Creating YooKassa payment (IDK: {idempotence_key}) with receipt. Email: {self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL}" + ) - res =Payment .create (payment_request ,idempotence_key ) - logging .info (f"YooKassa Payment.create response: ID={res.id}, Status={res.status}") + res = Payment.create(payment_request, idempotence_key) + logging.info( + f"YooKassa Payment.create response: ID={res.id}, Status={res.status}" + ) return { - "id":res .id , - "confirmation_url":res .confirmation .confirmation_url if res .confirmation else None , - "status":res .status , - "metadata":res .metadata , - "amount_value":float (res .amount .value ), - "amount_currency":res .amount .currency , - "idempotence_key":idempotence_key , - "paid":res .paid , - "refundable":res .refundable , - "created_at":res .created_at .isoformat ()if hasattr (res .created_at ,'isoformat')else str (res .created_at ) + "id": + res.id, + "confirmation_url": + res.confirmation.confirmation_url + if res.confirmation else None, + "status": + res.status, + "metadata": + res.metadata, + "amount_value": + float(res.amount.value), + "amount_currency": + res.amount.currency, + "idempotence_key": + idempotence_key, + "paid": + res.paid, + "refundable": + res.refundable, + "created_at": + res.created_at.isoformat() if hasattr( + res.created_at, 'isoformat') else str(res.created_at) } - except Exception as e : - logging .error (f"YooKassa payment creation failed: {e}",exc_info =True ) + except Exception as e: + logging.error(f"YooKassa payment creation failed: {e}", + exc_info=True) return None - async def get_payment_info (self ,payment_id :str )->Optional [Dict [str ,Any ]]: - if not self .configured : - logging .error ("YooKassa is not configured. Cannot get payment info.") + async def get_payment_info(self, + payment_id: str) -> Optional[Dict[str, Any]]: + if not self.configured: + logging.error( + "YooKassa is not configured. Cannot get payment info.") return None - try : - payment_info =Payment .find_one (payment_id ) - if payment_info : - return {"id":payment_info .id ,"status":payment_info .status ,"paid":payment_info .paid , - "amount_value":float (payment_info .amount .value ),"amount_currency":payment_info .amount .currency , - "metadata":payment_info .metadata ,"description":payment_info .description ,} + try: + payment_info = Payment.find_one(payment_id) + if payment_info: + return { + "id": payment_info.id, + "status": payment_info.status, + "paid": payment_info.paid, + "amount_value": float(payment_info.amount.value), + "amount_currency": payment_info.amount.currency, + "metadata": payment_info.metadata, + "description": payment_info.description, + } + return None + except Exception as e: + logging.error( + f"YooKassa get payment info for {payment_id} failed: {e}") return None - except Exception as e : - logging .error (f"YooKassa get payment info for {payment_id} failed: {e}") - return None \ No newline at end of file diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py index c9f2c73..31a42d2 100644 --- a/bot/services/promo_code_service.py +++ b/bot/services/promo_code_service.py @@ -1,88 +1,88 @@ import logging import aiosqlite -from datetime import datetime ,timezone -from typing import Optional ,Dict ,Callable ,Any ,Tuple +from datetime import datetime, timezone +from typing import Optional, Dict, Callable, Any, Tuple from aiogram import Bot -from config .settings import Settings -from db .database import get_promo_code_by_code ,increment_promo_activation ,get_db_connection_manager ,_setup_db_connection +from config.settings import Settings +from db.database import get_promo_code_by_code, increment_promo_activation, get_db_connection_manager, _setup_db_connection from .subscription_service import SubscriptionService -from bot .middlewares .i18n import JsonI18n +from bot.middlewares.i18n import JsonI18n -class PromoCodeService : - def __init__ ( - self , - db_conn_provider :Callable [[],Any ], - settings :Settings , - subscription_service :SubscriptionService , - bot :Bot , - i18n :JsonI18n - ): - self .db_conn_provider =db_conn_provider - self .settings =settings - self .subscription_service =subscription_service - self .bot =bot - self .i18n =i18n - async def apply_promo_code (self ,user_id :int ,code_input :str ,user_lang :str )->Tuple [bool ,str ]: +class PromoCodeService: + + def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings, + subscription_service: SubscriptionService, bot: Bot, + i18n: JsonI18n): + self.db_conn_provider = db_conn_provider + self.settings = settings + self.subscription_service = subscription_service + self.bot = bot + self.i18n = i18n + + async def apply_promo_code(self, user_id: int, code_input: str, + user_lang: str) -> Tuple[bool, str]: """ Applies a promo code for a user. Returns: (success_status: bool, message_text_for_user: str) """ - _ =lambda k ,**kw :self .i18n .gettext (user_lang ,k ,**kw ) - code_input_upper =code_input .strip ().upper () + _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) + code_input_upper = code_input.strip().upper() - async with self .db_conn_provider ()as db : - await _setup_db_connection (db ) - try : - promo_data =await get_promo_code_by_code (code_input_upper ,db_conn =db ) + async with self.db_conn_provider() as db: + await _setup_db_connection(db) + try: + promo_data = await get_promo_code_by_code(code_input_upper, + db_conn=db) - if not promo_data : - return False ,_ ("promo_code_not_found",code =code_input_upper ) + if not promo_data: + return False, _("promo_code_not_found", + code=code_input_upper) - if promo_data ['current_activations']>=promo_data ['max_activations']: - return False ,_ ("promo_code_max_activations_reached",code =code_input_upper ) + if promo_data['current_activations'] >= promo_data[ + 'max_activations']: + return False, _("promo_code_max_activations_reached", + code=code_input_upper) + active_sub = await self.subscription_service.get_active_subscription( + user_id) + if not active_sub: + return False, _("promo_code_no_active_subscription") + bonus_days = promo_data['bonus_days'] + new_end_date = await self.subscription_service.extend_active_subscription_days( + user_id=user_id, + bonus_days=bonus_days, + db_conn=db, + reason=f"promo code {code_input_upper}") + if new_end_date: + activation_success = await increment_promo_activation( + promo_data['promo_code_id'], + user_id, + db_conn=db, + payment_id=None) + if activation_success: + await db.commit() + return True, _( + "promo_code_applied_success", + code=code_input_upper, + bonus_days=bonus_days, + new_end_date=new_end_date.strftime('%Y-%m-%d')) + else: - active_sub =await self .subscription_service .get_active_subscription (user_id ) - if not active_sub : - return False ,_ ("promo_code_no_active_subscription") + await db.rollback() + return False, _("promo_code_invalid_or_expired") + else: + await db.rollback() + return False, _("error_applying_promo_bonus") - bonus_days =promo_data ['bonus_days'] - - - new_end_date =await self .subscription_service .extend_active_subscription_days ( - user_id =user_id , - bonus_days =bonus_days , - db_conn =db , - reason =f"promo code {code_input_upper}" - ) - - if new_end_date : - - - activation_success =await increment_promo_activation (promo_data ['promo_code_id'],user_id ,db_conn =db ,payment_id =None ) - if activation_success : - await db .commit () - return True ,_ ("promo_code_applied_success", - code =code_input_upper , - bonus_days =bonus_days , - new_end_date =new_end_date .strftime ('%Y-%m-%d')) - else : - - - - await db .rollback () - return False ,_ ("promo_code_invalid_or_expired") - else : - await db .rollback () - return False ,_ ("error_applying_promo_bonus") - - except Exception as e : - logging .error (f"Error applying promo code {code_input_upper} for user {user_id}: {e}",exc_info =True ) - await db .rollback () - return False ,_ ("error_try_again") \ No newline at end of file + except Exception as e: + logging.error( + f"Error applying promo code {code_input_upper} for user {user_id}: {e}", + exc_info=True) + await db.rollback() + return False, _("error_try_again") diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py index 47986cf..15d89fe 100644 --- a/bot/services/referral_service.py +++ b/bot/services/referral_service.py @@ -1,96 +1,129 @@ import logging import aiosqlite -from typing import Optional ,Dict ,Callable ,Any ,TYPE_CHECKING ,Tuple +from typing import Optional, Dict, Callable, Any, TYPE_CHECKING, Tuple from aiogram import Bot from datetime import datetime -from config .settings import Settings -from db .database import get_user -from bot .middlewares .i18n import JsonI18n +from config.settings import Settings +from db.database import get_user +from bot.middlewares.i18n import JsonI18n -if TYPE_CHECKING : +if TYPE_CHECKING: from .subscription_service import SubscriptionService -class ReferralService : - def __init__ (self ,db_conn_provider :Callable [[],Any ],settings :Settings ,subscription_service_instance :'SubscriptionService',bot :Bot ,i18n :JsonI18n ): - self .db_conn_provider =db_conn_provider - self .settings =settings - self .subscription_service =subscription_service_instance - self .bot =bot - self .i18n =i18n +class ReferralService: + def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings, + subscription_service_instance: 'SubscriptionService', + bot: Bot, i18n: JsonI18n): + self.db_conn_provider = db_conn_provider + self.settings = settings + self.subscription_service = subscription_service_instance + self.bot = bot + self.i18n = i18n + async def process_new_user_referral(self, referee_user_id: int, + inviter_user_id: Optional[int]): + if inviter_user_id and referee_user_id != inviter_user_id: - - async def process_new_user_referral (self ,referee_user_id :int ,inviter_user_id :Optional [int ]): - if inviter_user_id and referee_user_id !=inviter_user_id : - - - logging .info (f"Referral link used: User {referee_user_id} was invited by {inviter_user_id}.") + logging.info( + f"Referral link used: User {referee_user_id} was invited by {inviter_user_id}." + ) pass - async def apply_referral_bonuses_for_payment ( - self ,referee_user_id :int ,purchased_subscription_months :int , - db_conn :aiosqlite .Connection - )->Dict [str ,Any ]: + async def apply_referral_bonuses_for_payment( + self, referee_user_id: int, purchased_subscription_months: int, + db_conn: aiosqlite.Connection) -> Dict[str, Any]: """Applies bonuses. Notifies inviter. Returns referee bonus details.""" - referee_final_end_date :Optional [datetime ]=None - referee_bonus_applied_days :Optional [int ]=None - try : - referee_user_row =await get_user (referee_user_id ,db_conn =db_conn ) - if not referee_user_row or referee_user_row ['referred_by_id']is None : - logging .debug (f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses.") - return {"referee_bonus_applied_days":None ,"referee_new_end_date":None } - - inviter_user_id =referee_user_row ['referred_by_id'] - inviter_user_row =await get_user (inviter_user_id ,db_conn =db_conn ) - - referee_name =referee_user_row ['first_name']or f"User {referee_user_id}" - - default_lang_for_placeholder =getattr (self .settings ,'DEFAULT_LANGUAGE','en') - inviter_name =inviter_user_row ['first_name']if inviter_user_row else self .i18n .gettext (default_lang_for_placeholder ,"friend_placeholder") - - inviter_bonus =self .settings .referral_bonus_inviter .get (purchased_subscription_months ) - referee_bonus =self .settings .referral_bonus_referee .get (purchased_subscription_months ) - - if inviter_bonus and inviter_bonus >0 and inviter_user_row : - new_end_date_inviter =await self .subscription_service .extend_subscription_for_referral ( - user_id =inviter_user_id ,bonus_days =inviter_bonus ,db_conn =db_conn + referee_final_end_date: Optional[datetime] = None + referee_bonus_applied_days: Optional[int] = None + try: + referee_user_row = await get_user(referee_user_id, db_conn=db_conn) + if not referee_user_row or referee_user_row[ + 'referred_by_id'] is None: + logging.debug( + f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses." ) - if new_end_date_inviter : - logging .info (f"Bonus applied for inviter {inviter_user_id}.") - try : - inviter_lang =inviter_user_row .get ('language_code',default_lang_for_placeholder ) - _i =lambda k ,**kw :self .i18n .gettext (inviter_lang ,k ,**kw ) - await self .bot .send_message ( - inviter_user_id , - _i ("referral_bonus_inviter_notification_extended", - days =inviter_bonus , - referee_name =referee_name , - new_end_date =new_end_date_inviter .strftime ('%Y-%m-%d') - ) - ) - except Exception as e : - logging .error (f"Failed to send bonus notification to inviter {inviter_user_id}: {e}") - else :logging .warning (f"Failed to apply bonus subscription extension for inviter {inviter_user_id}.") + return { + "referee_bonus_applied_days": None, + "referee_new_end_date": None + } - if referee_bonus and referee_bonus >0 : - new_end_date_referee =await self .subscription_service .extend_subscription_for_referral ( - user_id =referee_user_id ,bonus_days =referee_bonus ,db_conn =db_conn ,is_referee_bonus =True - ) - if new_end_date_referee : - logging .info (f"Bonus applied for referee {referee_user_id}.") - referee_final_end_date =new_end_date_referee - referee_bonus_applied_days =referee_bonus - else :logging .warning (f"Failed to apply bonus subscription extension for referee {referee_user_id}.") + inviter_user_id = referee_user_row['referred_by_id'] + inviter_user_row = await get_user(inviter_user_id, db_conn=db_conn) - return {"referee_bonus_applied_days":referee_bonus_applied_days ,"referee_new_end_date":referee_final_end_date } - except Exception as e : - logging .error (f"Error in apply_referral_bonuses_for_payment (db_conn: {db_conn is not None}): {e}",exc_info =True ) + referee_name = referee_user_row[ + 'first_name'] or f"User {referee_user_id}" + + default_lang_for_placeholder = getattr(self.settings, + 'DEFAULT_LANGUAGE', 'en') + inviter_name = inviter_user_row[ + 'first_name'] if inviter_user_row else self.i18n.gettext( + default_lang_for_placeholder, "friend_placeholder") + + inviter_bonus = self.settings.referral_bonus_inviter.get( + purchased_subscription_months) + referee_bonus = self.settings.referral_bonus_referee.get( + purchased_subscription_months) + + if inviter_bonus and inviter_bonus > 0 and inviter_user_row: + new_end_date_inviter = await self.subscription_service.extend_subscription_for_referral( + user_id=inviter_user_id, + bonus_days=inviter_bonus, + db_conn=db_conn) + if new_end_date_inviter: + logging.info( + f"Bonus applied for inviter {inviter_user_id}.") + try: + inviter_lang = inviter_user_row.get( + 'language_code', default_lang_for_placeholder) + _i = lambda k, **kw: self.i18n.gettext( + inviter_lang, k, **kw) + await self.bot.send_message( + inviter_user_id, + _i("referral_bonus_inviter_notification_extended", + days=inviter_bonus, + referee_name=referee_name, + new_end_date=new_end_date_inviter.strftime( + '%Y-%m-%d'))) + except Exception as e: + logging.error( + f"Failed to send bonus notification to inviter {inviter_user_id}: {e}" + ) + else: + logging.warning( + f"Failed to apply bonus subscription extension for inviter {inviter_user_id}." + ) + + if referee_bonus and referee_bonus > 0: + new_end_date_referee = await self.subscription_service.extend_subscription_for_referral( + user_id=referee_user_id, + bonus_days=referee_bonus, + db_conn=db_conn, + is_referee_bonus=True) + if new_end_date_referee: + logging.info( + f"Bonus applied for referee {referee_user_id}.") + referee_final_end_date = new_end_date_referee + referee_bonus_applied_days = referee_bonus + else: + logging.warning( + f"Failed to apply bonus subscription extension for referee {referee_user_id}." + ) + + return { + "referee_bonus_applied_days": referee_bonus_applied_days, + "referee_new_end_date": referee_final_end_date + } + except Exception as e: + logging.error( + f"Error in apply_referral_bonuses_for_payment (db_conn: {db_conn is not None}): {e}", + exc_info=True) raise - def generate_referral_link (self ,bot_username :str ,inviter_user_id :int )->str : - return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}" \ No newline at end of file + def generate_referral_link(self, bot_username: str, + inviter_user_id: int) -> str: + return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}" diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index eae3f50..1e09071 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -1,336 +1,670 @@ import logging import aiosqlite -from datetime import datetime ,timedelta ,timezone -from typing import Optional ,Dict ,Any ,List ,Callable ,Awaitable +from datetime import datetime, timedelta, timezone +from typing import Optional, Dict, Any, List, Callable, Awaitable -from config .settings import Settings -from db .database import get_db_connection_manager ,_setup_db_connection ,get_user ,has_had_any_subscription as db_has_had_any_subscription +from config.settings import Settings +from db.database import get_db_connection_manager, _setup_db_connection, get_user, has_had_any_subscription as db_has_had_any_subscription from .panel_api_service import PanelApiService - -if False : +if False: from .referral_service import ReferralService -class SubscriptionService : - def __init__ (self ,db_conn_provider :Callable [[],Any ],settings :Settings ,panel_service :PanelApiService ): - self .db_conn_provider =db_conn_provider - self .settings =settings - self .panel_service =panel_service - async def _get_db (self ,existing_conn :Optional [aiosqlite .Connection ]=None )->tuple [aiosqlite .Connection ,bool ]: +class SubscriptionService: + + def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings, + panel_service: PanelApiService): + self.db_conn_provider = db_conn_provider + self.settings = settings + self.panel_service = panel_service + + async def _get_db( + self, + existing_conn: Optional[aiosqlite.Connection] = None + ) -> tuple[aiosqlite.Connection, bool]: """Helper to get a DB connection. Returns (connection, should_manage_flag).""" - if existing_conn : - return existing_conn ,False + if existing_conn: + return existing_conn, False - conn_manager =self .db_conn_provider () - conn =await conn_manager .__aenter__ () - try : - await _setup_db_connection (conn ) - except Exception as e : - await conn_manager .__aexit__ (type (e ),e ,e .__traceback__ ) + conn_manager = self.db_conn_provider() + conn = await conn_manager.__aenter__() + try: + await _setup_db_connection(conn) + except Exception as e: + await conn_manager.__aexit__(type(e), e, e.__traceback__) raise - return conn ,True + return conn, True - async def _release_db (self ,db :aiosqlite .Connection ,should_manage :bool ,exc_type =None ,exc_val =None ,exc_tb =None ): + async def _release_db(self, + db: aiosqlite.Connection, + should_manage: bool, + exc_type=None, + exc_val=None, + exc_tb=None): """Helper to release/close a DB connection if this service instance opened it.""" - if should_manage : - await db .__aexit__ (exc_type ,exc_val ,exc_tb ) + if should_manage: + await db.__aexit__(exc_type, exc_val, exc_tb) - async def get_user_language (self ,user_id :int )->str : + async def get_user_language(self, user_id: int) -> str: """Fetches the user's language preference from the local database.""" - db ,should_manage =await self ._get_db () - try : - user_record =await get_user (user_id ,db_conn =db ) - default_lang =self .settings .DEFAULT_LANGUAGE - return user_record ['language_code']if user_record and 'language_code'in user_record .keys ()and user_record ['language_code']else default_lang - finally : - await self ._release_db (db ,should_manage ) + db, should_manage = await self._get_db() + try: + user_record = await get_user(user_id, db_conn=db) + default_lang = self.settings.DEFAULT_LANGUAGE + return user_record[ + 'language_code'] if user_record and 'language_code' in user_record.keys( + ) and user_record['language_code'] else default_lang + finally: + await self._release_db(db, should_manage) - async def get_panel_user_uuid (self ,user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [str ]: + async def get_panel_user_uuid( + self, + user_id: int, + db_conn: Optional[aiosqlite.Connection] = None) -> Optional[str]: """Fetches panel_user_uuid for a given Telegram user_id from local DB.""" - db_to_use ,should_manage_this_conn =await self ._get_db (db_conn ) - try : - user_record =await get_user (user_id ,db_conn =db_to_use ) - return user_record ['panel_user_uuid']if user_record and 'panel_user_uuid'in user_record .keys ()and user_record ['panel_user_uuid']else None - finally : - await self ._release_db (db_to_use ,should_manage_this_conn ) + db_to_use, should_manage_this_conn = await self._get_db(db_conn) + try: + user_record = await get_user(user_id, db_conn=db_to_use) + return user_record[ + 'panel_user_uuid'] if user_record and 'panel_user_uuid' in user_record.keys( + ) and user_record['panel_user_uuid'] else None + finally: + await self._release_db(db_to_use, should_manage_this_conn) - async def has_had_any_subscription (self ,user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->bool : + async def has_had_any_subscription( + self, + user_id: int, + db_conn: Optional[aiosqlite.Connection] = None) -> bool: """Checks if the user has any record in the subscriptions table.""" - db_to_use ,should_manage_this_conn =await self ._get_db (db_conn ) - try : - return await db_has_had_any_subscription (user_id ,db_conn =db_to_use ) - finally : - await self ._release_db (db_to_use ,should_manage_this_conn ) + db_to_use, should_manage_this_conn = await self._get_db(db_conn) + try: + return await db_has_had_any_subscription(user_id, + db_conn=db_to_use) + finally: + await self._release_db(db_to_use, should_manage_this_conn) - async def activate_trial_subscription (self ,user_id :int )->Optional [Dict [str ,Any ]]: + async def activate_trial_subscription( + self, user_id: int) -> Optional[Dict[str, Any]]: """ Activates a trial subscription for an eligible user. Manages its own database connection and transaction. Returns dict with trial details or specific error dict on failure/ineligibility. """ - if not self .settings .TRIAL_ENABLED or self .settings .TRIAL_DURATION_DAYS <=0 : - logging .info (f"Trial subscription feature is disabled or duration invalid for user {user_id}.") - return {"eligible":False ,"activated":False ,"message_key":"trial_feature_disabled"} + if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0: + logging.info( + f"Trial subscription feature is disabled or duration invalid for user {user_id}." + ) + return { + "eligible": False, + "activated": False, + "message_key": "trial_feature_disabled" + } - async with self .db_conn_provider ()as db : - await _setup_db_connection (db ) - try : - if await db_has_had_any_subscription (user_id ,db_conn =db ): - logging .info (f"User {user_id} has prior subscriptions. Trial not applicable.") - return {"eligible":False ,"activated":False ,"message_key":"trial_not_eligible_already_subscribed"} - - panel_user_uuid =await self .get_panel_user_uuid (user_id ,db_conn =db ) - panel_subscription_uuid_for_link =None ;panel_short_uuid_for_link =None - panel_user_interacted_now =False ;specific_inbounds =self .settings .parsed_default_panel_user_inbound_uuids - panel_actual_subscription_url :Optional [str ]=None - - if not panel_user_uuid : - logging .info (f"No panel_user_uuid for TG user_id {user_id} for trial. Creating panel user.") - panel_username_to_create =f"tg_{user_id}" - creation_response =await self .panel_service .create_panel_user ( - username =panel_username_to_create ,telegram_id =user_id , - default_expire_days =self .settings .TRIAL_DURATION_DAYS , - default_traffic_limit_bytes =self .settings .trial_traffic_limit_bytes , - default_traffic_limit_strategy =self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY , - specific_inbound_uuids =specific_inbounds , - activate_all_inbounds_default_flag =False if specific_inbounds else True + async with self.db_conn_provider() as db: + await _setup_db_connection(db) + try: + if await db_has_had_any_subscription(user_id, db_conn=db): + logging.info( + f"User {user_id} has prior subscriptions. Trial not applicable." ) - panel_user_obj_from_api =None - if creation_response and not creation_response .get ("error"):panel_user_obj_from_api =creation_response .get ("response") - elif creation_response and creation_response .get ("errorCode")=="A019": - existing_users_list =await self .panel_service .get_users_by_filter (username =panel_username_to_create ) - if existing_users_list and len (existing_users_list )==1 :panel_user_obj_from_api =existing_users_list [0 ] + return { + "eligible": False, + "activated": False, + "message_key": "trial_not_eligible_already_subscribed" + } - if panel_user_obj_from_api and panel_user_obj_from_api .get ('uuid'): - panel_user_uuid =panel_user_obj_from_api ['uuid'] - panel_subscription_uuid_for_link =panel_user_obj_from_api .get ('subscriptionUuid') - panel_short_uuid_for_link =panel_user_obj_from_api .get ('shortUuid') - panel_actual_subscription_url =panel_user_obj_from_api .get ('subscriptionUrl') - await db .execute ("UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",(panel_user_uuid ,user_id )) - panel_user_interacted_now =True - if panel_user_obj_from_api .get ('telegramId')!=user_id : - await self .panel_service .update_user_details_on_panel (panel_user_uuid ,{"telegramId":user_id }) - else : - logging .error (f"Failed to create/link panel user for trial (TG_ID {user_id}). Resp: {creation_response if 'creation_response' in locals() else 'N/A'}") - await db .rollback () - return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"} - else : - panel_user_data =await self .panel_service .get_user_by_uuid (panel_user_uuid ) - if panel_user_data : - panel_subscription_uuid_for_link =panel_user_data .get ('subscriptionUuid') - panel_short_uuid_for_link =panel_user_data .get ('shortUuid') - panel_actual_subscription_url =panel_user_data .get ('subscriptionUrl') + panel_user_uuid = await self.get_panel_user_uuid(user_id, + db_conn=db) + panel_subscription_uuid_for_link = None + panel_short_uuid_for_link = None + panel_user_interacted_now = False + specific_inbounds = self.settings.parsed_default_panel_user_inbound_uuids + panel_actual_subscription_url: Optional[str] = None - if not panel_subscription_uuid_for_link and panel_short_uuid_for_link : - panel_subscription_uuid_for_link =panel_short_uuid_for_link - if not panel_subscription_uuid_for_link : - logging .error (f"Critical: panel_subscription_uuid for link is None for trial (panel_uuid {panel_user_uuid}).") - await db .rollback () - return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"} + if not panel_user_uuid: + logging.info( + f"No panel_user_uuid for TG user_id {user_id} for trial. Creating panel user." + ) + panel_username_to_create = f"tg_{user_id}" + creation_response = await self.panel_service.create_panel_user( + username=panel_username_to_create, + telegram_id=user_id, + default_expire_days=self.settings.TRIAL_DURATION_DAYS, + default_traffic_limit_bytes=self.settings. + trial_traffic_limit_bytes, + default_traffic_limit_strategy=self.settings. + PANEL_USER_DEFAULT_TRAFFIC_STRATEGY, + specific_inbound_uuids=specific_inbounds, + activate_all_inbounds_default_flag=False + if specific_inbounds else True) + panel_user_obj_from_api = None + if creation_response and not creation_response.get( + "error"): + panel_user_obj_from_api = creation_response.get( + "response") + elif creation_response and creation_response.get( + "errorCode") == "A019": + existing_users_list = await self.panel_service.get_users_by_filter( + username=panel_username_to_create) + if existing_users_list and len( + existing_users_list) == 1: + panel_user_obj_from_api = existing_users_list[0] - start_date =datetime .now (timezone .utc ) - end_date =start_date +timedelta (days =self .settings .TRIAL_DURATION_DAYS ) - await db .execute ("UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",(panel_user_uuid ,)) - 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) VALUES (?, ?, ?, ?, ?, 0, 1, 'TRIAL', ?) 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 = 0, is_active = 1, status_from_panel = 'TRIAL', traffic_limit_bytes = excluded.traffic_limit_bytes, last_notification_sent = NULL; """ - trial_traffic_val =self .settings .trial_traffic_limit_bytes - params =(user_id ,panel_user_uuid ,panel_subscription_uuid_for_link ,start_date .isoformat (),end_date .isoformat (),trial_traffic_val ) + if panel_user_obj_from_api and panel_user_obj_from_api.get( + 'uuid'): + panel_user_uuid = panel_user_obj_from_api['uuid'] + panel_subscription_uuid_for_link = panel_user_obj_from_api.get( + 'subscriptionUuid') + panel_short_uuid_for_link = panel_user_obj_from_api.get( + 'shortUuid') + panel_actual_subscription_url = panel_user_obj_from_api.get( + 'subscriptionUrl') + await db.execute( + "UPDATE users SET panel_user_uuid = ? WHERE user_id = ?", + (panel_user_uuid, user_id)) + panel_user_interacted_now = True + if panel_user_obj_from_api.get( + 'telegramId') != user_id: + await self.panel_service.update_user_details_on_panel( + panel_user_uuid, {"telegramId": user_id}) + else: + logging.error( + f"Failed to create/link panel user for trial (TG_ID {user_id}). Resp: {creation_response if 'creation_response' in locals() else 'N/A'}" + ) + await db.rollback() + return { + "eligible": True, + "activated": False, + "message_key": "trial_activation_failed" + } + else: + panel_user_data = await self.panel_service.get_user_by_uuid( + panel_user_uuid) + if panel_user_data: + panel_subscription_uuid_for_link = panel_user_data.get( + 'subscriptionUuid') + panel_short_uuid_for_link = panel_user_data.get( + 'shortUuid') + panel_actual_subscription_url = panel_user_data.get( + 'subscriptionUrl') - upsert_cursor =await db .execute (upsert_sql ,params ) - trial_subscription_id =upsert_cursor .lastrowid - if not trial_subscription_id or trial_subscription_id ==0 : - id_fetch_cursor =await db .execute ("SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",(panel_subscription_uuid_for_link ,)) - id_fetch_row =await id_fetch_cursor .fetchone () - if id_fetch_cursor :await id_fetch_cursor .close () - if id_fetch_row :trial_subscription_id =id_fetch_row ['subscription_id'] - logging .info (f"Local trial subscription (ID: {trial_subscription_id}) for user {user_id} prepared. Ends: {end_date.isoformat()}.") + if not panel_subscription_uuid_for_link and panel_short_uuid_for_link: + panel_subscription_uuid_for_link = panel_short_uuid_for_link + if not panel_subscription_uuid_for_link: + logging.error( + f"Critical: panel_subscription_uuid for link is None for trial (panel_uuid {panel_user_uuid})." + ) + await db.rollback() + return { + "eligible": True, + "activated": False, + "message_key": "trial_activation_failed" + } - panel_update_payload :Dict [str ,Any ]={"uuid":panel_user_uuid ,"expireAt":end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z'),"status":"ACTIVE","trafficLimitBytes":trial_traffic_val ,"trafficLimitStrategy":self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY ,} - if specific_inbounds :panel_update_payload ["activeUserInbounds"]=specific_inbounds - elif panel_user_interacted_now :panel_update_payload ["activateAllInbounds"]=True + start_date = datetime.now(timezone.utc) + end_date = start_date + timedelta( + days=self.settings.TRIAL_DURATION_DAYS) + await db.execute( + "UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1", + (panel_user_uuid, )) + 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) VALUES (?, ?, ?, ?, ?, 0, 1, 'TRIAL', ?) 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 = 0, is_active = 1, status_from_panel = 'TRIAL', traffic_limit_bytes = excluded.traffic_limit_bytes, last_notification_sent = NULL; """ + trial_traffic_val = self.settings.trial_traffic_limit_bytes + params = (user_id, panel_user_uuid, + panel_subscription_uuid_for_link, + start_date.isoformat(), end_date.isoformat(), + trial_traffic_val) - updated_panel_user =await self .panel_service .update_user_details_on_panel (panel_user_uuid ,panel_update_payload ) - if not updated_panel_user :logging .warning (f"Panel user details update FAILED for trial user {panel_user_uuid}.") - else : - logging .info (f"Panel user {panel_user_uuid} details updated for trial. Panel ExpireAt: {updated_panel_user.get('expireAt')}") - if updated_panel_user .get ('subscriptionUrl'):panel_actual_subscription_url =updated_panel_user .get ('subscriptionUrl') - if updated_panel_user .get ('shortUuid'):panel_short_uuid_for_link =updated_panel_user .get ('shortUuid') + upsert_cursor = await db.execute(upsert_sql, params) + trial_subscription_id = upsert_cursor.lastrowid + if not trial_subscription_id or trial_subscription_id == 0: + id_fetch_cursor = await db.execute( + "SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?", + (panel_subscription_uuid_for_link, )) + id_fetch_row = await id_fetch_cursor.fetchone() + if id_fetch_cursor: await id_fetch_cursor.close() + if id_fetch_row: + trial_subscription_id = id_fetch_row['subscription_id'] + logging.info( + f"Local trial subscription (ID: {trial_subscription_id}) for user {user_id} prepared. Ends: {end_date.isoformat()}." + ) - await db .commit () - return {"eligible":True ,"activated":True ,"end_date":end_date ,"days":self .settings .TRIAL_DURATION_DAYS ,"traffic_gb":self .settings .TRIAL_TRAFFIC_LIMIT_GB ,"panel_user_uuid":panel_user_uuid ,"panel_short_uuid":panel_short_uuid_for_link ,"subscription_url":panel_actual_subscription_url } - except Exception as e : - logging .error (f"Error activating trial for user {user_id}: {e}",exc_info =True ) - await db .rollback () - return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"} + panel_update_payload: Dict[str, Any] = { + "uuid": + panel_user_uuid, + "expireAt": + end_date.isoformat(timespec='milliseconds').replace( + '+00:00', 'Z'), + "status": + "ACTIVE", + "trafficLimitBytes": + trial_traffic_val, + "trafficLimitStrategy": + self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY, + } + if specific_inbounds: + panel_update_payload[ + "activeUserInbounds"] = specific_inbounds + elif panel_user_interacted_now: + panel_update_payload["activateAllInbounds"] = True - async def activate_subscription ( - self ,user_id :int ,months :int ,payment_amount :float , - payment_id_internal :int ,db_conn :aiosqlite .Connection , - promo_code_id :Optional [int ]=None - )->Optional [Dict [str ,Any ]]: - db =db_conn - try : - panel_user_uuid =await self .get_panel_user_uuid (user_id ,db_conn =db ) - panel_subscription_uuid_for_link =None ;panel_short_uuid_for_link =None ;panel_user_interacted_now =False - panel_actual_subscription_url :Optional [str ]=None - specific_inbounds_from_settings =self .settings .parsed_default_panel_user_inbound_uuids - if not panel_user_uuid : - panel_username_to_create =f"tg_{user_id}" - creation_response =await self .panel_service .create_panel_user (username =panel_username_to_create ,telegram_id =user_id ,default_expire_days =self .settings .PANEL_USER_DEFAULT_EXPIRE_DAYS ,default_traffic_limit_bytes =self .settings .PANEL_USER_DEFAULT_TRAFFIC_BYTES ,default_traffic_limit_strategy =self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY ,specific_inbound_uuids =specific_inbounds_from_settings ,activate_all_inbounds_default_flag =False if specific_inbounds_from_settings else True ) - panel_user_object_from_api =None - if creation_response and not creation_response .get ("error"):panel_user_object_from_api =creation_response .get ("response") - elif creation_response and creation_response .get ("errorCode")=="A019": - existing_users_list =await self .panel_service .get_users_by_filter (username =panel_username_to_create ) - if existing_users_list and len (existing_users_list )==1 :panel_user_object_from_api =existing_users_list [0 ] - if panel_user_object_from_api and panel_user_object_from_api .get ('uuid'): - panel_user_uuid =panel_user_object_from_api ['uuid'];panel_subscription_uuid_for_link =panel_user_object_from_api .get ('subscriptionUuid');panel_short_uuid_for_link =panel_user_object_from_api .get ('shortUuid');panel_actual_subscription_url =panel_user_object_from_api .get ('subscriptionUrl') - cursor_conflict =await db .execute ("SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?",(panel_user_uuid ,user_id ));conflicting_tg_user =await cursor_conflict .fetchone ();await cursor_conflict .close () - if conflicting_tg_user :logging .error (f"CRITICAL CONFLICT: Panel UUID {panel_user_uuid} already linked to TG user {conflicting_tg_user['user_id']}.");return None - await db .execute ("UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",(panel_user_uuid ,user_id ));panel_user_interacted_now =True - if panel_user_object_from_api .get ('telegramId')!=user_id :await self .panel_service .update_user_details_on_panel (panel_user_uuid ,{"telegramId":user_id }) - else :logging .error (f"Failed to create/link panel user for TG_ID {user_id}. Resp: {creation_response if 'creation_response' in locals() else 'N/A'}");return None - else : - panel_user_data =await self .panel_service .get_user_by_uuid (panel_user_uuid ) - if panel_user_data :panel_subscription_uuid_for_link =panel_user_data .get ('subscriptionUuid');panel_short_uuid_for_link =panel_user_data .get ('shortUuid');panel_actual_subscription_url =panel_user_data .get ('subscriptionUrl') - if not panel_subscription_uuid_for_link and panel_short_uuid_for_link :panel_subscription_uuid_for_link =panel_short_uuid_for_link - if not panel_subscription_uuid_for_link :logging .error (f"Critical: panel_subscription_uuid for link is None for panel_user_uuid {panel_user_uuid}.");return None + updated_panel_user = await self.panel_service.update_user_details_on_panel( + panel_user_uuid, panel_update_payload) + if not updated_panel_user: + logging.warning( + f"Panel user details update FAILED for trial user {panel_user_uuid}." + ) + else: + logging.info( + f"Panel user {panel_user_uuid} details updated for trial. Panel ExpireAt: {updated_panel_user.get('expireAt')}" + ) + if updated_panel_user.get('subscriptionUrl'): + panel_actual_subscription_url = updated_panel_user.get( + 'subscriptionUrl') + if updated_panel_user.get('shortUuid'): + panel_short_uuid_for_link = updated_panel_user.get( + 'shortUuid') - cursor =await db .execute ("SELECT subscription_id, end_date FROM subscriptions WHERE panel_user_uuid = ? AND is_active = 1 ORDER BY end_date DESC LIMIT 1",(panel_user_uuid ,));current_sub_row =await cursor .fetchone ();await cursor .close () - current_sub_end_date_str =current_sub_row ['end_date']if current_sub_row else None ;start_date =datetime .now (timezone .utc ) - if current_sub_end_date_str : - try : - parsed_current_end_date =datetime .fromisoformat (current_sub_end_date_str .replace ("Z","+00:00")) - if parsed_current_end_date .tzinfo is None :parsed_current_end_date =parsed_current_end_date .replace (tzinfo =timezone .utc ) - if parsed_current_end_date >start_date :start_date =parsed_current_end_date - except ValueError :logging .warning (f"Bad current_sub_end_date string: {current_sub_end_date_str} for panel_user {panel_user_uuid}.") - final_end_date =start_date +timedelta (days =months *30 ) - if promo_code_id : - promo_cursor =await db .execute ("SELECT bonus_days FROM promo_codes WHERE promo_code_id = ?",(promo_code_id ,));promo_row =await promo_cursor .fetchone ();await promo_cursor .close () - if promo_row :final_end_date +=timedelta (days =promo_row ['bonus_days']);await db .execute ("INSERT OR IGNORE INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)",(promo_code_id ,user_id ,payment_id_internal ));await db .execute ("UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?",(promo_code_id ,)) + await db.commit() + return { + "eligible": True, + "activated": True, + "end_date": end_date, + "days": self.settings.TRIAL_DURATION_DAYS, + "traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB, + "panel_user_uuid": panel_user_uuid, + "panel_short_uuid": panel_short_uuid_for_link, + "subscription_url": panel_actual_subscription_url + } + except Exception as e: + logging.error( + f"Error activating trial for user {user_id}: {e}", + exc_info=True) + await db.rollback() + return { + "eligible": True, + "activated": False, + "message_key": "trial_activation_failed" + } - await db .execute ("UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",(panel_user_uuid ,)) - upsert_sql =""" INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel) VALUES (?, ?, ?, ?, ?, ?, 1, 'ACTIVE') 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 = 1, status_from_panel = 'ACTIVE', last_notification_sent = NULL; """ - params =(user_id ,panel_user_uuid ,panel_subscription_uuid_for_link ,start_date .isoformat (),final_end_date .isoformat (),months ) - upsert_cursor =await db .execute (upsert_sql ,params );subscription_id_to_return =upsert_cursor .lastrowid - if not subscription_id_to_return or subscription_id_to_return ==0 : - id_cursor =await db .execute ("SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",(panel_subscription_uuid_for_link ,));id_row =await id_cursor .fetchone ();await id_cursor .close () - if id_row :subscription_id_to_return =id_row ['subscription_id'] - logging .info (f"Local subscription UPSERTED (ID: {subscription_id_to_return}) for user {user_id}. Ends: {final_end_date.isoformat()}.") - - panel_update_payload :Dict [str ,Any ]={"uuid":panel_user_uuid ,"expireAt":final_end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z'),"status":"ACTIVE","trafficLimitBytes":self .settings .PANEL_USER_DEFAULT_TRAFFIC_BYTES ,"trafficLimitStrategy":self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY } - if specific_inbounds_from_settings :panel_update_payload ["activeUserInbounds"]=specific_inbounds_from_settings - elif panel_user_interacted_now :panel_update_payload ["activateAllInbounds"]=True - if "activateAllInbounds"in panel_update_payload and not specific_inbounds_from_settings and not panel_user_interacted_now :del panel_update_payload ["activateAllInbounds"] - logging .info (f"Attempting to update panel user {panel_user_uuid} for paid sub: {panel_update_payload}") - updated_panel_user =await self .panel_service .update_user_details_on_panel (panel_user_uuid ,panel_update_payload ) - if not updated_panel_user :logging .warning (f"Panel user details update FAILED for {panel_user_uuid}.") - else : - logging .info (f"Panel user {panel_user_uuid} details updated. Panel ExpireAt: {updated_panel_user.get('expireAt')}") - if updated_panel_user .get ('subscriptionUrl'):panel_actual_subscription_url =updated_panel_user .get ('subscriptionUrl') - if updated_panel_user .get ('shortUuid'):panel_short_uuid_for_link =updated_panel_user .get ('shortUuid') - return {"subscription_id":subscription_id_to_return ,"end_date":final_end_date ,"is_active":True ,"panel_user_uuid":panel_user_uuid ,"panel_short_uuid":panel_short_uuid_for_link ,"subscription_url":panel_actual_subscription_url } - except Exception as e :logging .error (f"Error in activate_subscription (paid) for user {user_id}: {e}",exc_info =True );return None - - async def extend_active_subscription_days (self ,user_id :int ,bonus_days :int ,db_conn :aiosqlite .Connection ,reason :str ="bonus")->Optional [datetime ]: - db =db_conn - try : - user_cursor =await db .execute ("SELECT panel_user_uuid FROM users WHERE user_id = ?",(user_id ,)) - user_panel_data =await user_cursor .fetchone () - if user_cursor :await user_cursor .close () - panel_user_uuid_for_update =user_panel_data ['panel_user_uuid']if user_panel_data and 'panel_user_uuid'in user_panel_data .keys ()and user_panel_data ['panel_user_uuid']else None - - sql_select_active_sub ="SELECT subscription_id, end_date FROM subscriptions WHERE user_id = ? AND is_active = 1 " - params_select_active_sub :tuple =(user_id ,) - if panel_user_uuid_for_update : - sql_select_active_sub +="AND panel_user_uuid = ? " - params_select_active_sub +=(panel_user_uuid_for_update ,) - else : - logging .warning (f"Extending subscription for user {user_id} without panel_user_uuid. This might be ambiguous if user has multiple panel accounts linked to one TG ID (not typical).") - sql_select_active_sub +="ORDER BY end_date DESC LIMIT 1" - - cursor =await db .execute (sql_select_active_sub ,params_select_active_sub ) - active_sub_row =await cursor .fetchone () - if cursor :await cursor .close () - - if not active_sub_row or not active_sub_row ['end_date']: - logging .info (f"No active subscription found for user {user_id} (panel UUID: {panel_user_uuid_for_update}) to extend with {reason} bonus.") + async def activate_subscription( + self, + user_id: int, + months: int, + payment_amount: float, + payment_id_internal: int, + db_conn: aiosqlite.Connection, + promo_code_id: Optional[int] = None) -> Optional[Dict[str, Any]]: + db = db_conn + try: + panel_user_uuid = await self.get_panel_user_uuid(user_id, + db_conn=db) + panel_subscription_uuid_for_link = None + panel_short_uuid_for_link = None + panel_user_interacted_now = False + panel_actual_subscription_url: Optional[str] = None + specific_inbounds_from_settings = self.settings.parsed_default_panel_user_inbound_uuids + if not panel_user_uuid: + panel_username_to_create = f"tg_{user_id}" + creation_response = await self.panel_service.create_panel_user( + username=panel_username_to_create, + telegram_id=user_id, + default_expire_days=self.settings. + PANEL_USER_DEFAULT_EXPIRE_DAYS, + default_traffic_limit_bytes=self.settings. + PANEL_USER_DEFAULT_TRAFFIC_BYTES, + default_traffic_limit_strategy=self.settings. + PANEL_USER_DEFAULT_TRAFFIC_STRATEGY, + specific_inbound_uuids=specific_inbounds_from_settings, + activate_all_inbounds_default_flag=False + if specific_inbounds_from_settings else True) + panel_user_object_from_api = None + if creation_response and not creation_response.get("error"): + panel_user_object_from_api = creation_response.get( + "response") + elif creation_response and creation_response.get( + "errorCode") == "A019": + existing_users_list = await self.panel_service.get_users_by_filter( + username=panel_username_to_create) + if existing_users_list and len(existing_users_list) == 1: + panel_user_object_from_api = existing_users_list[0] + if panel_user_object_from_api and panel_user_object_from_api.get( + 'uuid'): + panel_user_uuid = panel_user_object_from_api['uuid'] + panel_subscription_uuid_for_link = panel_user_object_from_api.get( + 'subscriptionUuid') + panel_short_uuid_for_link = panel_user_object_from_api.get( + 'shortUuid') + panel_actual_subscription_url = panel_user_object_from_api.get( + 'subscriptionUrl') + cursor_conflict = await db.execute( + "SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?", + (panel_user_uuid, user_id)) + conflicting_tg_user = await cursor_conflict.fetchone() + await cursor_conflict.close() + if conflicting_tg_user: + logging.error( + f"CRITICAL CONFLICT: Panel UUID {panel_user_uuid} already linked to TG user {conflicting_tg_user['user_id']}." + ) + return None + await db.execute( + "UPDATE users SET panel_user_uuid = ? WHERE user_id = ?", + (panel_user_uuid, user_id)) + panel_user_interacted_now = True + if panel_user_object_from_api.get('telegramId') != user_id: + await self.panel_service.update_user_details_on_panel( + panel_user_uuid, {"telegramId": user_id}) + else: + logging.error( + f"Failed to create/link panel user for TG_ID {user_id}. Resp: {creation_response if 'creation_response' in locals() else 'N/A'}" + ) + return None + else: + panel_user_data = await self.panel_service.get_user_by_uuid( + panel_user_uuid) + if panel_user_data: + panel_subscription_uuid_for_link = panel_user_data.get( + 'subscriptionUuid') + panel_short_uuid_for_link = panel_user_data.get( + 'shortUuid') + panel_actual_subscription_url = panel_user_data.get( + 'subscriptionUrl') + if not panel_subscription_uuid_for_link and panel_short_uuid_for_link: + panel_subscription_uuid_for_link = panel_short_uuid_for_link + if not panel_subscription_uuid_for_link: + logging.error( + f"Critical: panel_subscription_uuid for link is None for panel_user_uuid {panel_user_uuid}." + ) return None - current_end_date_str =active_sub_row ['end_date'] - try : - current_end_date =datetime .fromisoformat (current_end_date_str .replace ("Z","+00:00"))if isinstance (current_end_date_str ,str )else current_end_date_str - if not isinstance (current_end_date ,datetime ): - raise ValueError ("current_end_date is not a datetime object after parsing") - except ValueError as ve : - logging .error (f"Error parsing current_end_date '{current_end_date_str}' for user {user_id}: {ve}") - return None + cursor = await db.execute( + "SELECT subscription_id, end_date FROM subscriptions WHERE panel_user_uuid = ? AND is_active = 1 ORDER BY end_date DESC LIMIT 1", + (panel_user_uuid, )) + current_sub_row = await cursor.fetchone() + await cursor.close() + current_sub_end_date_str = current_sub_row[ + 'end_date'] if current_sub_row else None + start_date = datetime.now(timezone.utc) + if current_sub_end_date_str: + try: + parsed_current_end_date = datetime.fromisoformat( + current_sub_end_date_str.replace("Z", "+00:00")) + if parsed_current_end_date.tzinfo is None: + parsed_current_end_date = parsed_current_end_date.replace( + tzinfo=timezone.utc) + if parsed_current_end_date > start_date: + start_date = parsed_current_end_date + except ValueError: + logging.warning( + f"Bad current_sub_end_date string: {current_sub_end_date_str} for panel_user {panel_user_uuid}." + ) + final_end_date = start_date + timedelta(days=months * 30) + if promo_code_id: + promo_cursor = await db.execute( + "SELECT bonus_days FROM promo_codes WHERE promo_code_id = ?", + (promo_code_id, )) + promo_row = await promo_cursor.fetchone() + await promo_cursor.close() + if promo_row: + final_end_date += timedelta(days=promo_row['bonus_days']) + await db.execute( + "INSERT OR IGNORE INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)", + (promo_code_id, user_id, payment_id_internal)) + await db.execute( + "UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?", + (promo_code_id, )) - if current_end_date .tzinfo is None : - current_end_date =current_end_date .replace (tzinfo =timezone .utc ) + await db.execute( + "UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1", + (panel_user_uuid, )) + upsert_sql = """ INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel) VALUES (?, ?, ?, ?, ?, ?, 1, 'ACTIVE') 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 = 1, status_from_panel = 'ACTIVE', last_notification_sent = NULL; """ + params = (user_id, panel_user_uuid, + panel_subscription_uuid_for_link, start_date.isoformat(), + final_end_date.isoformat(), months) + upsert_cursor = await db.execute(upsert_sql, params) + subscription_id_to_return = upsert_cursor.lastrowid + if not subscription_id_to_return or subscription_id_to_return == 0: + id_cursor = await db.execute( + "SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?", + (panel_subscription_uuid_for_link, )) + id_row = await id_cursor.fetchone() + await id_cursor.close() + if id_row: + subscription_id_to_return = id_row['subscription_id'] + logging.info( + f"Local subscription UPSERTED (ID: {subscription_id_to_return}) for user {user_id}. Ends: {final_end_date.isoformat()}." + ) - now_utc =datetime .now (timezone .utc ) - start_point_for_bonus =current_end_date if current_end_date >now_utc else now_utc - new_end_date =start_point_for_bonus +timedelta (days =bonus_days ) - - await db .execute ("UPDATE subscriptions SET end_date = ?, last_notification_sent = NULL WHERE subscription_id = ?",(new_end_date .isoformat (),active_sub_row ['subscription_id'])) - logging .info (f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date.isoformat()}") - - if panel_user_uuid_for_update : - panel_update_payload ={"uuid":panel_user_uuid_for_update ,"expireAt":new_end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z')} - if not await self .panel_service .update_user_details_on_panel (panel_user_uuid_for_update ,panel_update_payload ): - logging .warning (f"Failed to update panel expiry for {panel_user_uuid_for_update} after {reason} bonus.") - return new_end_date - except Exception as e : - logging .error (f"Error extending subscription with {reason} bonus for user {user_id} (using provided db_conn): {e}",exc_info =True ) + panel_update_payload: Dict[str, Any] = { + "uuid": + panel_user_uuid, + "expireAt": + final_end_date.isoformat(timespec='milliseconds').replace( + '+00:00', 'Z'), + "status": + "ACTIVE", + "trafficLimitBytes": + self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES, + "trafficLimitStrategy": + self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY + } + if specific_inbounds_from_settings: + panel_update_payload[ + "activeUserInbounds"] = specific_inbounds_from_settings + elif panel_user_interacted_now: + panel_update_payload["activateAllInbounds"] = True + if "activateAllInbounds" in panel_update_payload and not specific_inbounds_from_settings and not panel_user_interacted_now: + del panel_update_payload["activateAllInbounds"] + logging.info( + f"Attempting to update panel user {panel_user_uuid} for paid sub: {panel_update_payload}" + ) + updated_panel_user = await self.panel_service.update_user_details_on_panel( + panel_user_uuid, panel_update_payload) + if not updated_panel_user: + logging.warning( + f"Panel user details update FAILED for {panel_user_uuid}.") + else: + logging.info( + f"Panel user {panel_user_uuid} details updated. Panel ExpireAt: {updated_panel_user.get('expireAt')}" + ) + if updated_panel_user.get('subscriptionUrl'): + panel_actual_subscription_url = updated_panel_user.get( + 'subscriptionUrl') + if updated_panel_user.get('shortUuid'): + panel_short_uuid_for_link = updated_panel_user.get( + 'shortUuid') + return { + "subscription_id": subscription_id_to_return, + "end_date": final_end_date, + "is_active": True, + "panel_user_uuid": panel_user_uuid, + "panel_short_uuid": panel_short_uuid_for_link, + "subscription_url": panel_actual_subscription_url + } + except Exception as e: + logging.error( + f"Error in activate_subscription (paid) for user {user_id}: {e}", + exc_info=True) return None - async def extend_subscription_for_referral (self ,user_id :int ,bonus_days :int ,db_conn :aiosqlite .Connection ,is_referee_bonus :bool =False )->Optional [datetime ]: - reason ="referee bonus"if is_referee_bonus else "inviter referral bonus" - return await self .extend_active_subscription_days (user_id ,bonus_days ,db_conn ,reason =reason ) + async def extend_active_subscription_days( + self, + user_id: int, + bonus_days: int, + db_conn: aiosqlite.Connection, + reason: str = "bonus") -> Optional[datetime]: + db = db_conn + try: + user_cursor = await db.execute( + "SELECT panel_user_uuid FROM users WHERE user_id = ?", + (user_id, )) + user_panel_data = await user_cursor.fetchone() + if user_cursor: await user_cursor.close() + panel_user_uuid_for_update = user_panel_data[ + 'panel_user_uuid'] if user_panel_data and 'panel_user_uuid' in user_panel_data.keys( + ) and user_panel_data['panel_user_uuid'] else None - async def get_active_subscription (self ,user_id :int )->Optional [Dict [str ,Any ]]: - db ,should_manage =await self ._get_db () - try : - now_iso_utc =datetime .now (timezone .utc ).isoformat () - cursor =await db .execute ("""SELECT s.subscription_id, s.panel_subscription_uuid, s.panel_user_uuid, s.start_date, s.end_date, s.duration_months, s.is_active, s.status_from_panel, s.traffic_limit_bytes, s.traffic_used_bytes, u.username as bot_username FROM subscriptions s LEFT JOIN users u ON s.user_id = u.user_id WHERE s.user_id = ? AND s.is_active = 1 AND s.end_date > ? ORDER BY s.end_date DESC LIMIT 1""",(user_id ,now_iso_utc )) - sub_row =await cursor .fetchone ();await cursor .close () - if sub_row : - sub_dict =dict (sub_row ) - for date_key in ['start_date','end_date']: - if sub_dict .get (date_key )and isinstance (sub_dict [date_key ],str ): - try :sub_dict [date_key ]=datetime .fromisoformat (sub_dict [date_key ].replace ("Z","+00:00")) - except ValueError :logging .warning (f"Could not parse date string {sub_dict[date_key]} for key {date_key} in get_active_subscription") + sql_select_active_sub = "SELECT subscription_id, end_date FROM subscriptions WHERE user_id = ? AND is_active = 1 " + params_select_active_sub: tuple = (user_id, ) + if panel_user_uuid_for_update: + sql_select_active_sub += "AND panel_user_uuid = ? " + params_select_active_sub += (panel_user_uuid_for_update, ) + else: + logging.warning( + f"Extending subscription for user {user_id} without panel_user_uuid. This might be ambiguous if user has multiple panel accounts linked to one TG ID (not typical)." + ) + sql_select_active_sub += "ORDER BY end_date DESC LIMIT 1" + + cursor = await db.execute(sql_select_active_sub, + params_select_active_sub) + active_sub_row = await cursor.fetchone() + if cursor: await cursor.close() + + if not active_sub_row or not active_sub_row['end_date']: + logging.info( + f"No active subscription found for user {user_id} (panel UUID: {panel_user_uuid_for_update}) to extend with {reason} bonus." + ) + return None + + current_end_date_str = active_sub_row['end_date'] + try: + current_end_date = datetime.fromisoformat( + current_end_date_str.replace("Z", "+00:00")) if isinstance( + current_end_date_str, str) else current_end_date_str + if not isinstance(current_end_date, datetime): + raise ValueError( + "current_end_date is not a datetime object after parsing" + ) + except ValueError as ve: + logging.error( + f"Error parsing current_end_date '{current_end_date_str}' for user {user_id}: {ve}" + ) + return None + + if current_end_date.tzinfo is None: + current_end_date = current_end_date.replace( + tzinfo=timezone.utc) + + now_utc = datetime.now(timezone.utc) + start_point_for_bonus = current_end_date if current_end_date > now_utc else now_utc + new_end_date = start_point_for_bonus + timedelta(days=bonus_days) + + await db.execute( + "UPDATE subscriptions SET end_date = ?, last_notification_sent = NULL WHERE subscription_id = ?", + (new_end_date.isoformat(), active_sub_row['subscription_id'])) + logging.info( + f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date.isoformat()}" + ) + + if panel_user_uuid_for_update: + panel_update_payload = { + "uuid": + panel_user_uuid_for_update, + "expireAt": + new_end_date.isoformat(timespec='milliseconds').replace( + '+00:00', 'Z') + } + if not await self.panel_service.update_user_details_on_panel( + panel_user_uuid_for_update, panel_update_payload): + logging.warning( + f"Failed to update panel expiry for {panel_user_uuid_for_update} after {reason} bonus." + ) + return new_end_date + except Exception as e: + logging.error( + f"Error extending subscription with {reason} bonus for user {user_id} (using provided db_conn): {e}", + exc_info=True) + return None + + async def extend_subscription_for_referral( + self, + user_id: int, + bonus_days: int, + db_conn: aiosqlite.Connection, + is_referee_bonus: bool = False) -> Optional[datetime]: + reason = "referee bonus" if is_referee_bonus else "inviter referral bonus" + return await self.extend_active_subscription_days(user_id, + bonus_days, + db_conn, + reason=reason) + + async def get_active_subscription( + self, user_id: int) -> Optional[Dict[str, Any]]: + db, should_manage = await self._get_db() + try: + now_iso_utc = datetime.now(timezone.utc).isoformat() + cursor = await db.execute( + """SELECT s.subscription_id, s.panel_subscription_uuid, s.panel_user_uuid, s.start_date, s.end_date, s.duration_months, s.is_active, s.status_from_panel, s.traffic_limit_bytes, s.traffic_used_bytes, u.username as bot_username FROM subscriptions s LEFT JOIN users u ON s.user_id = u.user_id WHERE s.user_id = ? AND s.is_active = 1 AND s.end_date > ? ORDER BY s.end_date DESC LIMIT 1""", + (user_id, now_iso_utc)) + sub_row = await cursor.fetchone() + await cursor.close() + if sub_row: + sub_dict = dict(sub_row) + for date_key in ['start_date', 'end_date']: + if sub_dict.get(date_key) and isinstance( + sub_dict[date_key], str): + try: + sub_dict[date_key] = datetime.fromisoformat( + sub_dict[date_key].replace("Z", "+00:00")) + except ValueError: + logging.warning( + f"Could not parse date string {sub_dict[date_key]} for key {date_key} in get_active_subscription" + ) return sub_dict return None - finally :await self ._release_db (db ,should_manage ) + finally: + await self._release_db(db, should_manage) - async def get_subscriptions_ending_soon (self ,days_threshold :int )->List [Dict [str ,Any ]]: - db ,should_manage =await self ._get_db () - try : - now_utc =datetime .now (timezone .utc ) - threshold_date =now_utc +timedelta (days =days_threshold ) - today_date_str =now_utc .strftime ('%Y-%m-%d') - query =""" SELECT s.user_id, u.first_name, u.language_code, s.end_date as end_date_raw, strftime('%Y-%m-%d', s.end_date) as end_date_str FROM subscriptions s JOIN users u ON s.user_id = u.user_id WHERE s.is_active = 1 AND s.end_date BETWEEN ? AND ? AND (s.last_notification_sent IS NULL OR s.last_notification_sent < ?) ORDER BY s.end_date ASC """ - cursor =await db .execute (query ,(now_utc .isoformat (),threshold_date .isoformat (),today_date_str ));rows =await cursor .fetchall ();await cursor .close () - processed_rows =[] - for row_data in rows : - row_dict =dict (row_data );end_date_obj_for_calc =None - if isinstance (row_dict ['end_date_raw'],str ): - try : - end_date_obj_for_calc =datetime .fromisoformat (row_dict ['end_date_raw'].replace ("Z","+00:00")) - if end_date_obj_for_calc .tzinfo is None :end_date_obj_for_calc =end_date_obj_for_calc .replace (tzinfo =timezone .utc ) - row_dict ['days_left']=(end_date_obj_for_calc -now_utc ).total_seconds ()/(24 *3600 ) - except ValueError :row_dict ['days_left']=None - else :row_dict ['days_left']=None - processed_rows .append (row_dict ) + async def get_subscriptions_ending_soon( + self, days_threshold: int) -> List[Dict[str, Any]]: + db, should_manage = await self._get_db() + try: + now_utc = datetime.now(timezone.utc) + threshold_date = now_utc + timedelta(days=days_threshold) + today_date_str = now_utc.strftime('%Y-%m-%d') + query = """ SELECT s.user_id, u.first_name, u.language_code, s.end_date as end_date_raw, strftime('%Y-%m-%d', s.end_date) as end_date_str FROM subscriptions s JOIN users u ON s.user_id = u.user_id WHERE s.is_active = 1 AND s.end_date BETWEEN ? AND ? AND (s.last_notification_sent IS NULL OR s.last_notification_sent < ?) ORDER BY s.end_date ASC """ + cursor = await db.execute( + query, (now_utc.isoformat(), threshold_date.isoformat(), + today_date_str)) + rows = await cursor.fetchall() + await cursor.close() + processed_rows = [] + for row_data in rows: + row_dict = dict(row_data) + end_date_obj_for_calc = None + if isinstance(row_dict['end_date_raw'], str): + try: + end_date_obj_for_calc = datetime.fromisoformat( + row_dict['end_date_raw'].replace("Z", "+00:00")) + if end_date_obj_for_calc.tzinfo is None: + end_date_obj_for_calc = end_date_obj_for_calc.replace( + tzinfo=timezone.utc) + row_dict['days_left'] = (end_date_obj_for_calc - + now_utc).total_seconds() / ( + 24 * 3600) + except ValueError: + row_dict['days_left'] = None + else: + row_dict['days_left'] = None + processed_rows.append(row_dict) return processed_rows - finally :await self ._release_db (db ,should_manage ) + finally: + await self._release_db(db, should_manage) - async def update_last_notification_sent (self ,user_id :int ,subscription_end_date_iso :str ): - db ,should_manage =await self ._get_db () - try : - today_iso_date_str =datetime .now (timezone .utc ).strftime ('%Y-%m-%d') - await db .execute ("UPDATE subscriptions SET last_notification_sent = ? WHERE user_id = ? AND is_active = 1 AND end_date = ?",(today_iso_date_str ,user_id ,subscription_end_date_iso )) - await db .commit () - except Exception as e :logging .error (f"Error updating last_notification_sent for {user_id} and end_date {subscription_end_date_iso}: {e}");await db .rollback () - finally :await self ._release_db (db ,should_manage ) \ No newline at end of file + async def update_last_notification_sent(self, user_id: int, + subscription_end_date_iso: str): + db, should_manage = await self._get_db() + try: + today_iso_date_str = datetime.now( + timezone.utc).strftime('%Y-%m-%d') + await db.execute( + "UPDATE subscriptions SET last_notification_sent = ? WHERE user_id = ? AND is_active = 1 AND end_date = ?", + (today_iso_date_str, user_id, subscription_end_date_iso)) + await db.commit() + except Exception as e: + logging.error( + f"Error updating last_notification_sent for {user_id} and end_date {subscription_end_date_iso}: {e}" + ) + await db.rollback() + finally: + await self._release_db(db, should_manage) diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py index 4a91043..7ea9e00 100644 --- a/bot/states/admin_states.py +++ b/bot/states/admin_states.py @@ -1,11 +1,12 @@ -from aiogram .fsm .state import State ,StatesGroup +from aiogram.fsm.state import State, StatesGroup -class AdminStates (StatesGroup ): - waiting_for_broadcast_message =State () - confirming_broadcast =State () - waiting_for_promo_details =State () - waiting_for_user_id_to_ban =State () - waiting_for_user_id_to_unban =State () +class AdminStates(StatesGroup): - waiting_for_user_id_for_logs =State () \ No newline at end of file + waiting_for_broadcast_message = State() + confirming_broadcast = State() + waiting_for_promo_details = State() + waiting_for_user_id_to_ban = State() + waiting_for_user_id_to_unban = State() + + waiting_for_user_id_for_logs = State() diff --git a/bot/states/user_states.py b/bot/states/user_states.py index 5976225..5217755 100644 --- a/bot/states/user_states.py +++ b/bot/states/user_states.py @@ -1,4 +1,5 @@ -from aiogram .fsm .state import State ,StatesGroup +from aiogram.fsm.state import State, StatesGroup -class UserPromoStates (StatesGroup ): - waiting_for_promo_code =State () \ No newline at end of file + +class UserPromoStates(StatesGroup): + waiting_for_promo_code = State() diff --git a/config/settings.py b/config/settings.py index 94e95b7..fd27ded 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,146 +1,198 @@ import logging -from pydantic_settings import BaseSettings ,SettingsConfigDict -from pydantic import Field ,field_validator ,ValidationError -from typing import Optional ,List ,Dict ,Any - -class Settings (BaseSettings ): - BOT_TOKEN :str - ADMIN_IDS_STR :str =Field (default ="",alias ="ADMIN_IDS",description ="Comma-separated list of admin Telegram User IDs") - - DB_NAME :str =Field (default ="bot_database.sqlite3") - DEFAULT_LANGUAGE :str =Field (default ="ru") - DEFAULT_CURRENCY_SYMBOL :str =Field (default ="RUB") - - SUPPORT_LINK :Optional [str ]=Field (default =None ,description ="Link to support contact (e.g., t.me/your_support_contact)") - SERVER_STATUS_URL :Optional [str ]=Field (default =None ,description ="Link to server status page (e.g., Uptime Kuma)") +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field, field_validator, ValidationError +from typing import Optional, List, Dict, Any - YOOKASSA_SHOP_ID :Optional [str ]=None - YOOKASSA_SECRET_KEY :Optional [str ]=None - YOOKASSA_WEBHOOK_BASE_URL :Optional [str ]=None - YOOKASSA_RETURN_URL :Optional [str ]=None +class Settings(BaseSettings): + BOT_TOKEN: str + ADMIN_IDS_STR: str = Field( + default="", + alias="ADMIN_IDS", + description="Comma-separated list of admin Telegram User IDs") - YOOKASSA_DEFAULT_RECEIPT_EMAIL :Optional [str ]=Field (default =None ,description ="Default email for YooKassa receipts (REQUIRED for 54-FZ)") - YOOKASSA_VAT_CODE :int =Field (default =6 ,description ="YooKassa VAT code (e.g., 1=20%, 2=10%, 4=0%, 6=No VAT - check YooKassa docs!)") - YOOKASSA_PAYMENT_MODE :str =Field (default ="full_prepayment",description ="YooKassa payment mode (e.g., full_prepayment, full_payment)") - YOOKASSA_PAYMENT_SUBJECT :str =Field (default ="service",description ="YooKassa payment subject (e.g., service, commodity)") + DB_NAME: str = Field(default="bot_database.sqlite3") + DEFAULT_LANGUAGE: str = Field(default="ru") + DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB") + SUPPORT_LINK: Optional[str] = Field( + default=None, + description="Link to support contact (e.g., t.me/your_support_contact)" + ) + SERVER_STATUS_URL: Optional[str] = Field( + default=None, + description="Link to server status page (e.g., Uptime Kuma)") - TELEGRAM_WEBHOOK_BASE_URL :Optional [str ]=None + YOOKASSA_SHOP_ID: Optional[str] = None + YOOKASSA_SECRET_KEY: Optional[str] = None + YOOKASSA_WEBHOOK_BASE_URL: Optional[str] = None + YOOKASSA_RETURN_URL: Optional[str] = None + YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field( + default=None, + description="Default email for YooKassa receipts (REQUIRED for 54-FZ)") + YOOKASSA_VAT_CODE: int = Field( + default=6, + description= + "YooKassa VAT code (e.g., 1=20%, 2=10%, 4=0%, 6=No VAT - check YooKassa docs!)" + ) + YOOKASSA_PAYMENT_MODE: str = Field( + default="full_prepayment", + description= + "YooKassa payment mode (e.g., full_prepayment, full_payment)") + YOOKASSA_PAYMENT_SUBJECT: str = Field( + default="service", + description="YooKassa payment subject (e.g., service, commodity)") - PRICE_1_MONTH :Optional [int ]=None - PRICE_3_MONTHS :Optional [int ]=None - PRICE_6_MONTHS :Optional [int ]=None - PRICE_12_MONTHS :Optional [int ]=None + TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None + PRICE_1_MONTH: Optional[int] = None + PRICE_3_MONTHS: Optional[int] = None + PRICE_6_MONTHS: Optional[int] = None + PRICE_12_MONTHS: Optional[int] = None - SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS :int =Field (default =7 ) - SUBSCRIPTION_NOTIFICATION_HOUR_UTC :int =Field (default =9 ) - SUBSCRIPTION_NOTIFICATION_MINUTE_UTC :int =Field (default =0 ) + SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7) + SUBSCRIPTION_NOTIFICATION_HOUR_UTC: int = Field(default=9) + SUBSCRIPTION_NOTIFICATION_MINUTE_UTC: int = Field(default=0) + REFERRAL_BONUS_DAYS_1_MONTH: Optional[int] = 3 + REFERRAL_BONUS_DAYS_3_MONTHS: Optional[int] = 7 + REFERRAL_BONUS_DAYS_6_MONTHS: Optional[int] = 15 + REFERRAL_BONUS_DAYS_12_MONTHS: Optional[int] = 30 + REFEREE_BONUS_DAYS_1_MONTH: Optional[int] = 1 + REFEREE_BONUS_DAYS_3_MONTHS: Optional[int] = 3 + REFEREE_BONUS_DAYS_6_MONTHS: Optional[int] = 7 + REFEREE_BONUS_DAYS_12_MONTHS: Optional[int] = 15 - REFERRAL_BONUS_DAYS_1_MONTH :Optional [int ]=3 - REFERRAL_BONUS_DAYS_3_MONTHS :Optional [int ]=7 - REFERRAL_BONUS_DAYS_6_MONTHS :Optional [int ]=15 - REFERRAL_BONUS_DAYS_12_MONTHS :Optional [int ]=30 - REFEREE_BONUS_DAYS_1_MONTH :Optional [int ]=1 - REFEREE_BONUS_DAYS_3_MONTHS :Optional [int ]=3 - REFEREE_BONUS_DAYS_6_MONTHS :Optional [int ]=7 - REFEREE_BONUS_DAYS_12_MONTHS :Optional [int ]=15 + PANEL_API_URL: Optional[str] = None + PANEL_API_KEY: Optional[str] = None + PANEL_USER_DEFAULT_EXPIRE_DAYS: int = Field(default=1) + PANEL_USER_DEFAULT_TRAFFIC_BYTES: int = Field(default=0) + PANEL_USER_DEFAULT_TRAFFIC_STRATEGY: str = Field(default="NO_RESET") + PANEL_USER_DEFAULT_INBOUND_UUIDS: Optional[str] = Field(default=None) + TRIAL_ENABLED: bool = Field(default=True) + TRIAL_DURATION_DAYS: int = Field(default=3) + TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0) - PANEL_API_URL :Optional [str ]=None - PANEL_API_KEY :Optional [str ]=None - PANEL_USER_DEFAULT_EXPIRE_DAYS :int =Field (default =1 ) - PANEL_USER_DEFAULT_TRAFFIC_BYTES :int =Field (default =0 ) - PANEL_USER_DEFAULT_TRAFFIC_STRATEGY :str =Field (default ="NO_RESET") - PANEL_USER_DEFAULT_INBOUND_UUIDS :Optional [str ]=Field (default =None ) + WEB_SERVER_HOST: str = Field(default="0.0.0.0") + WEB_SERVER_PORT: int = Field(default=8080) + LOGS_PAGE_SIZE: int = Field(default=10) - - TRIAL_ENABLED :bool =Field (default =True ) - TRIAL_DURATION_DAYS :int =Field (default =3 ) - TRIAL_TRAFFIC_LIMIT_GB :Optional [float ]=Field (default =5.0 ) - - WEB_SERVER_HOST :str =Field (default ="0.0.0.0") - WEB_SERVER_PORT :int =Field (default =8080 ) - LOGS_PAGE_SIZE :int =Field (default =10 ) - - _admin_ids_list :Optional [List [int ]]=None + _admin_ids_list: Optional[List[int]] = None @property - def ADMIN_IDS (self )->List [int ]: - if self ._admin_ids_list is None : - if self .ADMIN_IDS_STR : - try : - self ._admin_ids_list =[int (admin_id .strip ())for admin_id in self .ADMIN_IDS_STR .split (',')if admin_id .strip ().isdigit ()] - if not self ._admin_ids_list and self .ADMIN_IDS_STR : - logging .error (f"ADMIN_IDS_STR ('{self.ADMIN_IDS_STR}') contains non-integer values or is malformed. No admin IDs loaded from string.") - self ._admin_ids_list =[] - except ValueError : - logging .error (f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers.") - self ._admin_ids_list =[] - else : - self ._admin_ids_list =[] - return self ._admin_ids_list + def ADMIN_IDS(self) -> List[int]: + if self._admin_ids_list is None: + if self.ADMIN_IDS_STR: + try: + self._admin_ids_list = [ + int(admin_id.strip()) + for admin_id in self.ADMIN_IDS_STR.split(',') + if admin_id.strip().isdigit() + ] + if not self._admin_ids_list and self.ADMIN_IDS_STR: + logging.error( + f"ADMIN_IDS_STR ('{self.ADMIN_IDS_STR}') contains non-integer values or is malformed. No admin IDs loaded from string." + ) + self._admin_ids_list = [] + except ValueError: + logging.error( + f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers." + ) + self._admin_ids_list = [] + else: + self._admin_ids_list = [] + return self._admin_ids_list @property - def PRIMARY_ADMIN_ID (self )->Optional [int ]: - admin_ids_list =self .ADMIN_IDS - if admin_ids_list : - return admin_ids_list [0 ] + def PRIMARY_ADMIN_ID(self) -> Optional[int]: + admin_ids_list = self.ADMIN_IDS + if admin_ids_list: + return admin_ids_list[0] return None @property - def trial_traffic_limit_bytes (self )->int : - if self .TRIAL_TRAFFIC_LIMIT_GB is None or self .TRIAL_TRAFFIC_LIMIT_GB <=0 :return 0 - return int (self .TRIAL_TRAFFIC_LIMIT_GB *(1024 **3 )) + def trial_traffic_limit_bytes(self) -> int: + if self.TRIAL_TRAFFIC_LIMIT_GB is None or self.TRIAL_TRAFFIC_LIMIT_GB <= 0: + return 0 + return int(self.TRIAL_TRAFFIC_LIMIT_GB * (1024**3)) + @property - def parsed_default_panel_user_inbound_uuids (self )->Optional [List [str ]]: - if self .PANEL_USER_DEFAULT_INBOUND_UUIDS :return [uuid .strip ()for uuid in self .PANEL_USER_DEFAULT_INBOUND_UUIDS .split (',')if uuid .strip ()] + def parsed_default_panel_user_inbound_uuids(self) -> Optional[List[str]]: + if self.PANEL_USER_DEFAULT_INBOUND_UUIDS: + return [ + uuid.strip() + for uuid in self.PANEL_USER_DEFAULT_INBOUND_UUIDS.split(',') + if uuid.strip() + ] return None + @property - def yookassa_webhook_path (self )->str :return "/webhook/yookassa" + def yookassa_webhook_path(self) -> str: + return "/webhook/yookassa" + @property - def yookassa_full_webhook_url (self )->Optional [str ]: - if self .YOOKASSA_WEBHOOK_BASE_URL :return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}" + def yookassa_full_webhook_url(self) -> Optional[str]: + if self.YOOKASSA_WEBHOOK_BASE_URL: + return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}" return None + @property - def subscription_options (self )->Dict [int ,int ]: - options :Dict [int ,int ]={}; - if self .PRICE_1_MONTH is not None :options [1 ]=self .PRICE_1_MONTH - if self .PRICE_3_MONTHS is not None :options [3 ]=self .PRICE_3_MONTHS - if self .PRICE_6_MONTHS is not None :options [6 ]=self .PRICE_6_MONTHS - if self .PRICE_12_MONTHS is not None :options [12 ]=self .PRICE_12_MONTHS + def subscription_options(self) -> Dict[int, int]: + options: Dict[int, int] = {} + if self.PRICE_1_MONTH is not None: options[1] = self.PRICE_1_MONTH + if self.PRICE_3_MONTHS is not None: options[3] = self.PRICE_3_MONTHS + if self.PRICE_6_MONTHS is not None: options[6] = self.PRICE_6_MONTHS + if self.PRICE_12_MONTHS is not None: options[12] = self.PRICE_12_MONTHS return options + @property - def referral_bonus_inviter (self )->Dict [int ,int ]: - bonuses :Dict [int ,int ]={}; - if self .REFERRAL_BONUS_DAYS_1_MONTH is not None :bonuses [1 ]=self .REFERRAL_BONUS_DAYS_1_MONTH - if self .REFERRAL_BONUS_DAYS_3_MONTHS is not None :bonuses [3 ]=self .REFERRAL_BONUS_DAYS_3_MONTHS - if self .REFERRAL_BONUS_DAYS_6_MONTHS is not None :bonuses [6 ]=self .REFERRAL_BONUS_DAYS_6_MONTHS - if self .REFERRAL_BONUS_DAYS_12_MONTHS is not None :bonuses [12 ]=self .REFERRAL_BONUS_DAYS_12_MONTHS - return bonuses - @property - def referral_bonus_referee (self )->Dict [int ,int ]: - bonuses :Dict [int ,int ]={}; - if self .REFEREE_BONUS_DAYS_1_MONTH is not None :bonuses [1 ]=self .REFEREE_BONUS_DAYS_1_MONTH - if self .REFEREE_BONUS_DAYS_3_MONTHS is not None :bonuses [3 ]=self .REFEREE_BONUS_DAYS_3_MONTHS - if self .REFEREE_BONUS_DAYS_6_MONTHS is not None :bonuses [6 ]=self .REFEREE_BONUS_DAYS_6_MONTHS - if self .REFEREE_BONUS_DAYS_12_MONTHS is not None :bonuses [12 ]=self .REFEREE_BONUS_DAYS_12_MONTHS + def referral_bonus_inviter(self) -> Dict[int, int]: + bonuses: Dict[int, int] = {} + if self.REFERRAL_BONUS_DAYS_1_MONTH is not None: + bonuses[1] = self.REFERRAL_BONUS_DAYS_1_MONTH + if self.REFERRAL_BONUS_DAYS_3_MONTHS is not None: + bonuses[3] = self.REFERRAL_BONUS_DAYS_3_MONTHS + if self.REFERRAL_BONUS_DAYS_6_MONTHS is not None: + bonuses[6] = self.REFERRAL_BONUS_DAYS_6_MONTHS + if self.REFERRAL_BONUS_DAYS_12_MONTHS is not None: + bonuses[12] = self.REFERRAL_BONUS_DAYS_12_MONTHS return bonuses - model_config =SettingsConfigDict (env_file ='.env',env_file_encoding ='utf-8',extra ='ignore',populate_by_name =True ) + @property + def referral_bonus_referee(self) -> Dict[int, int]: + bonuses: Dict[int, int] = {} + if self.REFEREE_BONUS_DAYS_1_MONTH is not None: + bonuses[1] = self.REFEREE_BONUS_DAYS_1_MONTH + if self.REFEREE_BONUS_DAYS_3_MONTHS is not None: + bonuses[3] = self.REFEREE_BONUS_DAYS_3_MONTHS + if self.REFEREE_BONUS_DAYS_6_MONTHS is not None: + bonuses[6] = self.REFEREE_BONUS_DAYS_6_MONTHS + if self.REFEREE_BONUS_DAYS_12_MONTHS is not None: + bonuses[12] = self.REFEREE_BONUS_DAYS_12_MONTHS + return bonuses -_settings_instance :Optional [Settings ]=None -def get_settings ()->Settings : + model_config = SettingsConfigDict(env_file='.env', + env_file_encoding='utf-8', + extra='ignore', + populate_by_name=True) + + +_settings_instance: Optional[Settings] = None + + +def get_settings() -> Settings: global _settings_instance - if _settings_instance is None : - try : - _settings_instance =Settings () - if not _settings_instance .ADMIN_IDS : - logging .warning ("CRITICAL: ADMIN_IDS not set or contains no valid integer IDs in .env. Admin functionality will be restricted.") - except ValidationError as e : - logging .critical (f"Pydantic validation error while loading settings: {e}") - raise SystemExit (f"CRITICAL SETTINGS ERROR: {e}") - return _settings_instance \ No newline at end of file + if _settings_instance is None: + try: + _settings_instance = Settings() + if not _settings_instance.ADMIN_IDS: + logging.warning( + "CRITICAL: ADMIN_IDS not set or contains no valid integer IDs in .env. Admin functionality will be restricted." + ) + except ValidationError as e: + logging.critical( + f"Pydantic validation error while loading settings: {e}") + raise SystemExit(f"CRITICAL SETTINGS ERROR: {e}") + return _settings_instance diff --git a/db/database.py b/db/database.py index 0923a8c..9a3f38a 100644 --- a/db/database.py +++ b/db/database.py @@ -1,43 +1,48 @@ import aiosqlite import logging -from typing import Optional ,Dict ,Any ,List ,Tuple -from datetime import datetime ,timezone +from typing import Optional, Dict, Any, List, Tuple +from datetime import datetime, timezone -from config .settings import get_settings +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 -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 (): +def get_db_connection_manager(): """ Возвращает awaitable/async context manager для соединения с SQLite. """ - return aiosqlite .connect (DB_NAME ,timeout =DB_BUSY_TIMEOUT_SECONDS ) + return aiosqlite.connect(DB_NAME, timeout=DB_BUSY_TIMEOUT_SECONDS) -async def _setup_db_connection (db :aiosqlite .Connection ): + +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 ) + 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 (): + +async def init_db(): """Инициализирует схему БД.""" - try : - async with get_db_connection_manager ()as db : - await _setup_db_connection (db ) + try: + async with get_db_connection_manager() as db: + await _setup_db_connection(db) - - await db .execute (""" + await db.execute(""" CREATE TABLE IF NOT EXISTS users ( user_id INTEGER PRIMARY KEY, username TEXT, @@ -51,10 +56,11 @@ async def init_db (): 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 INDEX IF NOT EXISTS idx_users_panel_user_uuid ON users (panel_user_uuid);" + ) - - await db .execute (""" + await db.execute(""" CREATE TABLE IF NOT EXISTS subscriptions ( subscription_id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, @@ -69,13 +75,20 @@ async def init_db (): 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 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 (""" + await db.execute(""" CREATE TABLE IF NOT EXISTS payments ( payment_id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -92,11 +105,14 @@ async def init_db (): 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 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 (""" + await db.execute(""" CREATE TABLE IF NOT EXISTS promo_codes ( promo_code_id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT UNIQUE NOT NULL, @@ -108,10 +124,11 @@ async def init_db (): 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 INDEX IF NOT EXISTS idx_promo_codes_code ON promo_codes (code);" + ) - - await db .execute (""" + await db.execute(""" CREATE TABLE IF NOT EXISTS promo_code_activations ( activation_id INTEGER PRIMARY KEY AUTOINCREMENT, promo_code_id INTEGER NOT NULL, @@ -124,8 +141,7 @@ async def init_db (): UNIQUE (promo_code_id, user_id) )""") - - await db .execute (""" + await db.execute(""" CREATE TABLE IF NOT EXISTS message_logs ( log_id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, @@ -140,12 +156,17 @@ async def init_db (): 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 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 (""" + 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 */ @@ -154,282 +175,685 @@ async def init_db (): 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.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 + 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 ]: + +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 + 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 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 + 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( + 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_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_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_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 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 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 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 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 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_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 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 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 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 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 update_user_panel_uuid( + user_id: int, + panel_user_uuid: str, + db_conn: Optional[aiosqlite.Connection] = None): -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 _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() -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 + 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_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 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 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 ,)) + 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 - 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 ]: + 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 ) + async with get_db_connection_manager() as db: + await _setup_db_connection(db) - - sql =""" + sql = """ SELECT log_id, user_id, @@ -444,125 +868,173 @@ async def get_all_message_logs_paginated (limit :int ,offset :int )->List [aiosq ORDER BY timestamp DESC LIMIT ? OFFSET ? """ - try : - cursor =await db .execute (sql ,(limit ,offset )) - rows =await cursor .fetchall () - await cursor .close () + 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 ) + 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 : + +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 () + 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 () + 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 : + +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') + 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}") + 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.") - - + 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 ): + async def _operation(db: aiosqlite.Connection): - bot_user_id =int (telegram_id_from_panel ) + bot_user_id = int(telegram_id_from_panel) - existing_bot_user =await get_user (bot_user_id ,db_conn =db ) + existing_bot_user = await get_user(bot_user_id, db_conn=db) - if existing_bot_user : + 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.") + 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 : + 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 : + 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.") + 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) + 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 ) + (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}" ) - 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." + ) - 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 () + 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 + 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 + 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')) + 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 () + 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)) - 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 =""" + upsert_sub_sql = """ INSERT INTO subscriptions ( user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, @@ -581,27 +1053,29 @@ async def sync_panel_user_data (panel_user_dict :Dict [str ,Any ],db_conn_passed 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 + 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 .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 () + 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) - 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 \ No newline at end of file + 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 diff --git a/main.py b/main.py index c190122..7be473a 100644 --- a/main.py +++ b/main.py @@ -2,39 +2,30 @@ import asyncio import logging import sys -from aiogram import Bot ,Dispatcher -from aiogram .enums import ParseMode +from aiogram import Bot, Dispatcher +from aiogram.enums import ParseMode from dotenv import load_dotenv - -from bot .main_bot import run_bot -from config .settings import get_settings ,Settings -from db .database import init_db +from bot.main_bot import run_bot +from config.settings import get_settings, Settings +from db.database import init_db +async def main(): + load_dotenv() + settings = get_settings() + + await init_db() + + await run_bot(settings) - -async def main (): - load_dotenv () - settings =get_settings () - - - await init_db () - - - - - - - - await run_bot (settings ) - - -if __name__ =="__main__": - logging .basicConfig (level =logging .INFO ,stream =sys .stdout , - format ='%(asctime)s - %(name)s - %(levelname)s - %(message)s') - try : - asyncio .run (main ()) - except (KeyboardInterrupt ,SystemExit ): - logging .info ("Bot stopped manually") \ No newline at end of file +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + stream=sys.stdout, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logging.info("Bot stopped manually")