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")