From a56d804b5848efbf19a3cdc22139b96f8b423650 Mon Sep 17 00:00:00 2001 From: machka Date: Tue, 20 May 2025 18:41:32 +0000 Subject: [PATCH] Migrating to Postgres from sqlite3 --- bot/handlers/admin/__init__.py | 32 +- bot/handlers/admin/broadcast.py | 154 +-- bot/handlers/admin/common.py | 48 +- bot/handlers/admin/logs_admin.py | 230 ++-- bot/handlers/admin/promo_codes.py | 208 ++-- bot/handlers/admin/statistics.py | 126 +- bot/handlers/admin/sync_admin.py | 305 +++-- bot/handlers/admin/user_management.py | 787 ++++++------ bot/handlers/user/payment.py | 452 +++---- bot/handlers/user/promo_user.py | 129 +- bot/handlers/user/referral.py | 98 +- bot/handlers/user/start.py | 398 ++++--- bot/handlers/user/subscription.py | 483 ++++---- bot/handlers/user/trial_handler.py | 122 +- bot/keyboards/inline/admin_keyboards.py | 77 +- bot/main_bot.py | 348 ++++-- bot/middlewares/action_logger_middleware.py | 89 +- bot/middlewares/ban_check_middleware.py | 204 +--- bot/middlewares/i18n.py | 135 +-- bot/services/notification_service.py | 151 ++- bot/services/panel_api_service.py | 463 ++++--- bot/services/payment_service.py | 214 ++-- bot/services/promo_code_service.py | 105 +- bot/services/referral_service.py | 253 ++-- bot/services/subscription_service.py | 1191 +++++++++---------- config/settings.py | 197 +-- db/dal/message_log_dal.py | 76 ++ db/dal/panel_sync_dal.py | 50 + db/dal/payment_dal.py | 115 ++ db/dal/promo_code_dal.py | 126 ++ db/dal/subscription_dal.py | 205 ++++ db/dal/user_dal.py | 124 ++ db/database.py | 1081 ----------------- db/database_setup.py | 85 ++ db/models.py | 193 +++ locales/ru.json | 327 ++--- main.py | 16 +- requirements.txt | 9 +- 38 files changed, 5017 insertions(+), 4389 deletions(-) create mode 100644 db/dal/message_log_dal.py create mode 100644 db/dal/panel_sync_dal.py create mode 100644 db/dal/payment_dal.py create mode 100644 db/dal/promo_code_dal.py create mode 100644 db/dal/subscription_dal.py create mode 100644 db/dal/user_dal.py delete mode 100644 db/database.py create mode 100644 db/database_setup.py create mode 100644 db/models.py diff --git a/bot/handlers/admin/__init__.py b/bot/handlers/admin/__init__.py index 3ec5047..ddfe2bb 100644 --- a/bot/handlers/admin/__init__.py +++ b/bot/handlers/admin/__init__.py @@ -1,19 +1,21 @@ 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 -from .broadcast import router as broadcast_admin_router -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 +from . import common +from . import broadcast +from . import promo_codes +from . import user_management +from . import statistics +from . import sync_admin +from . import logs_admin -admin_router_aggregate = Router(name="admin_router_aggregate") +admin_router_aggregate = Router(name="admin_features_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) +admin_router_aggregate.include_router(common.router) +admin_router_aggregate.include_router(broadcast.router) +admin_router_aggregate.include_router(promo_codes.router) +admin_router_aggregate.include_router(user_management.router) +admin_router_aggregate.include_router(statistics.router) +admin_router_aggregate.include_router(sync_admin.router) +admin_router_aggregate.include_router(logs_admin.router) + +__all__ = ("admin_router_aggregate", ) diff --git a/bot/handlers/admin/broadcast.py b/bot/handlers/admin/broadcast.py index 5668c4b..bf67826 100644 --- a/bot/handlers/admin/broadcast.py +++ b/bot/handlers/admin/broadcast.py @@ -1,15 +1,17 @@ import logging import asyncio from aiogram import Router, F, types, Bot -from aiogram.filters import Command + from aiogram.fsm.context import FSMContext from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings -from db.database import get_all_users_for_broadcast, log_user_action +from db.dal import user_dal, message_log_dal + 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.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard, get_admin_panel_keyboard from bot.middlewares.i18n import JsonI18n router = Router(name="admin_broadcast_router") @@ -17,9 +19,9 @@ 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')) + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: logging.error("i18n missing in broadcast_message_prompt_handler") @@ -37,7 +39,8 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery, current_lang, i18n)) except Exception as e: logging.warning( - f"Could not edit message for broadcast prompt: {e}") + f"Could not edit message for broadcast prompt: {e}. Sending new." + ) await callback.message.answer( prompt_text, reply_markup=get_back_to_admin_panel_keyboard( @@ -49,9 +52,9 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery, @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')) + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: logging.error("i18n missing in process_broadcast_message_handler") @@ -63,8 +66,9 @@ async def process_broadcast_message_handler(message: types.Message, 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 + 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) @@ -79,34 +83,27 @@ async def process_broadcast_message_handler(message: types.Message, 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')) + settings: Settings, i18n_data: dict, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - logging.error("i18n missing") - await callback.answer("Language error.", show_alert=True) + if not i18n or not callback.message: + await callback.answer("Error cancelling.", 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")) + try: + await callback.message.edit_text( + _("admin_broadcast_cancelled_nav_back"), reply_markup=None) + except Exception: + await callback.message.answer(_("admin_broadcast_cancelled_nav_back")) + + await callback.answer(_("admin_broadcast_cancelled_alert")) 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)) + await callback.message.answer(_(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard( + i18n, current_lang, settings)) @router.callback_query(F.data.startswith("broadcast_final_action:"), @@ -114,80 +111,87 @@ async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery, 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')) + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - logging.error("i18n missing") - await callback.answer("Language error.", show_alert=True) + if not i18n or not callback.message: + await callback.answer("Error processing broadcast confirmation.", + 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 + broadcast_message_to_send = user_fsm_data.get("broadcast_message") if action == "send": - if not broadcast_message: + if not broadcast_message_to_send: await callback.message.edit_text( _("admin_broadcast_error_no_message")) await state.clear() - await callback.answer(show_alert=True) + await callback.answer(_("admin_broadcast_error_no_message_alert"), + 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() + user_ids_for_broadcast = await user_dal.get_all_active_user_ids_for_broadcast( + session) + sent_count = 0 failed_count = 0 logging.info( - f"Starting broadcast: '{broadcast_message[:50]}...' to {len(users_to_broadcast)} users." + f"Admin {callback.from_user.id} starting broadcast: '{broadcast_message_to_send[:50]}...' to {len(user_ids_for_broadcast)} users." ) - admin_user = callback.from_user - for user_row in users_to_broadcast: - user_id = user_row['user_id'] + for user_id_to_send in user_ids_for_broadcast: try: - await bot.send_message(user_id, - broadcast_message, + await bot.send_message(user_id_to_send, + broadcast_message_to_send, 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) + log_payload_sent = { + "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_to_send}: {broadcast_message_to_send[:70]}...", + "is_admin_event": True, + "target_user_id": user_id_to_send + } + await message_log_dal.create_message_log( + session, log_payload_sent) except Exception as e: failed_count += 1 logging.warning( - f"Failed to send broadcast to user {user_id}: {type(e).__name__} - {e}" + f"Failed to send broadcast to user {user_id_to_send}: {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) + log_payload_failed = { + "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_to_send}: {type(e).__name__} - {str(e)[:70]}...", + "is_admin_event": True, + "target_user_id": user_id_to_send + } + await message_log_dal.create_message_log( + session, log_payload_failed) await asyncio.sleep(0.05) + try: + await session.commit() + except Exception as e_commit: + await session.rollback() + logging.error(f"Error committing broadcast logs: {e_commit}") + result_message = _("admin_broadcast_finished_stats", sent_count=sent_count, failed_count=failed_count) diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index ecba824..9e92f11 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -3,11 +3,13 @@ from aiogram import Router, F, types, Bot from aiogram.filters import Command from aiogram.fsm.context import FSMContext from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession 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 bot.services.subscription_service import SubscriptionService from . import broadcast as admin_broadcast_handlers from . import promo_codes as admin_promo_handlers @@ -20,10 +22,12 @@ 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')) +async def admin_panel_command_handler( + message: types.Message, + settings: Settings, + i18n_data: dict, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: logging.error("i18n missing in admin_panel_command_handler") @@ -37,16 +41,14 @@ async def admin_panel_command_handler(message: types.Message, @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): +async def admin_panel_actions_callback_handler( + callback: types.CallbackQuery, state: FSMContext, settings: Settings, + i18n_data: dict, bot: Bot, panel_service: PanelApiService, + subscription_service: SubscriptionService, session: AsyncSession): action_parts = callback.data.split(":") action = action_parts[1] - current_lang = i18n_data.get("current_language", - getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: logging.error("i18n missing in admin_panel_actions_callback_handler") @@ -56,7 +58,7 @@ async def admin_panel_actions_callback_handler(callback: types.CallbackQuery, if not callback.message: logging.error( - f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for action {action}" + f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for admin_action {action}" ) await callback.answer("Error processing action: message context lost.", show_alert=True) @@ -64,36 +66,38 @@ async def admin_panel_actions_callback_handler(callback: types.CallbackQuery, if action == "stats": await admin_stats_handlers.show_statistics_handler( - callback, i18n_data, settings) + callback, i18n_data, settings, session) elif action == "broadcast": await admin_broadcast_handlers.broadcast_message_prompt_handler( - callback, state, i18n_data, settings) + callback, state, i18n_data, settings, session) elif action == "create_promo": await admin_promo_handlers.create_promo_prompt_handler( - callback, state, i18n_data, settings) + callback, state, i18n_data, settings, session) elif action == "view_promos": await admin_promo_handlers.view_promo_codes_handler( - callback, i18n_data, settings) + callback, i18n_data, settings, session) elif action == "ban_user_prompt": await admin_user_mgmnt_handlers.ban_user_prompt_handler( - callback, state, i18n_data, settings) + callback, state, i18n_data, settings, session) elif action == "unban_user_prompt": await admin_user_mgmnt_handlers.unban_user_prompt_handler( - callback, state, i18n_data, settings) + callback, state, i18n_data, settings, session) elif action == "view_banned": await admin_user_mgmnt_handlers.view_banned_users_handler( - callback, i18n_data, settings, state) + callback, state, i18n_data, settings, session) elif action == "view_logs_menu": await admin_logs_handlers.display_logs_menu(callback, i18n_data, - settings) + settings, session) elif action == "sync_panel": + await admin_sync_handlers.sync_command_handler( - callback.message, + message_event=callback, bot=bot, settings=settings, i18n_data=i18n_data, - panel_service=panel_service) + panel_service=panel_service, + session=session) await callback.answer(_("admin_sync_initiated_from_panel")) elif action == "main": try: diff --git a/bot/handlers/admin/logs_admin.py b/bot/handlers/admin/logs_admin.py index 4f7d726..5f43c08 100644 --- a/bot/handlers/admin/logs_admin.py +++ b/bot/handlers/admin/logs_admin.py @@ -4,18 +4,18 @@ import re from aiogram import Router, F, types, Bot from aiogram.fsm.context import FSMContext from typing import Optional, List, Dict, Any -import aiosqlite + +from sqlalchemy.ext.asyncio import AsyncSession 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 db.dal import message_log_dal, user_dal +from db.models import MessageLog, User + 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) + get_back_to_admin_panel_keyboard) from bot.middlewares.i18n import JsonI18n router = Router(name="admin_logs_router") @@ -23,19 +23,13 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings): + settings: Settings, session: AsyncSession): 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) + if not i18n or not callback.message: + await callback.answer("Error displaying logs menu.", 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) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) try: @@ -43,7 +37,8 @@ async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict, reply_markup=get_logs_menu_keyboard( i18n, current_lang)) except Exception as e: - logging.warning(f"Failed to edit message for logs menu: {e}") + logging.warning( + f"Failed to edit message for logs menu: {e}. Sending new.") await callback.message.answer(text=_(key="admin_logs_menu_title"), reply_markup=get_logs_menu_keyboard( i18n, current_lang)) @@ -51,9 +46,9 @@ async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict, async def _display_formatted_logs(target_message: types.Message, - logs: List[aiosqlite.Row], + logs: List[MessageLog], total_logs: int, - current_page: int, + current_page_idx: int, settings: Settings, title_key: str, base_pagination_callback_data: str, @@ -63,7 +58,6 @@ async def _display_formatted_logs(target_message: types.Message, 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: @@ -71,7 +65,7 @@ async def _display_formatted_logs(target_message: types.Message, 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, + current_page_idx, 1, base_pagination_callback_data, i18n, @@ -80,55 +74,46 @@ async def _display_formatted_logs(target_message: types.Message, else: total_pages = math.ceil(total_logs / page_size) if page_size > 0 else 1 text = _(title_key, - current_page=current_page + 1, + current_page=current_page_idx + 1, total_pages=max(1, total_pages), **actual_title_kwargs) + "\n" log_entries_text = [] - for log_entry in logs: + for log_entry_model 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})") + if log_entry_model.telegram_first_name: + user_display_parts.append(log_entry_model.telegram_first_name) + if log_entry_model.telegram_username: + user_display_parts.append( + f"(@{log_entry_model.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}" + ) if not log_entry_model.user_id else f"ID: {log_entry_model.user_id}" 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 "" + log_entry_model.user_id + ) if log_entry_model.user_id is not None else "N/A" + content_raw = log_entry_model.content or "" content_preview = (content_raw[:100] + "...") if len(content_raw) > 100 else ( content_raw or "N/A") + timestamp_str_display = log_entry_model.timestamp.strftime( + '%Y-%m-%d %H:%M:%S') if log_entry_model.timestamp else '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', + timestamp_str=timestamp_str_display, 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', + event_type=log_entry_model.event_type or 'N/A', content_preview=content_preview).replace("\n", "\n ")) text += "\n\n".join(log_entries_text) reply_markup = get_logs_pagination_keyboard( - current_page, + current_page_idx, total_pages, base_pagination_callback_data, i18n, @@ -142,30 +127,40 @@ async def _display_formatted_logs(target_message: types.Message, disable_web_page_preview=True) except Exception as e: logging.warning( - f"Failed to edit message for logs display: {e}. Content length: {len(text)}" + f"Failed to edit message for logs display (len: {len(text)}): {e}. Sending new message(s)." ) - 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) + + max_chunk_size = 4000 + for i in range(0, len(text), max_chunk_size): + chunk = text[i:i + max_chunk_size] + is_last_chunk = (i + max_chunk_size) >= len(text) + try: + await target_message.answer( + chunk, + reply_markup=reply_markup if is_last_chunk else None, + parse_mode="HTML", + disable_web_page_preview=True) + except Exception as e_chunk: + logging.error(f"Failed to send log chunk: {e_chunk}") + + if i == 0: + await target_message.answer( + _("error_displaying_logs_too_long"), + reply_markup=reply_markup if is_last_chunk else None) + break @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 + settings: Settings, i18n_data: dict, + session: AsyncSession): + page_idx = 0 parts = callback.data.split(":") - if len(parts) == 3: try: - page = int(parts[2]) + page_idx = int(parts[2]) except ValueError: - page = 0 + page_idx = 0 i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) @@ -173,15 +168,15 @@ async def view_all_logs_handler(callback: types.CallbackQuery, 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_models = await message_log_dal.get_all_message_logs( + session, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE) + total_logs_count = await message_log_dal.count_all_message_logs(session) await _display_formatted_logs( target_message=callback.message, - logs=logs, - total_logs=total_logs, - current_page=page, + logs=logs_models, + total_logs=total_logs_count, + current_page_idx=page_idx, settings=settings, title_key="admin_all_logs_title", base_pagination_callback_data="admin_logs:view_all", @@ -193,11 +188,13 @@ async def view_all_logs_handler(callback: types.CallbackQuery, @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): + settings: Settings, + session: AsyncSession): 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") + await callback.answer("Error preparing user log prompt.", + show_alert=True) return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) @@ -211,103 +208,106 @@ async def prompt_user_for_logs_handler(callback: types.CallbackQuery, @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}'" - ) + settings: Settings, i18n_data: dict, + session: AsyncSession): await state.clear() 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.") + await message.reply("Language service error.") return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - input_text = message.text.strip() - user_data_for_logs: Optional[aiosqlite.Row] = None + input_text = message.text.strip() if message.text else "" + user_model_for_logs: Optional[User] = None if input_text.isdigit(): try: - user_data_for_logs = await get_user(int(input_text)) + user_model_for_logs = await user_dal.get_user_by_id( + session, 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:] - ) + user_model_for_logs = await user_dal.get_user_by_username( + session, input_text[1:]) elif USERNAME_REGEX.match(input_text): - user_data_for_logs = await get_user_by_telegram_username(input_text) + user_model_for_logs = await user_dal.get_user_by_username( + session, input_text) - if not user_data_for_logs: + if not user_model_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_model_for_logs.user_id + user_display_name = user_model_for_logs.first_name or ( + f"@{user_model_for_logs.username}" + if user_model_for_logs.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_models = await message_log_dal.get_user_message_logs( + session, target_user_id, settings.LOGS_PAGE_SIZE, 0) + total_user_logs_count = await message_log_dal.count_user_message_logs( + session, target_user_id) await _display_formatted_logs( target_message=message, - logs=logs, - total_logs=total_logs, - current_page=0, + logs=logs_models, + total_logs=total_user_logs_count, + current_page_idx=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}) + title_kwargs={"user_display": user_display_name}) @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): + settings: Settings, i18n_data: dict, + session: AsyncSession): try: parts = callback.data.split(":") target_user_id = int(parts[2]) - page = int(parts[3]) + page_idx = int(parts[3]) except (IndexError, ValueError): - await callback.answer("Invalid log request.", show_alert=True) + await callback.answer("Invalid log request format.", 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") + await callback.answer("Error processing request.", show_alert=True) return - user_data_for_logs = await get_user(target_user_id) - if not user_data_for_logs: + user_model_for_logs = await user_dal.get_user_by_id( + session, target_user_id) + if not user_model_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}") - 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_display_name = user_model_for_logs.first_name or ( + f"@{user_model_for_logs.username}" + if user_model_for_logs.username else f"ID {target_user_id}") + + logs_models = await message_log_dal.get_user_message_logs( + session, target_user_id, settings.LOGS_PAGE_SIZE, + page_idx * settings.LOGS_PAGE_SIZE) + total_user_logs_count = await message_log_dal.count_user_message_logs( + session, target_user_id) await _display_formatted_logs( target_message=callback.message, - logs=logs, - total_logs=total_logs, - current_page=page, + logs=logs_models, + total_logs=total_user_logs_count, + current_page_idx=page_idx, 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}) + title_kwargs={"user_display": user_display_name}) await callback.answer() @@ -316,6 +316,8 @@ async def view_user_logs_paginated_handler(callback: types.CallbackQuery, async def cancel_log_user_input_state_to_menu(callback: types.CallbackQuery, state: FSMContext, settings: Settings, - i18n_data: dict): + i18n_data: dict, + session: AsyncSession): await state.clear() - await display_logs_menu(callback, i18n_data, settings) + + await display_logs_menu(callback, i18n_data, settings, session) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index 7655572..96042ff 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -1,14 +1,16 @@ 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 typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings -from db.database import create_promo_code_db, get_promo_codes_db + +from db.dal import promo_code_dal + from bot.states.admin_states import AdminStates -from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard +from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard from bot.middlewares.i18n import JsonI18n router = Router(name="admin_promo_codes_router") @@ -16,32 +18,29 @@ 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')) + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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) + if not i18n or not callback.message: + await callback.answer("Error preparing promo creation.", + show_alert=True) return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) 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)) + 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}. Sending new.") + 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) @@ -50,87 +49,112 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery, 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')) + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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) + if not message.text: + await message.answer(_("admin_promo_invalid_format")) + return + 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.") + code_str = parts[0].upper() + if not (3 <= len(code_str) <= 30 and code_str.isalnum()): + raise ValueError(_("admin_promo_invalid_code_format")) + 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.") - + raise ValueError(_("admin_promo_invalid_validity_days")) 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.") + raise ValueError(_("admin_promo_invalid_bonus_or_activations")) + except ValueError as e: await message.answer(_("admin_promo_invalid_values", error=str(e))) return + except Exception as e_parse: + logging.error( + f"Error parsing promo details '{message.text}': {e_parse}") + await message.answer(_("admin_promo_invalid_format_general")) + return - admin_id = message.from_user.id + admin_id = message.from_user.id if message.from_user else 0 - promo_id = await create_promo_code_db(code, bonus_days, max_activations, - admin_id, valid_until_date) + promo_data_to_create = { + "code": code_str, + "bonus_days": bonus_days, + "max_activations": max_activations, + "created_by_admin_id": admin_id, + "valid_until": valid_until_date, + "is_active": True, + "current_activations": 0 + } - 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: + try: + created_promo = await promo_code_dal.create_promo_code( + session, promo_data_to_create) + await session.commit() + + if created_promo: + success_text = _("admin_promo_created_success", + code=created_promo.code, + bonus_days=created_promo.bonus_days, + max_activations=created_promo.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_duplicate", code=code_str)) + + except Exception as e_db_create: + await session.rollback() + logging.error( + f"Failed to create promo code '{code_str}' in DB: {e_db_create}", + exc_info=True) 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_data: dict, settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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) + if not i18n or not callback.message: + await callback.answer("Error displaying promo codes.", 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) - if not callback.message: - await callback.answer("Error: message context lost.", show_alert=True) - return + promo_models = await promo_code_dal.get_all_active_promo_codes(session, + limit=20, + offset=0) - if not promos: + if not promo_models: await callback.message.edit_text( _("admin_no_active_promos"), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) @@ -138,36 +162,18 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, return response_text_parts = [f"{_('admin_active_promos_list_header')}\n"] - for promo in promos: + for promo in promo_models: valid_until_display_text = _("admin_promo_valid_indefinitely") - if promo['valid_until']: - try: + if promo.valid_until: - 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'] + valid_until_display_text = promo.valid_until.strftime('%Y-%m-%d') response_text_parts.append( _("admin_promo_list_item", - code=promo['code'], - bonus=promo['bonus_days'], - current=promo['current_activations'], - max=promo['max_activations'], + 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) @@ -177,7 +183,8 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, 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}") + logging.warning( + f"Failed to edit message for promo list: {e}. Sending new.") if callback.message: await callback.message.answer( final_text, @@ -185,3 +192,30 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, current_lang, i18n), parse_mode="HTML") await callback.answer() + + +@router.callback_query(F.data == "admin_action:main", + AdminStates.waiting_for_promo_details) +async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery, + state: FSMContext, + settings: Settings, + i18n_data: dict, + session: AsyncSession): + 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 cancelling.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + await callback.message.edit_text(_("admin_action_cancelled_default"), + reply_markup=get_admin_panel_keyboard( + i18n, current_lang, settings)) + except Exception: + 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_alert")) + await state.clear() diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index 439276b..616ea09 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -1,10 +1,14 @@ import logging from aiogram import Router, F, types -from typing import Optional, Dict +from typing import Optional, Dict, List from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession 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 db.dal import user_dal, payment_dal, panel_sync_dal +from db.models import Payment, PanelSyncStatus + from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard from bot.middlewares.i18n import JsonI18n @@ -12,67 +16,69 @@ 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_data: dict, settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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) + if not i18n or not callback.message: + await callback.answer("Error displaying statistics.", show_alert=True) return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) await callback.answer() stats_text_parts = [f"{_('admin_stats_header')}"] - user_stats = await get_user_count_stats() + user_stats_dict = await user_dal.get_user_count_stats_dal(session) 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))) + total_users=user_stats_dict.get("total_users", 0), + banned_users=user_stats_dict.get("banned_users", 0), + active_subs=user_stats_dict.get("users_with_active_subscriptions", + 0))) - last_payments = await get_payment_logs(limit=5) - if last_payments: + last_payments_models: List[ + Payment] = await payment_dal.get_recent_payment_logs_with_user(session, + limit=5) + if last_payments_models: 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] + for payment in last_payments_models: + status_emoji = "✅" if payment.status == 'succeeded' else ( + "⏳" if payment.status == 'pending' + or payment.status == 'pending_yookassa' else "❌") + + user_info = f"User {payment.user_id}" + if payment.user and payment.user.username: + user_info += f" (@{payment.user.username})" + elif payment.user and payment.user.first_name: + user_info += f" ({payment.user.first_name})" + + payment_date_str = payment.created_at.strftime( + '%Y-%m-%d') if payment.created_at else "N/A" stats_text_parts.append( _("admin_stats_payment_item", status_emoji=status_emoji, - amount=payment['amount'], - currency=payment['currency'], + amount=payment.amount, + currency=payment.currency, user_info=user_info, - p_status=payment['status'], + p_status=payment.status, p_date=payment_date_str)) else: stats_text_parts.append(f"\n{_('admin_stats_no_payments_found')}") - sync_status = await get_last_sync_status() - if sync_status: + sync_status_model: Optional[ + PanelSyncStatus] = await panel_sync_dal.get_panel_sync_status(session) + if sync_status_model and sync_status_model.status != "never_run": stats_text_parts.append( f"\n{_('admin_stats_last_sync_header')}") - 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 + sync_time_val = sync_status_model.last_sync_time + sync_time_str = sync_time_val.strftime( + '%Y-%m-%d %H:%M:%S UTC') if sync_time_val else "N/A" - details_val = sync_status['details'] + details_val = sync_status_model.details details_str = (details_val[:100] + "...") if details_val and len(details_val) > 100 else ( details_val or "N/A") @@ -80,12 +86,12 @@ async def show_statistics_handler(callback: types.CallbackQuery, stats_text_parts.append( f" {_('admin_stats_sync_time')}: {sync_time_str}") stats_text_parts.append( - f" {_('admin_stats_sync_status')}: {sync_status['status']}") + f" {_('admin_stats_sync_status')}: {sync_status_model.status}") stats_text_parts.append( - f" {_('admin_stats_sync_users_processed')}: {sync_status['users_processed_from_panel']}" + f" {_('admin_stats_sync_users_processed')}: {sync_status_model.users_processed_from_panel}" ) stats_text_parts.append( - f" {_('admin_stats_sync_subs_synced')}: {sync_status['subscriptions_synced']}" + f" {_('admin_stats_sync_subs_synced')}: {sync_status_model.subscriptions_synced}" ) stats_text_parts.append( f" {_('admin_stats_sync_details_label')}: {details_str}") @@ -94,26 +100,30 @@ async def show_statistics_handler(callback: types.CallbackQuery, 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) + 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_edit: + logging.error(f"Error editing message for statistics: {e_edit}", + exc_info=True) - for chunk in [ - final_text[i:i + 4000] - for i in range(0, len(final_text), 4000) - ]: + max_chunk_size = 4000 + for i in range(0, len(final_text), max_chunk_size): + chunk = final_text[i:i + max_chunk_size] + is_last_chunk = (i + max_chunk_size) >= len(final_text) + try: await callback.message.answer( chunk, reply_markup=get_back_to_admin_panel_keyboard( - current_lang, i18n) - if chunk == final_text[-len(chunk):] else None, + current_lang, i18n) if is_last_chunk else None, parse_mode="HTML") - else: - logging.error("Cannot send statistics, callback.message is None.") + except Exception as e_chunk: + logging.error(f"Failed to send statistics chunk: {e_chunk}") + if i == 0: + await callback.message.answer( + _("error_displaying_statistics"), + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) + break diff --git a/bot/handlers/admin/sync_admin.py b/bot/handlers/admin/sync_admin.py index 8e60ecf..ed26d0e 100644 --- a/bot/handlers/admin/sync_admin.py +++ b/bot/handlers/admin/sync_admin.py @@ -1,36 +1,56 @@ import logging from aiogram import Router, types, Bot from aiogram.filters import Command -from typing import Optional +from typing import Optional, Union +from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime, timezone 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 db.dal import user_dal, subscription_dal, panel_sync_dal + 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.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')) +async def sync_command_handler(message_event: Union[types.Message, + types.CallbackQuery], + bot: Bot, settings: Settings, i18n_data: dict, + panel_service: PanelApiService, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: - logging.error("i18n missing") - await message.answer("Language error.") + logging.error("i18n missing in sync_command_handler") + + if isinstance(message_event, types.Message): + await message_event.answer("Language error.") + elif isinstance(message_event, types.CallbackQuery): + await message_event.answer("Language error.", show_alert=True) return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - await message.answer(_("sync_started")) - logging.info("Admin triggered panel sync.") + target_chat_id = message_event.chat.id if isinstance( + message_event, types.Message) else ( + message_event.message.chat.id if message_event.message else None) + if not target_chat_id: + logging.error("Sync handler: could not determine target_chat_id.") + if isinstance(message_event, types.CallbackQuery): + await message_event.answer("Error initiating sync.", + show_alert=True) + return + + if isinstance(message_event, types.Message): + await message_event.answer(_("sync_started")) + + logging.info(f"Admin ({message_event.from_user.id}) triggered panel sync.") users_processed_count = 0 users_synced_successfully = 0 + subscriptions_synced_count = 0 sync_errors = [] try: @@ -39,14 +59,20 @@ async def sync_command_handler(message: types.Message, bot: Bot, 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)) + await panel_sync_dal.update_panel_sync_status( + session, "failed", error_msg) + await session.commit() + await bot.send_message(target_chat_id, + _("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( + await panel_sync_dal.update_panel_sync_status( + session, "success", status_msg, 0, 0) + await session.commit() + await bot.send_message( + target_chat_id, _("sync_completed", status="Success", details=status_msg)) return @@ -55,94 +81,233 @@ async def sync_command_handler(message: types.Message, bot: Bot, 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 + telegram_id_from_panel_str = panel_user_dict.get('telegramId') + panel_username = panel_user_dict.get('username') if not panel_uuid: logging.warning( - f"Panel user (TG ID: {telegram_id_from_panel}) missing 'uuid'. Skipping." + f"Sync: Panel user data missing 'uuid'. Data: {str(panel_user_dict)[:200]}. Skipping." ) sync_errors.append( - f"Panel user data for TG ID {telegram_id_from_panel} missing UUID." + f"Panel user data (username: {panel_username or 'N/A'}) 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})" + telegram_id_from_panel: Optional[int] = None + if telegram_id_from_panel_str: + try: + telegram_id_from_panel = int(telegram_id_from_panel_str) + except ValueError: + logging.warning( + f"Sync: Panel user {panel_uuid} (username: {panel_username}) has invalid 'telegramId': {telegram_id_from_panel_str}. Skipping TG ID based sync." + ) + + if not telegram_id_from_panel: + + logging.info( + f"Sync: Panel user {panel_uuid} (username: {panel_username}) has no valid 'telegramId'. Skipping full sync for this user." ) + continue + + bot_user = await user_dal.get_user_by_id(session, + telegram_id_from_panel) + if not bot_user: + user_data_to_create = { + "user_id": + telegram_id_from_panel, + "username": + panel_username, + "panel_user_uuid": + panel_uuid, + "language_code": + settings.DEFAULT_LANGUAGE, + "registration_date": + datetime.fromisoformat( + panel_user_dict['createdAt'].replace("Z", "+00:00")) + if panel_user_dict.get('createdAt') else datetime.now( + timezone.utc) + } + bot_user = await user_dal.create_user(session, + user_data_to_create) + logging.info( + f"Sync: Created new local user {telegram_id_from_panel} from panel data {panel_uuid}." + ) + else: + if bot_user.panel_user_uuid != panel_uuid: + if bot_user.panel_user_uuid is not None: + logging.warning( + f"Sync: Local user {telegram_id_from_panel} was linked to {bot_user.panel_user_uuid}, panel now gives {panel_uuid}. Updating." + ) + + conflicting_user = await user_dal.get_user_by_panel_uuid( + session, panel_uuid) + if conflicting_user and conflicting_user.user_id != telegram_id_from_panel: + sync_errors.append( + f"Panel UUID {panel_uuid} for TG {telegram_id_from_panel} already linked to another TG user {conflicting_user.user_id}." + ) + logging.error(sync_errors[-1]) + continue + + await user_dal.update_user( + session, telegram_id_from_panel, { + "panel_user_uuid": panel_uuid, + "username": panel_username + }) + logging.info( + f"Sync: Updated panel_uuid for local user {telegram_id_from_panel} to {panel_uuid}." + ) + + panel_sub_link_id = panel_user_dict.get( + 'subscriptionUuid') or panel_user_dict.get('shortUuid') + if panel_sub_link_id: + end_date_str = panel_user_dict.get('expireAt') + start_date_str = panel_user_dict.get('createdAt') + + if end_date_str: + try: + end_date_obj = datetime.fromisoformat( + end_date_str.replace("Z", "+00:00")) + start_date_obj = datetime.fromisoformat( + start_date_str.replace( + "Z", + "+00:00")) if start_date_str else datetime.now( + timezone.utc) + + status_from_panel = panel_user_dict.get( + 'status', 'UNKNOWN').upper() + is_active_flag = 1 if status_from_panel == 'ACTIVE' and end_date_obj > datetime.now( + timezone.utc) else 0 + + sub_payload = { + "user_id": + telegram_id_from_panel, + "panel_user_uuid": + panel_uuid, + "panel_subscription_uuid": + panel_sub_link_id, + "start_date": + start_date_obj, + "end_date": + end_date_obj, + "is_active": + is_active_flag, + "status_from_panel": + status_from_panel, + "traffic_limit_bytes": + panel_user_dict.get('trafficLimitBytes'), + "traffic_used_bytes": + panel_user_dict.get('usedTrafficBytes') + } + + await subscription_dal.deactivate_other_active_subscriptions( + session, panel_uuid, panel_sub_link_id) + await subscription_dal.upsert_subscription( + session, sub_payload) + subscriptions_synced_count += 1 + users_synced_successfully += 1 + except ValueError as e_date: + logging.warning( + f"Sync: Bad date format for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}). Sub data: {str(panel_user_dict)[:100]}. Error: {e_date}" + ) + sync_errors.append( + f"Bad date for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})." + ) + except Exception as e_sub_sync: + logging.error( + f"Sync: Error syncing subscription for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}): {e_sub_sync}", + exc_info=True) + sync_errors.append( + f"Sub sync error for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})." + ) + else: + logging.warning( + f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has sub link but no expireAt date. Skipping subscription sync." + ) + else: + + await subscription_dal.deactivate_other_active_subscriptions( + session, panel_uuid, None) + logging.info( + f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has no subscription link on panel. Deactivated local subs if any." + ) + users_synced_successfully += 1 + if users_processed_count % 20 == 0: logging.info( - f"Sync progress: {users_processed_count}/{total_panel_users} users processed." + f"Sync progress: {users_processed_count}/{total_panel_users} users processed from panel." ) - 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_key = "sync_completed_details" + final_status_type = "success" - 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)) + if sync_errors: + final_status_type = "partial_success" + status_msg_key = "sync_completed_with_errors_details" + error_preview = "\n".join(sync_errors[:3]) + details_for_db = f"Users processed: {users_processed_count}. Subs synced: {subscriptions_synced_count}. Errors: {len(sync_errors)}. First few: {error_preview}" + else: + details_for_db = f"Successfully processed {users_processed_count} users. Synced {subscriptions_synced_count} subscriptions." + + await panel_sync_dal.update_panel_sync_status( + session, final_status_type, details_for_db, users_processed_count, + subscriptions_synced_count) + await session.commit() + + final_user_message = _(status_msg_key, + total_checked=total_panel_users, + users_synced=users_synced_successfully, + subs_synced=subscriptions_synced_count, + errors_count=len(sync_errors), + error_details_preview=error_preview + if sync_errors else _("no_errors_placeholder")) + await bot.send_message(target_chat_id, final_user_message) + + except Exception as e_sync_global: + await session.rollback() + logging.error(f"Global error during /sync command: {e_sync_global}", + exc_info=True) + error_detail_for_db = f"An unexpected error occurred during sync: {str(e_sync_global)[:200]}" + await panel_sync_dal.update_panel_sync_status( + session, "failed", error_detail_for_db, users_processed_count, + subscriptions_synced_count) + + await bot.send_message(target_chat_id, + _("sync_failed", details=error_detail_for_db)) @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')) + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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() + + status_record_model = await panel_sync_dal.get_panel_sync_status(session) response_text = "" - if status_record: - last_time_val = status_record['last_sync_time'] + if status_record_model: + last_time_val = status_record_model.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'] + '%Y-%m-%d %H:%M:%S UTC') if last_time_val else "N/A" + + details_val = status_record_model.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_status')}: {status_record_model.status}\n" + f" {_('admin_stats_sync_users_processed')}: {status_record_model.users_processed_from_panel}\n" + f" {_('admin_stats_sync_subs_synced')}: {status_record_model.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 b9d12f2..8e2b601 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -1,18 +1,20 @@ import logging import re +import math 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 sqlalchemy.ext.asyncio import AsyncSession 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 db.dal import user_dal, subscription_dal +from db.models import User, Subscription + 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, @@ -21,35 +23,49 @@ from bot.keyboards.inline.admin_keyboards import ( from bot.middlewares.i18n import JsonI18n router = Router(name="admin_user_management_router") - USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") +async def _get_user_model_by_input(session: AsyncSession, + input_text: str) -> Optional[User]: + + user_model: Optional[User] = None + if input_text.isdigit(): + try: + user_model = await user_dal.get_user_by_id(session, + int(input_text)) + except ValueError: + pass + elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]): + user_model = await user_dal.get_user_by_username( + session, input_text[1:]) + elif USERNAME_REGEX.match(input_text): + user_model = await user_dal.get_user_by_username(session, input_text) + return user_model + + 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')) + settings: Settings, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - logging.error("i18n missing") - await callback.answer("Language error.", show_alert=True) + if not i18n or not callback.message: + await callback.answer("Error processing ban prompt.", 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)) + 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 for ban_user_prompt: {e}. Sending new message.") + 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) @@ -58,103 +74,90 @@ async def ban_user_prompt_handler(callback: types.CallbackQuery, 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')) + panel_service: PanelApiService, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: - logging.error("i18n missing") - await message.reply("Language error.") + await message.reply("Language service 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: + + input_text = message.text.strip() if message.text else "" + user_to_ban_model = await _get_user_model_by_input(session, input_text) + + if not user_to_ban_model: 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: + + user_id_to_ban = user_to_ban_model.user_id + user_display_for_msg = user_to_ban_model.username or str(user_id_to_ban) + + if message.from_user and (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']: + + if user_to_ban_model.is_banned: await message.answer( _("admin_user_already_banned", - user_id_or_username=user_id_or_username_for_msg)) + user_id_or_username=user_display_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) + + ban_success_local = await user_dal.set_user_ban_status( + session, user_id_to_ban, True) + + if ban_success_local: + + panel_ban_message_part = "" + if user_to_ban_model.panel_user_uuid: + panel_ban_api_success = await panel_service.update_user_status_on_panel( + user_to_ban_model.panel_user_uuid, enable=False) + if panel_ban_api_success: + panel_ban_message_part = _("admin_panel_ban_success_part") + else: + panel_ban_message_part = _("admin_panel_ban_fail_part") + + await session.commit() + await message.answer(_("admin_user_banned_success_combined", + user_id_or_username=user_display_for_msg, + panel_status_part=panel_ban_message_part), + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) 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 session.rollback() + await message.answer(_("admin_user_ban_failed_local_db_error")) + 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')) + settings: Settings, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - logging.error("i18n missing") - await callback.answer("Language error.", show_alert=True) + if not i18n or not callback.message: + await callback.answer("Error processing unban prompt.", + 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)) + 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 for unban_user_prompt: {e}. Sending new message.") + 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) @@ -164,131 +167,168 @@ 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')) + panel_service: PanelApiService, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: - logging.error("i18n missing") - await message.reply("Language error.") + await message.reply("Language service 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: + + input_text = message.text.strip() if message.text else "" + user_to_unban_model = await _get_user_model_by_input(session, input_text) + + if not user_to_unban_model: 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']: + + user_id_to_unban = user_to_unban_model.user_id + user_display_for_msg = user_to_unban_model.username or str( + user_id_to_unban) + + if not user_to_unban_model.is_banned: await message.answer( _("admin_user_not_banned", - user_id_or_username=user_id_or_username_for_msg)) + user_id_or_username=user_display_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) + + unban_success_local = await user_dal.set_user_ban_status( + session, user_id_to_unban, False) + + if unban_success_local: + panel_unban_message_part = "" + if user_to_unban_model.panel_user_uuid: + panel_unban_api_success = await panel_service.update_user_status_on_panel( + user_to_unban_model.panel_user_uuid, enable=True) + if panel_unban_api_success: + panel_unban_message_part = _("admin_panel_unban_success_part") + else: + panel_unban_message_part = _("admin_panel_unban_fail_part") + + await session.commit() + await message.answer(_("admin_user_unbanned_success_combined", + user_id_or_username=user_display_for_msg, + panel_status_part=panel_unban_message_part), + reply_markup=get_back_to_admin_panel_keyboard( + current_lang, i18n)) 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 session.rollback() + await message.answer(_("admin_user_unban_failed_local_db_error")) + await state.clear() async def view_banned_users_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - state: FSMContext): + state: FSMContext, i18n_data: dict, + settings: Settings, session: AsyncSession): await state.clear() - current_page = 0 + current_page_idx = 0 if ":" in callback.data and callback.data.count(":") == 2: try: - current_page = int(callback.data.split(":")[-1]) + current_page_idx = int(callback.data.split(":")[-1]) except ValueError: - current_page = 0 - current_lang = i18n_data.get("current_language", - getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + current_page_idx = 0 + + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await callback.answer("Language error.", show_alert=True) + if not i18n or not callback.message: + await callback.answer("Error displaying banned users.", + 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) + page_size = settings.LOGS_PAGE_SIZE + offset = current_page_idx * page_size - if not callback.message: - await callback.answer("Error.") - return - if total_banned == 0: + banned_user_models, total_banned_count = await user_dal.get_banned_users_paginated( + session, limit=page_size, offset=offset) + + if total_banned_count == 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 + total_pages = math.ceil(total_banned_count / + page_size) if page_size > 0 else 1 await callback.message.edit_text( text=_("admin_banned_list_title", - current_page=current_page + 1, + current_page=current_page_idx + 1, total_pages=max(1, total_pages)), - reply_markup=get_banned_users_keyboard(banned_users, current_page, - total_banned, i18n, + reply_markup=get_banned_users_keyboard(banned_user_models, + current_page_idx, + total_banned_count, i18n, current_lang, settings)) await callback.answer() +async def _show_user_card_actual(target_message: types.Message, + user_id_to_show: int, + banned_list_page_to_return: int, + i18n_data: dict, settings: Settings, + panel_service: PanelApiService, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: return + + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + user_model = await user_dal.get_user_by_id(session, user_id_to_show) + if not user_model: + await target_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)) + return + + user_display_name = user_model.first_name or (f"@{user_model.username}" + if user_model.username else + f"ID: {user_id_to_show}") + + sub_end_date_str = await subscription_dal.get_user_active_subscription_end_date_str( + session, user_id_to_show) or _("user_card_sub_na") + + reg_date_display = user_model.registration_date.strftime( + '%Y-%m-%d %H:%M') if user_model.registration_date else "N/A" + + card_text = _("user_card_info", + user_id=user_model.user_id, + username=user_model.username or "N/A", + first_name=user_model.first_name or "", + last_name=user_model.last_name or "", + language_code=user_model.language_code or "N/A", + panel_user_uuid=user_model.panel_user_uuid or "N/A", + ban_status=_("user_card_banned") + if user_model.is_banned else _("user_card_active"), + reg_date=reg_date_display, + sub_end_date=sub_end_date_str) + await target_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_model.is_banned), i18n, + current_lang, + banned_list_page_to_return), + parse_mode="HTML") + + @router.callback_query(F.data.startswith("admin_user_card:")) async def show_user_card_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, settings: Settings, panel_service: PanelApiService, - state: FSMContext, + session: AsyncSession, 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 @@ -300,281 +340,210 @@ async def show_user_card_handler(callback: types.CallbackQuery, 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) + if not i18n or not callback.message: + await callback.answer("Error displaying user card.", 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 _show_user_card_actual( + target_message=callback.message, + user_id_to_show=user_id_to_show, + banned_list_page_to_return=banned_list_page_to_return, + i18n_data=i18n_data, + settings=settings, + panel_service=panel_service, + session=session) + if force_user_id is None: 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): - +async def _confirm_action_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession, action_type: str): 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) + except (ValueError, IndexError): + await callback.answer("Invalid confirmation data.", show_alert=True) return - current_lang = i18n_data.get("current_language", - getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await callback.answer("Language error.", show_alert=True) + if not i18n or not callback.message: + await callback.answer("Error processing confirmation.", + 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") + + user_model = await user_dal.get_user_by_id(session, user_id) + user_display = (user_model.first_name or + (f"@{user_model.username}" if user_model.username else + f"ID {user_id}")) if user_model else f"ID {user_id}" + + prompt_key = f"admin_confirm_{action_type}_prompt" + yes_callback = f"admin_{action_type}_do:{user_id}:{banned_list_page}" + no_callback = f"admin_user_card:{user_id}:{banned_list_page}" + + await callback.message.edit_text( + text=_("admin_confirm_action_title", + action_text=_(f"{action_type}_verb_l")) + "\n\n" + + _(prompt_key, user_display=user_display, user_id=user_id), + reply_markup=get_confirmation_keyboard(yes_callback_data=yes_callback, + no_callback_data=no_callback, + 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): + settings: Settings, session: AsyncSession): + await _confirm_action_handler(callback, i18n_data, settings, session, + "ban") + + +@router.callback_query(F.data.startswith("admin_unban_confirm:")) +async def confirm_unban_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession): + await _confirm_action_handler(callback, i18n_data, settings, session, + "unban") + + +async def _do_ban_unban_action_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + panel_service: PanelApiService, + session: AsyncSession, + state: FSMContext, action_type: str): try: + _, user_id_str, page_str = callback.data.split(":") - user_id = int(user_id_str) + user_id_target = int(user_id_str) banned_list_page = int(page_str) - except: - await callback.answer("Invalid data", show_alert=True) + except (ValueError, IndexError): + await callback.answer("Invalid action data.", show_alert=True) return - current_lang = i18n_data.get("current_language", - getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await callback.answer("Language error.", show_alert=True) + if not i18n or not callback.message: + await callback.answer("Error processing action.", 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), + user_model = await user_dal.get_user_by_id(session, user_id_target) + if not user_model: + await callback.answer(_("admin_user_not_found_in_bot_db", + user_id=user_id_target), 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) + + user_display_name = user_model.first_name or (f"@{user_model.username}" + if user_model.username else + f"ID {user_id_target}") + + is_banning = action_type == "ban" + + if is_banning: + if user_model.is_banned: + await callback.answer(_("admin_user_already_banned", + user_id_or_username=user_display_name), + show_alert=True) + return + if user_id_target == callback.from_user.id or user_id_target in settings.ADMIN_IDS: + await callback.answer(_("admin_cannot_ban_self_or_admin"), + show_alert=True) + return + else: + if not user_model.is_banned: + await callback.answer(_("admin_user_not_banned", + user_id_or_username=user_display_name), + show_alert=True) + return + + action_success_local = await user_dal.set_user_ban_status( + session, user_id_target, is_banning) + + if action_success_local: + panel_action_message = "" + if user_model.panel_user_uuid: + panel_api_success = await panel_service.update_user_status_on_panel( + user_model.panel_user_uuid, enable=not is_banning) + if not panel_api_success: + panel_action_message = _("admin_panel_status_update_fail_part") + logging.warning( + f"Panel status update failed for {action_type} of user {user_id_target} (panel: {user_model.panel_user_uuid})" + ) + + await session.commit() + alert_message_key = f"admin_user_{action_type}ned_from_card_alert" + await callback.answer(_(alert_message_key, + user_display=user_display_name, + user_id=user_id_target) + " " + + panel_action_message, + show_alert=False) + else: + await session.rollback() + await callback.answer(_(f"admin_user_{action_type}_failed_db_error"), + show_alert=True) + return + + await show_user_card_handler(callback, + state, + i18n_data, + settings, + panel_service, + session, + force_user_id=user_id_target, + 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 + session: AsyncSession, state: FSMContext): + await _do_ban_unban_action_handler(callback, i18n_data, settings, + panel_service, session, state, "ban") - 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.startswith("admin_unban_do:")) +async def do_unban_user_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, + panel_service: PanelApiService, + session: AsyncSession, state: FSMContext): + await _do_ban_unban_action_handler(callback, i18n_data, settings, + panel_service, session, state, "unban") @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')) +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", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await callback.answer("Language error.", show_alert=True) + if not i18n or not callback.message: + await callback.answer("Error cancelling.", 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")) + + try: + await callback.message.edit_text(_("admin_action_cancelled_default"), + reply_markup=get_admin_panel_keyboard( + i18n, current_lang, settings)) + except Exception: + 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_alert")) await state.clear() diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 76e5333..6063652 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -1,35 +1,36 @@ import logging import json -import aiosqlite import asyncio -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from typing import Optional, Dict, Any from aiohttp import web from aiogram import Bot +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + from yookassa.domain.notification import WebhookNotification -from yookassa.domain.models import Amount +from yookassa.domain.models.amount import Amount as YooKassaAmount + +from db.dal import payment_dal, user_dal -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.services.payment_service import YooKassaService from bot.middlewares.i18n import JsonI18n from config.settings import Settings -from bot.services.payment_service import YooKassaService 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' -async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict, +async def process_successful_payment(session: AsyncSession, 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", {}) @@ -43,6 +44,8 @@ async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict, f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" ) return + + db_user = None try: user_id = int(user_id_str) subscription_months = int(subscription_months_str) @@ -50,262 +53,283 @@ async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict, 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)) + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user: + logging.error( + f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." + ) + + await payment_dal.update_payment_status_by_db_id( + session, payment_db_id, "failed_user_not_found", + payment_info_from_webhook.get("id")) + + return + except (TypeError, ValueError) as e: logging.error( f"Invalid metadata format for payment processing: {metadata} - {e}" ) + + if payment_db_id_str and payment_db_id_str.isdigit(): + try: + await payment_dal.update_payment_status_by_db_id( + session, int(payment_db_id_str), "failed_metadata_error", + payment_info_from_webhook.get("id")) + except Exception as e_upd: + logging.error( + f"Failed to update payment status after metadata error: {e_upd}" + ) return - 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"))) - - 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 - - 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") - - await db.commit() - - 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") - - 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) - 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}" - ) - 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: + try: + yk_payment_id_from_hook = payment_info_from_webhook.get("id") + updated_payment_record = await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=payment_db_id, + new_status=payment_info_from_webhook.get("status", "succeeded"), + yk_payment_id=yk_payment_id_from_hook) + if not updated_payment_record: 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}" - ) + f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" + ) + raise Exception( + f"DB Error: Could not update payment record {payment_db_id}") + + activation_details = await subscription_service.activate_subscription( + session, + user_id, + subscription_months, + payment_value, + payment_db_id, + promo_code_id_from_payment=promo_code_id) + + if not activation_details or not activation_details.get('end_date'): + logging.error( + f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" + ) + raise Exception( + f"Subscription Error: Failed to activate for user {user_id}") + + base_subscription_end_date = activation_details['end_date'] + final_end_date_for_user = base_subscription_end_date + applied_promo_bonus_days = activation_details.get( + "applied_promo_bonus_days", 0) + + referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment( + session, user_id, subscription_months) + applied_referee_bonus_days_from_referral: Optional[int] = None + 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_from_referral = referral_bonus_info.get( + "referee_bonus_applied_days") + + user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE + _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) + + success_message = "" + if applied_referee_bonus_days_from_referral and final_end_date_for_user: + inviter_name_display = _("friend_placeholder") + if db_user and db_user.referred_by_id: + inviter = await user_dal.get_user_by_id( + session, db_user.referred_by_id) + if inviter and inviter.first_name: + inviter_name_display = inviter.first_name + elif inviter and inviter.username: + inviter_name_display = f"@{inviter.username}" + + success_message = _( + "payment_successful_with_referral_bonus", + months=subscription_months, + base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'), + bonus_days=applied_referee_bonus_days_from_referral, + final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'), + inviter_name=inviter_name_display) + elif applied_promo_bonus_days > 0 and final_end_date_for_user: + success_message = _( + "payment_successful_with_promo", + months=subscription_months, + bonus_days=applied_promo_bonus_days, + end_date=final_end_date_for_user.strftime('%Y-%m-%d')) + 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} after successful payment logic." + ) + success_message = _("payment_successful_error_details") + + try: + await bot.send_message(user_id, success_message) + except Exception as e_notify: + logging.error( + f"Failed to send final payment success message to user {user_id}: {e_notify}" + ) + + except Exception as e_process: + logging.error( + f"Error during process_successful_payment main try block for user {user_id}: {e_process}", + exc_info=True) + + raise -async def process_cancelled_payment(bot: Bot, payment_info_from_webhook: dict, +async def process_cancelled_payment(session: AsyncSession, 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')}" + f"Missing metadata in cancelled payment webhook: {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}" - ) + f"Invalid metadata in cancelled payment webhook: {metadata}") + return + + try: + updated_payment = await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=payment_db_id, + new_status=payment_info_from_webhook.get("status", "canceled"), + yk_payment_id=payment_info_from_webhook.get("id")) + + if updated_payment: + logging.info( + f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." + ) + else: + logging.warning( + f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." + ) + + db_user = await user_dal.get_user_by_id(session, user_id) + user_lang = settings.DEFAULT_LANGUAGE + if db_user and db_user.language_code: user_lang = db_user.language_code + + _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) + await bot.send_message(user_id, _("payment_failed")) + + except Exception as e_process_cancel: + logging.error( + f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", + exc_info=True) + raise 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: + async_session_factory: sessionmaker = request.app[ + 'async_session_factory'] + except KeyError as e_app_ctx: logging.error( - f"KeyError accessing app context in yookassa_webhook_route: {e}.", + f"KeyError accessing app context in yookassa_webhook_route: {e_app_ctx}.", exc_info=True) - return web.Response(status=500, - text="Internal Server Error: Missing app context") + return web.Response( + status=500, + text="Internal Server Error: Missing app context component") + 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}'" + f"YooKassa Webhook Parsed: Event='{notification_object.event}', " + f"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." + f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." ) 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") + + 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(payment_data_from_notification.amount.value), + "currency": str(payment_data_from_notification.amount.currency) + } if payment_data_from_notification.amount else {}, + "metadata": + dict(payment_data_from_notification.metadata), + "description": + str(payment_data_from_notification.description) + if payment_data_from_notification.description else None, + } + 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) + async with async_session_factory() as session: + try: + 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( + session, bot, payment_dict_for_processing, + i18n_instance, settings, panel_service, + subscription_service, referral_service) + await session.commit() + else: + logging.warning( + f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " + f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " + f"paid='{payment_dict_for_processing.get('paid')}'" + ) + elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED: + await process_cancelled_payment( + session, bot, payment_dict_for_processing, + i18n_instance, settings) + await session.commit() + except Exception as e_webhook_db_processing: + await session.rollback() + logging.error( + f"Error processing YooKassa webhook event '{notification_object.event}' " + f"for YK Payment ID {payment_dict_for_processing.get('id')} in DB transaction: {e_webhook_db_processing}", + exc_info=True) + return web.Response( + status=200, text="ok_internal_processing_error_logged") + 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("YooKassa Webhook: Invalid JSON received.") + return web.Response(status=400, text="bad_request_invalid_json") + except Exception as e_general_webhook: logging.error( - f"KeyError in yookassa_webhook_route after initial context access: {e}.", + f"YooKassa Webhook general processing error: {e_general_webhook}", 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") + return web.Response(status=200, + text="ok_general_internal_error_logged") diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index 79af153..03dad66 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -3,6 +3,8 @@ import re from aiogram import Router, F, types, Bot from aiogram.fsm.context import FSMContext from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from aiogram.utils.markdown import hcode from config.settings import Settings from bot.states.user_states import UserPromoStates @@ -10,26 +12,26 @@ 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") 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", + r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|" + r";\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b", re.IGNORECASE) SUSPICIOUS_CHARS_REGEX = re.compile(r"(--|#\s|;|\*\/|\/\*)") +MAX_PROMO_CODE_INPUT_LENGTH = 100 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')) + settings: Settings, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: - await callback.answer("Language error.", show_alert=True) + await callback.answer("Language service error.", show_alert=True) return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) @@ -44,8 +46,10 @@ async def prompt_promo_code_input(callback: types.CallbackQuery, 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}") + except Exception as e_edit: + logging.warning( + f"Failed to edit message for promo prompt: {e_edit}. Sending new one." + ) await callback.message.answer( text=_(key="promo_code_prompt"), reply_markup=get_back_to_main_menu_markup(current_lang, i18n)) @@ -53,73 +57,96 @@ async def prompt_promo_code_input(callback: types.CallbackQuery, 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()}" - ) + f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. " + f"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): + bot: Bot, session: AsyncSession): 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')) + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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.") + logging.error( + "Dependencies (i18n or PromoCodeService) missing in process_promo_code_input" + ) + await message.reply("Service error. Please try again later.") await state.clear() return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - code_input = message.text.strip() + code_input = message.text.strip() if message.text else "" 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: + if not code_input: + is_suspicious = True + logging.warning(f"Empty promo code input by user {user.id}.") + elif len( + code_input + ) > MAX_PROMO_CODE_INPUT_LENGTH or SUSPICIOUS_SQL_KEYWORDS_REGEX.search( + code_input) or SUSPICIOUS_CHARS_REGEX.search(code_input): is_suspicious = True logging.warning( - f"Suspicious input for promo by user {user.id} (len: {len(code_input)}): '{code_input}'" + f"Suspicious input for promo code 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}" - ) + + if settings.ADMIN_IDS: + admin_notify_key = "admin_suspicious_promo_attempt_notification" if user.username else "admin_suspicious_promo_attempt_notification_no_username" + + 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)) + for admin_id in settings.ADMIN_IDS: + try: + await bot.send_message(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 {admin_id}: {e_admin_notify}" + ) + response_to_user_text = _("promo_code_not_found", - code=code_input.upper()) + code=hcode(code_input.upper())) else: + success, response_text_from_service = await promo_code_service.apply_promo_code( - user.id, code_input, current_lang) + session, user.id, code_input, current_lang) response_to_user_text = response_text_from_service + if success: + await session.commit() + logging.info( + f"Promo code '{code_input}' successfully applied for user {user.id}." + ) + else: + await session.rollback() + logging.info( + f"Promo code '{code_input}' application failed for user {user.id}. Reason: {response_text_from_service}" + ) await message.answer(response_to_user_text, reply_markup=get_back_to_main_menu_markup( - current_lang, i18n)) + current_lang, i18n), + parse_mode="HTML") await state.clear() logging.info( - f"Promo code '{code_input}' processing finished for user {message.from_user.id}. State cleared." + f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared." ) @@ -127,9 +154,9 @@ async def process_promo_code_input(message: types.Message, state: FSMContext, 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_data: dict, subscription_service: SubscriptionService, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: logging.error("i18n missing in cancel_promo_input_via_button") @@ -140,21 +167,17 @@ async def cancel_promo_input_via_button( 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 await send_main_menu(callback, settings, i18n_data, - show_trial_button_flag=show_trial_button_on_back, + subscription_service, + session, is_edit=True) else: - await callback.answer("Promo code input cancelled.", show_alert=False) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + await callback.answer(_("promo_input_cancelled_short"), + show_alert=False) diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py index bf355b8..ea06161 100644 --- a/bot/handlers/user/referral.py +++ b/bot/handlers/user/referral.py @@ -1,67 +1,88 @@ import logging from aiogram import Router, F, types, Bot from aiogram.filters import Command -from typing import Optional, Dict +from typing import Optional, Union +from sqlalchemy.ext.asyncio import AsyncSession 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.keyboards.inline.user_keyboards import get_back_to_main_menu_markup from bot.middlewares.i18n import JsonI18n router = Router(name="user_referral_router") -async def referral_command_handler(event: types.Message | types.CallbackQuery, +async def referral_command_handler(event: Union[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')) + referral_service: ReferralService, bot: Bot, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - target_message = event.message if isinstance( + target_message_obj = event.message if isinstance( event, types.CallbackQuery) else event - if not target_message: + if not target_message_obj: logging.error( - "Target message is None in referral_command_handler from callback." + "Target message is None in referral_command_handler (possibly from callback without message)." ) if isinstance(event, types.CallbackQuery): - await event.answer("Error displaying referral info.") + await event.answer("Error displaying referral info.", + show_alert=True) 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) + logging.error( + "Dependencies (i18n or ReferralService) missing in referral_command_handler" + ) + await target_message_obj.answer( + "Service error. Please try again later.") if isinstance(event, types.CallbackQuery): await event.answer() return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - bot_info = await bot.get_me() - bot_username = bot_info.username + try: + bot_info = await bot.get_me() + bot_username = bot_info.username + except Exception as e_bot_info: + logging.error( + f"Failed to get bot info for referral link: {e_bot_info}") + await target_message_obj.answer(_("error_generating_referral_link")) + if isinstance(event, types.CallbackQuery): await event.answer() + return + + if not bot_username: + logging.error("Bot username is None, cannot generate referral link.") + await target_message_obj.answer(_("error_generating_referral_link")) + if isinstance(event, types.CallbackQuery): await event.answer() + return + 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) + bonus_info_parts = [] + if settings.subscription_options: + + for months_period_key, _price in sorted( + settings.subscription_options.items()): + + inv_bonus = settings.referral_bonus_inviter.get(months_period_key) + ref_bonus = settings.referral_bonus_referee.get(months_period_key) if inv_bonus is not None or ref_bonus is not None: bonus_info_parts.append( _("referral_bonus_per_period", - months=months_period, + months=months_period_key, inviter_bonus_days=inv_bonus - if inv_bonus is not None else _("no_bonus_days"), + if inv_bonus is not None else _("no_bonus_placeholder"), referee_bonus_days=ref_bonus - if ref_bonus is not None else _("no_bonus_days"))) + if ref_bonus is not None else _("no_bonus_placeholder"))) + 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) @@ -72,29 +93,16 @@ async def referral_command_handler(event: types.Message | types.CallbackQuery, await event.answer(text, reply_markup=reply_markup_val, disable_web_page_preview=True) - elif isinstance(event, types.CallbackQuery): + elif isinstance(event, types.CallbackQuery) and event.message: 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}") - + except Exception as e_edit: + logging.warning( + f"Failed to edit message for referral info: {e_edit}. Sending new one." + ) 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) diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 8cbb8e3..fc41f76 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -2,10 +2,12 @@ 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 typing import Optional, Union +from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime, timezone + +from db.dal import user_dal -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 @@ -13,228 +15,272 @@ 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") -async def send_main_menu(message_or_callback: types.Message - | types.CallbackQuery, +async def send_main_menu(target_event: Union[types.Message, + types.CallbackQuery], settings: Settings, i18n_data: dict, - show_trial_button_flag: bool, + subscription_service: SubscriptionService, + session: AsyncSession, is_edit: bool = False): - current_lang = i18n_data.get("current_language", - getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - answered_callback_internally = False + + user_id = target_event.from_user.id + user_full_name = target_event.from_user.full_name 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: + logging.error( + f"i18n_instance missing in send_main_menu for user {user_id}") + err_msg_fallback = "Error: Language service unavailable. Please try again later." + if isinstance(target_event, types.CallbackQuery): 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 + await target_event.answer(err_msg_fallback, show_alert=True) + except Exception: + pass + elif isinstance(target_event, types.Message) and hasattr( + target_event, 'chat') and target_event.chat: + try: + await target_event.chat.send_message(err_msg_fallback) + except Exception: + pass 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 - if not target_message: + show_trial_button_in_menu = False + if settings.TRIAL_ENABLED: + if hasattr( + subscription_service, 'has_had_any_subscription') and callable( + getattr(subscription_service, 'has_had_any_subscription')): + if not await subscription_service.has_had_any_subscription( + session, user_id): + show_trial_button_in_menu = True + else: + logging.error( + "Method has_had_any_subscription is missing in SubscriptionService for send_main_menu!" + ) + + text = _(key="main_menu_greeting", user_name=user_full_name) + reply_markup = get_main_menu_inline_keyboard(current_lang, i18n, settings, + show_trial_button_in_menu) + + target_message_obj: Optional[types.Message] = None + if isinstance(target_event, types.Message): + target_message_obj = target_event + elif isinstance(target_event, + types.CallbackQuery) and target_event.message: + target_message_obj = target_event.message + + if not target_message_obj: logging.error( - f"send_main_menu: target_message is None for event from user {message_or_callback.from_user.id}." + f"send_main_menu: target_message_obj is None for event from user {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 isinstance(target_event, types.CallbackQuery): + await target_event.answer(_("error_displaying_menu"), + show_alert=True) return try: if is_edit: - await target_message.edit_text(text, reply_markup=reply_markup) + await target_message_obj.edit_text(text, reply_markup=reply_markup) else: - await target_message.answer(text, reply_markup=reply_markup) + await target_message_obj.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 + if isinstance(target_event, types.CallbackQuery): + await target_event.answer() 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}." + f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}." ) - if is_edit: + if is_edit and target_message_obj and hasattr( + target_message_obj, 'chat') and target_message_obj.chat: try: - await target_message.answer(text, reply_markup=reply_markup) + await target_message_obj.chat.send_message( + 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() + f"Also failed to send new main menu message for user {user_id}: {e_send_new}" + ) + if isinstance(target_event, types.CallbackQuery): + await target_event.answer( + _("error_occurred_try_again") if is_edit else None) @router.message(CommandStart()) -async def start_command_handler(message: types.Message, state: FSMContext, - settings: Settings, i18n_data: dict, +async def start_command_handler(message: types.Message, + state: FSMContext, + settings: Settings, + i18n_data: dict, subscription_service: SubscriptionService, - bot: Bot): - + session: AsyncSession, + command: Optional[CommandStart] = None): 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 + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs + ) if i18n else key + + user = message.from_user + user_id = user.id + referred_by_user_id: Optional[int] = None - args = message.text.split() - if len(args) > 1 and args[0] == "/start": + if command and command.args: + arg_payload = command.args + if arg_payload.startswith("ref_"): + try: + potential_referrer_id_str = arg_payload.split("_")[1] + if potential_referrer_id_str.isdigit(): + potential_referrer_id = int(potential_referrer_id_str) + if potential_referrer_id != user_id: + referred_by_user_id = potential_referrer_id + except (IndexError, ValueError) as e: + logging.warning( + f"Could not parse referral from /start args '{arg_payload}': {e}" + ) + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user: + user_data_to_create = { + "user_id": user_id, + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "language_code": current_lang, + "referred_by_id": referred_by_user_id, + "registration_date": datetime.now(timezone.utc) + } 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.") + db_user = await user_dal.create_user(session, user_data_to_create) - else: logging.info( - f"User {user_id} not eligible for trial button (already had a subscription)." + f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." ) + except Exception as e_create: + + logging.error( + f"Failed to add new user {user_id} to session: {e_create}", + exc_info=True) + await message.answer(_("error_occurred_processing_request")) + return else: - logging.info(f"Trial period is disabled in settings. No trial button.") + update_payload = {} + if db_user.language_code != current_lang: + update_payload["language_code"] = current_lang + if referred_by_user_id and db_user.referred_by_id is None: + update_payload["referred_by_id"] = referred_by_user_id + if user.username != db_user.username: + update_payload["username"] = user.username + if user.first_name != db_user.first_name: + update_payload["first_name"] = user.first_name + if user.last_name != db_user.last_name: + update_payload["last_name"] = user.last_name + + if update_payload: + try: + await user_dal.update_user(session, user_id, update_payload) + + logging.info( + f"Updated existing user {user_id} in session: {update_payload}" + ) + except Exception as e_update: + + logging.error( + f"Failed to update existing user {user_id} in session: {e_update}", + exc_info=True) + + await message.answer(_(key="welcome", user_name=user.full_name)) await send_main_menu(message, settings, i18n_data, - show_trial_button_flag=show_trial_button_in_menu) + subscription_service, + session, + is_edit=False) @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): - +async def language_command_handler( + event: Union[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) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs + ) if i18n else key + 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.") + reply_markup = get_language_selection_keyboard(i18n, current_lang) + + target_message_obj = event.message if isinstance( + event, types.CallbackQuery) else event + if not target_message_obj: + if isinstance(event, types.CallbackQuery): + await event.answer(_("error_occurred_try_again"), show_alert=True) 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) + + if isinstance(event, types.CallbackQuery): + if event.message: + try: + await event.message.edit_text(text_to_send, + reply_markup=reply_markup) + except Exception: + await target_message_obj.answer(text_to_send, + reply_markup=reply_markup) + await event.answer() else: - await target_message_for_reply.answer( - text_to_send, reply_markup=reply_markup_to_send) + await target_message_obj.answer(text_to_send, + reply_markup=reply_markup) @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): + subscription_service: SubscriptionService, session: AsyncSession): i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n or not callback.message: - await callback.answer("Language service error.", show_alert=True) + await callback.answer("Service error or message context lost.", + show_alert=True) + return + + try: + lang_code = callback.data.split("_")[2] + except IndexError: + await callback.answer("Error processing language selection.", + show_alert=True) return - 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) + try: + updated = await user_dal.update_user_language(session, user_id, + lang_code) + if updated: - await callback.answer(_(key="language_set_alert")) + i18n_data["current_language"] = lang_code + _ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs) + await callback.answer(_(key="language_set_alert")) + logging.info( + f"User {user_id} language updated to {lang_code} in session.") + else: + await callback.answer("Could not set language.", show_alert=True) + return + except Exception as e_lang_update: - 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) + logging.error( + f"Error updating lang for user {user_id}: {e_lang_update}", + exc_info=True) + await callback.answer("Error setting language.", show_alert=True) + return + await send_main_menu(callback, + settings, + i18n_data, + subscription_service, + session, + is_edit=True) @router.callback_query(F.data.startswith("main_action:")) @@ -242,44 +288,48 @@ 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): - + promo_code_service: PromoCodeService, session: AsyncSession): action = callback.data.split(":")[1] + user_id = callback.from_user.id + 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.") + await callback.answer("Error: message context lost.", show_alert=True) return if action == "subscribe": await user_subscription_handlers.display_subscription_options( - callback, i18n_data, settings) + callback, i18n_data, settings, session) elif action == "my_subscription": + await user_subscription_handlers.my_subscription_command_handler( - callback, i18n_data, settings, panel_service, subscription_service) + callback, i18n_data, settings, panel_service, subscription_service, + session, bot) elif action == "referral": await user_referral_handlers.referral_command_handler( - callback, settings, i18n_data, referral_service, bot) + callback, settings, i18n_data, referral_service, bot, session) elif action == "apply_promo": await user_promo_handlers.prompt_promo_code_input( - callback, state, i18n_data, settings) + callback, state, i18n_data, settings, session) elif action == "request_trial": await user_trial_handlers.request_trial_confirmation_handler( - callback, settings, i18n_data, subscription_service) + callback, settings, i18n_data, subscription_service, session) 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, + subscription_service, + session, is_edit=True) else: - await callback.answer("Unknown action.", show_alert=True) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext( + i18n_data.get("current_language"), key, **kw) if i18n else key + await callback.answer(_("main_menu_unknown_action"), show_alert=True) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 7c04237..af3560c 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -1,15 +1,12 @@ 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 typing import Optional, Dict, Any, Union from datetime import datetime, timezone -from aiogram.utils.keyboard import InlineKeyboardBuilder -from aiogram.types import InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings -from db.database import add_payment_record, get_db_connection_manager, _setup_db_connection +from db.dal import payment_dal 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) @@ -21,307 +18,345 @@ from bot.middlewares.i18n import JsonI18n 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')) +async def display_subscription_options(event: Union[types.Message, + types.CallbackQuery], + i18n_data: dict, settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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() + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs + ) if i18n else key + + if not i18n: + err_msg = "Language service error." + if isinstance(event, types.CallbackQuery): + await event.answer(err_msg, show_alert=True) + elif isinstance(event, types.Message): + await event.answer(err_msg) return - get_translation = lambda key, **kwargs: i18n.gettext( - current_lang, key, **kwargs) currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL + text_content = get_text("select_subscription_period" + ) if settings.subscription_options else get_text( + "no_subscription_options_available") - 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 + settings.subscription_options, currency_symbol_val, current_lang, i18n + ) if settings.subscription_options else get_back_to_main_menu_markup( + current_lang, i18n) - target_message = message_or_callback.message if isinstance( - message_or_callback, types.CallbackQuery) else message_or_callback - answered_callback = False + target_message_obj = event.message if isinstance( + event, types.CallbackQuery) else event + if not target_message_obj: + if isinstance(event, types.CallbackQuery): + await event.answer(get_text("error_occurred_try_again"), + show_alert=True) + return - if isinstance(message_or_callback, types.CallbackQuery): - - 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 isinstance(message_or_callback, - types.CallbackQuery) and not answered_callback: - await message_or_callback.answer() + if isinstance(event, types.CallbackQuery): + try: + await target_message_obj.edit_text(text_content, + reply_markup=reply_markup) + except Exception: + await target_message_obj.answer(text_content, + reply_markup=reply_markup) + await event.answer() + else: + await target_message_obj.answer(text_content, + reply_markup=reply_markup) @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')) + callback: types.CallbackQuery, settings: Settings, i18n_data: dict, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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.", + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs + ) if i18n else key + + if not i18n or not callback.message: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) return - 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) + except (ValueError, IndexError): + logging.error( + f"Invalid subscription period in callback_data: {callback.data}") + await callback.answer(get_text("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) + price_rub = settings.subscription_options.get(months) + if price_rub is None: + logging.error( + f"Price not found for {months} months subscription period in settings.subscription_options." + ) + await callback.answer(get_text("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, + confirmation_text_content = get_text("confirm_subscription_prompt", + months=months, + price=f"{price_rub:.2f}", + currency_symbol=currency_symbol_val) + reply_markup = get_confirm_subscription_keyboard(months, price_rub, 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) + try: + await callback.message.edit_text(confirmation_text_content, + reply_markup=reply_markup) + except Exception as e_edit: + logging.warning( + f"Edit message for subscription confirmation failed: {e_edit}. Sending new one." + ) + await callback.message.answer(confirmation_text_content, + 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): + callback: types.CallbackQuery, settings: Settings, i18n_data: dict, + yookassa_service: YooKassaService, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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"), + + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs + ) if i18n else key + + if not i18n or not callback.message: + + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) return + + if not yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service is not configured or unavailable.") + target_msg_edit = callback.message + await target_msg_edit.edit_text(get_text("payment_service_unavailable") + ) + await callback.answer(get_text("payment_service_unavailable_alert"), + 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) + price_rub = float(price_str) + except (ValueError, IndexError): + logging.error( + f"Invalid confirmation data in callback: {callback.data}") + await callback.answer(get_text("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 = { + + payment_description = get_text("payment_description_subscription", + months=months) + currency_code_for_yk = "RUB" + + payment_record_data = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code_for_yk, + "status": "pending_yookassa", + "description": payment_description, + "subscription_duration_months": months, + } + db_payment_record = None + try: + db_payment_record = await payment_dal.create_payment_record( + session, payment_record_data) + await session.commit() + logging.info( + f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." + ) + except Exception as e_db_payment: + await session.rollback() + logging.error( + f"Failed to create payment record in DB for user {user_id}: {e_db_payment}", + exc_info=True) + await callback.message.edit_text( + get_text("error_creating_payment_record")) + await callback.answer(get_text("error_try_again"), show_alert=True) + return + + if not db_payment_record: + await callback.message.edit_text( + get_text("error_creating_payment_record")) + await callback.answer(get_text("error_try_again"), show_alert=True) + return + + yookassa_metadata = { "user_id": str(user_id), "subscription_months": str(months), - "description": description + "payment_db_id": str(db_payment_record.payment_id), } - 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) + receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL - 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() + payment_response_yk = await yookassa_service.create_payment( + amount=price_rub, + currency=currency_code_for_yk, + description=payment_description, + metadata=yookassa_metadata, + receipt_email=receipt_email_for_yk) + + if payment_response_yk and payment_response_yk.get("confirmation_url"): + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=db_payment_record.payment_id, + new_status=payment_response_yk.get("status", "pending"), + yk_payment_id=payment_response_yk.get("id")) + await session.commit() + except Exception as e_db_update_ykid: + await session.rollback() + logging.error( + f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", + exc_info=True) 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")) + get_text("error_payment_gateway_link_failed")) + await callback.answer(get_text("error_try_again"), show_alert=True) + return + + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + payment_response_yk["confirmation_url"], current_lang, i18n), + disable_web_page_preview=False) + else: + try: + await payment_dal.update_payment_status_by_db_id( + session, db_payment_record.payment_id, "failed_creation") + await session.commit() + except Exception as e_db_fail_create: + await session.rollback() + logging.error( + f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", + exc_info=True) + + logging.error( + f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" + ) + await callback.message.edit_text(get_text("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) + settings: Settings, + session: AsyncSession): + await display_subscription_options(callback, i18n_data, settings, session) async def my_subscription_command_handler( - message_event: types.Message | types.CallbackQuery, i18n_data: dict, + event: Union[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() + subscription_service: SubscriptionService, session: AsyncSession, + bot: Bot): + target_message_obj = event.message if isinstance( + event, types.CallbackQuery) else event + user = event.from_user - current_lang = i18n_data.get("current_language", - getattr(settings, 'DEFAULT_LANGUAGE', 'en')) + if isinstance(event, types.CallbackQuery): + await event.answer() + + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - logging.error("i18n missing") - await target_message.answer("Lang error") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs + ) if i18n else key + + if not i18n or not target_message_obj: + if isinstance(event, types.Message): + await event.answer(get_text("error_occurred_try_again")) 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")) + logging.error( + "PanelService or SubscriptionService is missing in my_subscription_command_handler." + ) + await target_message_obj.answer(get_text("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_details = await subscription_service.get_active_subscription_details( + session, user.id) - 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) + sub_info_text_content = "" + if active_sub_details: + end_date_obj = active_sub_details.get('end_date') + days_left = 0 + if end_date_obj: + if end_date_obj.tzinfo is None: + end_date_obj = end_date_obj.replace(tzinfo=timezone.utc) + days_left = (end_date_obj.date() - datetime.now().date()).days - 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 = active_sub_details.get('config_link') or get_text( + "config_link_not_available") - 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_bytes = active_sub_details.get('traffic_limit_bytes') + traffic_used_bytes = active_sub_details.get('traffic_used_bytes') - 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" + traffic_limit_gb_str = get_text("traffic_unlimited") + if traffic_limit_bytes and traffic_limit_bytes > 0: + traffic_limit_gb_str = f"{traffic_limit_bytes / (1024**3):.2f} GB" - sub_info_text = get_translation( + traffic_used_gb_str = get_text("traffic_na") + if traffic_used_bytes is not None: + traffic_used_gb_str = f"{traffic_used_bytes / (1024**3):.2f} GB" + + sub_info_text_content = get_text( "my_subscription_details", - end_date=end_date_obj.strftime("%Y-%m-%d"), + end_date=end_date_obj.strftime("%Y-%m-%d") + if end_date_obj else "N/A", days_left=max(0, days_left), - status=active_sub.get( - 'status_from_panel', - get_translation('status_active')).capitalize(), + status=active_sub_details.get( + 'status_from_panel', get_text('status_active')).capitalize(), config_link=actual_config_link, - traffic_limit=traffic_limit_gb, - traffic_used=traffic_used_gb) + traffic_limit=traffic_limit_gb_str, + traffic_used=traffic_used_gb_str) else: - sub_info_text = get_translation("subscription_not_active") + sub_info_text_content = get_text("subscription_not_active") + logging.info( + f"User {user.id} no active sub details for 'my_subscription'.") reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n) - if isinstance(message_event, - types.CallbackQuery) and message_event.message: + + if isinstance(event, types.CallbackQuery) and 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, + await event.message.edit_text(sub_info_text_content, + reply_markup=reply_markup_val, + parse_mode="HTML", + disable_web_page_preview=True) + except Exception as e_edit: + logging.warning( + f"Edit 'my_subscription' failed: {e_edit}. Sending new message to chat {target_message_obj.chat.id}." + ) + await bot.send_message(chat_id=target_message_obj.chat.id, + text=sub_info_text_content, + reply_markup=reply_markup_val, + parse_mode="HTML", + disable_web_page_preview=True) + else: + await target_message_obj.answer(sub_info_text_content, 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): - """Handles the /connect command, showing subscription info.""" + subscription_service: SubscriptionService, + session: AsyncSession, bot: Bot): logging.info(f"User {message.from_user.id} used /connect command.") await my_subscription_command_handler(message, i18n_data, settings, - panel_service, subscription_service) + panel_service, subscription_service, + session, bot) diff --git a/bot/handlers/user/trial_handler.py b/bot/handlers/user/trial_handler.py index 247af23..158fa9b 100644 --- a/bot/handlers/user/trial_handler.py +++ b/bot/handlers/user/trial_handler.py @@ -1,8 +1,8 @@ import logging from aiogram import Router, F, types, Bot - -from typing import Optional, Dict, Any -from datetime import datetime, timedelta, timezone +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime from config.settings import Settings from bot.services.subscription_service import SubscriptionService @@ -16,37 +16,44 @@ router = Router(name="user_trial_router") async def request_trial_confirmation_handler( callback: types.CallbackQuery, settings: Settings, i18n_data: dict, - subscription_service: SubscriptionService): + subscription_service: SubscriptionService, session: AsyncSession): user_id = callback.from_user.id current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs + ) if i18n else key + if not i18n or not callback.message: - await callback.answer("Error.", show_alert=True) + await callback.answer(_("error_occurred_try_again"), 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 + + show_trial_btn_in_menu_if_fail = False + if settings.TRIAL_ENABLED: + if not await subscription_service.has_had_any_subscription( + session, user_id): + show_trial_btn_in_menu_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)) + settings, False)) await callback.answer() return - if await subscription_service.has_had_any_subscription(user_id): + + if await subscription_service.has_had_any_subscription(session, 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, @@ -59,55 +66,58 @@ async def request_trial_confirmation_handler( async def confirm_activate_trial_handler( callback: types.CallbackQuery, settings: Settings, i18n_data: dict, subscription_service: SubscriptionService, - panel_service: PanelApiService, bot: Bot): + panel_service: PanelApiService, session: AsyncSession): user_id = callback.from_user.id + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs + ) if i18n else key if not i18n or not callback.message: - await callback.answer("Error.", show_alert=True) + await callback.answer(_("error_occurred_try_again"), 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, + subscription_service, + session, is_edit=True) return - if await subscription_service.has_had_any_subscription(user_id): + if await subscription_service.has_had_any_subscription(session, 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, + subscription_service, + session, is_edit=True) return activation_result = await subscription_service.activate_trial_subscription( - user_id) + session, user_id) + final_message_text_in_chat = "" + show_trial_button_after_action = False 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_obj = activation_result.get("end_date") + config_link_for_trial = activation_result.get("subscription_url") or _( + "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( + if config_link_for_trial == _( + "config_link_not_available") and activation_result.get( + "panel_short_uuid"): + generated_link = await panel_service.get_subscription_link( activation_result["panel_short_uuid"]) - if link: config_link_for_trial = link + if generated_link: config_link_for_trial = generated_link traffic_gb_val = activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB) @@ -117,30 +127,34 @@ async def confirm_activate_trial_handler( 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", + end_date=end_date_obj.strftime('%Y-%m-%d') if isinstance( + end_date_obj, 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_from_service = activation_result.get( "message_key", "trial_activation_failed" ) if activation_result else "trial_activation_failed" - final_message_text_in_chat = _(message_key) + final_message_text_in_chat = _(message_key_from_service) await callback.answer(final_message_text_in_chat, show_alert=True) + if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( + session, user_id): + show_trial_button_after_action = 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( + 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}. Sending new one.") + + if callback.message and hasattr(callback.message, + 'chat') and callback.message.chat: + await callback.message.chat.send_message( final_message_text_in_chat, parse_mode="HTML", reply_markup=get_main_menu_inline_keyboard( @@ -152,13 +166,11 @@ async def confirm_activate_trial_handler( @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 + subscription_service: SubscriptionService, + session: AsyncSession): await send_main_menu(callback, settings, i18n_data, - show_trial_button_flag=show_trial_button_on_back, + subscription_service, + session, is_edit=True) diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 34871b6..55fecf0 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -4,6 +4,8 @@ from typing import Optional, List, Any import math from config.settings import Settings +from bot.middlewares.i18n import JsonI18n +from db.models import User def get_admin_panel_keyboard(i18n_instance, lang: str, @@ -79,41 +81,62 @@ def get_logs_pagination_keyboard( return builder.as_markup() -def get_banned_users_keyboard(users: List[Any], current_page: int, - total_users_banned: int, i18n_instance, +def get_banned_users_keyboard(banned_users: List[User], current_page: int, + total_banned: int, i18n_instance: JsonI18n, 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( + + if not banned_users and total_banned == 0: + pass + + for user_row in banned_users: + + user_display_parts = [] + if user_row.first_name: + user_display_parts.append(user_row.first_name) + if user_row.username: + user_display_parts.append(f"(@{user_row.username})") + if not user_display_parts: + user_display_parts.append(f"ID: {user_row.user_id}") + + user_display = " ".join(user_display_parts).strip() + + button_text = _("admin_banned_user_button_text", + user_display=user_display, + user_id=user_row.user_id) + builder.row( 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) + text=button_text, + callback_data= + f"admin_user_card:{user_row.user_id}:{current_page}")) + + if total_banned > page_size: + total_pages = math.ceil(total_banned / page_size) + pagination_buttons = [] + if current_page > 0: + pagination_buttons.append( + InlineKeyboardButton( + text=_("prev_page_button"), + callback_data=f"admin_action:view_banned:{current_page - 1}" + )) + pagination_buttons.append( + InlineKeyboardButton(text=f"{current_page + 1}/{total_pages}", + callback_data="stub_page_display")) + if current_page < total_pages - 1: + pagination_buttons.append( + InlineKeyboardButton( + text=_("next_page_button"), + callback_data=f"admin_action:view_banned:{current_page + 1}" + )) + if pagination_buttons: + builder.row(*pagination_buttons) + builder.row( - InlineKeyboardButton(text=_(key="back_to_admin_panel_button"), + InlineKeyboardButton(text=_("back_to_admin_panel_button"), callback_data="admin_action:main")) - builder.adjust(1) return builder.as_markup() diff --git a/bot/main_bot.py b/bot/main_bot.py index a171cf2..99708bb 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -1,7 +1,9 @@ import logging import asyncio +from typing import Callable, Dict, Any, Awaitable, Optional -from aiogram import Bot, Dispatcher, Router, F +from aiogram import Bot, Dispatcher, BaseMiddleware, Router, F +from aiogram.types import Update from aiogram.enums import ParseMode from aiogram.filters import CommandStart, Command from aiogram.client.default import DefaultBotProperties @@ -9,75 +11,120 @@ from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_applicati from aiogram.fsm.storage.memory import MemoryStorage from aiohttp import web from apscheduler.schedulers.asyncio import AsyncIOScheduler +from sqlalchemy.orm import sessionmaker -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 config.settings import Settings -from .handlers.user import user_router_aggregate -from .handlers.user import payment as user_payment_webhook_module +from db.database_setup import init_db_connection -from .handlers.admin import admin_router_aggregate -from .filters.admin_filter import AdminFilter +from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n +from bot.middlewares.ban_check_middleware import BanCheckMiddleware +from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware -from db.database import get_db_connection_manager +from bot.handlers.user import user_router_aggregate +from bot.handlers.admin import admin_router_aggregate +from bot.filters.admin_filter import AdminFilter -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 bot.services.notification_service import schedule_subscription_notifications +from bot.services.payment_service import YooKassaService +from bot.services.panel_api_service import PanelApiService +from bot.services.subscription_service import SubscriptionService +from bot.services.referral_service import ReferralService +from bot.services.promo_code_service import PromoCodeService + +from bot.handlers.user import payment as user_payment_webhook_module + + +class DBSessionMiddleware(BaseMiddleware): + + def __init__(self, async_session_factory: sessionmaker): + super().__init__() + self.async_session_factory = async_session_factory + + async def __call__(self, handler: Callable[[Update, Dict[str, Any]], + Awaitable[Any]], event: Update, + data: Dict[str, Any]) -> Any: + if self.async_session_factory is None: + logging.critical( + "DBSessionMiddleware: async_session_factory is None!") + raise RuntimeError( + "async_session_factory not provided to DBSessionMiddleware") + + async with self.async_session_factory() as session: + data["session"] = session + try: + result = await handler(event, data) + + await session.commit() + return result + except Exception: + await session.rollback() + logging.error( + "DBSessionMiddleware: Exception caused rollback.", + exc_info=True) + raise 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_main_router = Router(name="admin_main_filtered_router") 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) + + admin_main_router.message.filter(admin_filter_instance) + admin_main_router.callback_query.filter(admin_filter_instance) + + admin_main_router.include_router(admin_router_aggregate) + + dp.include_router(admin_main_router) logging.info("All application routers registered.") async def on_startup_configured(dispatcher: Dispatcher): - bot: Bot = dispatcher["bot_instance"] settings: Settings = dispatcher["settings"] i18n_instance: JsonI18n = dispatcher["i18n_instance"] + panel_service: PanelApiService = dispatcher["panel_service"] + + async_session_factory: sessionmaker = dispatcher["async_session_factory"] + logging.info("STARTUP: on_startup_configured executing...") scheduler = AsyncIOScheduler(timezone="UTC") try: + await schedule_subscription_notifications(bot, settings, i18n_instance, - scheduler) + scheduler, panel_service, + async_session_factory) 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}')." + f"CRITICAL SECURITY RISK: Bot token detected in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}'). " + "This is a major security vulnerability. Webhook will NOT be set." ) - full_telegram_webhook_url = "ERROR_URL" + full_telegram_webhook_url = "ERROR_URL_TOKEN_DETECTED" 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}" + f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}" ) - 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": + + if full_telegram_webhook_url != "ERROR_URL_TOKEN_DETECTED": + try: + current_webhook_info = await bot.get_webhook_info() + logging.info( + f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}" + ) + set_success = await bot.set_webhook( url=full_telegram_webhook_url, drop_pending_updates=True, @@ -90,46 +137,64 @@ async def on_startup_configured(dispatcher: Dispatcher): 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)}" + f"STARTUP: Telegram Webhook info AFTER setting: {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." + "STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity." ) - else: + + except Exception as e_setwebhook: logging.error( - "STARTUP: Skipped setting webhook due to URL config error." - ) - except Exception as e_setwebhook: + f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}", + exc_info=True) + else: logging.error( - f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}", - exc_info=True) + "STARTUP: Skipped setting Telegram webhook due to security or configuration error." + ) else: logging.info( - "STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set. Attempting to delete webhook." + "STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set in environment. Attempting to delete any existing webhook (running in polling mode)." ) await bot.delete_webhook(drop_pending_updates=True) + logging.info("STARTUP: Bot on_startup_configured completed.") async def on_shutdown_configured(dispatcher: Dispatcher): - logging.warning("SHUTDOWN: on_shutdown_configured executing...") - scheduler: AsyncIOScheduler = dispatcher.get("scheduler") + + scheduler: Optional[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: + logging.error(f"SHUTDOWN: Error shutting down APScheduler: {e}", + exc_info=True) + + panel_service: Optional[PanelApiService] = dispatcher.get("panel_service") + if panel_service and hasattr(panel_service, 'close_session'): 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("Panel API service session closed on shutdown.") + + bot: Bot = dispatcher["bot_instance"] + if bot and bot.session: + try: + await bot.session.close() + logging.info("SHUTDOWN: Aiogram Bot session closed.") + except Exception as e: + logging.warning(f"SHUTDOWN: Failed to close bot session: {e}") + + from db.database_setup import async_engine as global_async_engine + if global_async_engine: + logging.info("SHUTDOWN: Disposing SQLAlchemy engine...") + await global_async_engine.dispose() + logging.info("SHUTDOWN: SQLAlchemy engine disposed.") + logging.info("SHUTDOWN: Bot on_shutdown_configured completed.") @@ -138,49 +203,52 @@ async def run_bot(settings_param: Settings): default_props = DefaultBotProperties(parse_mode=ParseMode.HTML) bot = Bot(token=settings_param.BOT_TOKEN, default=default_props) + local_async_session_factory = init_db_connection(settings_param) + if local_async_session_factory is None: + logging.critical( + "Failed to initialize database connection and session factory. Exiting." + ) + return + 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}") + logging.info(f"Bot username resolved: @{actual_bot_username}") except Exception as e: logging.error( - f"Failed to get bot info: {e}. Using fallback username: {actual_bot_username}" + f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}" ) - default_lang = settings_param.DEFAULT_LANGUAGE - i18n_instance = get_i18n_instance(path="locales", default=default_lang) + i18n_instance = get_i18n_instance(path="locales", + default=settings_param.DEFAULT_LANGUAGE) 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, + bot_username_for_default_return=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, + + subscription_service = SubscriptionService(settings_param, panel_service) + referral_service = ReferralService(settings_param, subscription_service, bot, i18n_instance) - promo_code_service = PromoCodeService(get_db_connection_manager, - settings_param, subscription_service, + promo_code_service = PromoCodeService(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["async_session_factory"] = local_async_session_factory + dp.update.outer_middleware( + DBSessionMiddleware(local_async_session_factory)) dp.update.outer_middleware( I18nMiddleware(i18n=i18n_instance, settings=settings_param)) dp.update.outer_middleware( @@ -189,106 +257,138 @@ async def run_bot(settings_param: Settings): dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings_param)) dp.startup.register(on_startup_configured) - dp.shutdown.register(on_shutdown_configured) + dp.shutdown.register(lambda: on_shutdown_configured(dp)) + 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 ---") + + should_run_aiohttp_server = bool(tg_webhook_base) or ( + bool(yk_webhook_base) and bool(settings_param.yookassa_webhook_path)) + + telegram_uses_webhook_mode = bool(tg_webhook_base) + run_telegram_polling = not telegram_uses_webhook_mode + + logging.info(f"--- Bot Run Mode Decision ---") + logging.info( + f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Telegram Webhook Mode: {telegram_uses_webhook_mode}" + ) + logging.info( + f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}' & Path: '{settings_param.yookassa_webhook_path}'" + ) + logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp_server}") + logging.info(f"Decision: Run Telegram Polling: {run_telegram_polling}") + logging.info(f"--- End Bot Run Mode Decision ---") + web_app_runner = None - main_tasks_to_await = [] - if should_run_aiohttp: + main_tasks = [] + + if should_run_aiohttp_server: app = web.Application() app['bot'] = bot app['dp'] = dp app['settings'] = settings_param app['i18n'] = i18n_instance + app['async_session_factory'] = local_async_session_factory + 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 + app['panel_service'] = panel_service + setup_application(app, dp, bot=bot) - logging.info( - "AIOHTTP app context populated and dispatcher lifecycle linked.") - if telegram_uses_webhook: + + if telegram_uses_webhook_mode: telegram_webhook_path = f"/{settings_param.BOT_TOKEN}" + if not telegram_webhook_path.startswith('/'): + telegram_webhook_path = '/' + telegram_webhook_path 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}") + f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" + ) + + if yk_webhook_base and settings_param.yookassa_webhook_path: + yk_path = settings_param.yookassa_webhook_path + if not yk_path or not isinstance(yk_path, str): + logging.error( + f"YooKassa webhook path is invalid or not configured in settings: {yk_path}. Skipping YooKassa webhook setup." + ) + elif not yk_path.startswith('/'): + logging.error( + f"CRITICAL: YooKassa webhook path '{yk_path}' from settings does not start with '/'. Correct settings.py or .env. Skipping YooKassa webhook." + ) + else: + app.router.add_post( + yk_path, + user_payment_webhook_module.yookassa_webhook_route) + logging.info( + f"YooKassa webhook route configured at: [POST] {yk_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(): + async def web_server_task(): await site.start() logging.info( - f"AIOHTTP server started on {settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}." + f"AIOHTTP server started on http://{settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}" ) await asyncio.Event().wait( - ) if not telegram_should_poll else await asyncio.sleep(31536000) + ) if not run_telegram_polling 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( + main_tasks.append( + asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")) + + if run_telegram_polling: + logging.info("Starting bot in Telegram Polling mode...") + main_tasks.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.") + + if not main_tasks: + logging.error( + "Bot is not configured for any run mode (neither Webhook nor Polling). Exiting." + ) await dp.emit_shutdown() return + logging.info( - f"Starting bot with main tasks: {[task.get_name() for task in main_tasks_to_await]}" + f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}" ) + try: - if main_tasks_to_await: await asyncio.gather(*main_tasks_to_await) + await asyncio.gather(*main_tasks) except (KeyboardInterrupt, SystemExit, asyncio.CancelledError) as e: - logging.info(f"Main loop interrupted: {type(e).__name__}") + logging.info( + f"Main bot loop interrupted/cancelled: {type(e).__name__} - {e}") 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) + logging.info("Initiating final bot shutdown sequence...") + for task in main_tasks: + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + logging.info( + f"Task '{task.get_name()}' was cancelled successfully." + ) + except Exception as e_task_cancel: + logging.error( + f"Error during cancellation of task '{task.get_name()}': {e_task_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() + + await dp.emit_shutdown() + logging.info("Dispatcher shutdown sequence emitted.") + logging.info("Bot run_bot function finished.") diff --git a/bot/middlewares/action_logger_middleware.py b/bot/middlewares/action_logger_middleware.py index ee75b2e..2261df8 100644 --- a/bot/middlewares/action_logger_middleware.py +++ b/bot/middlewares/action_logger_middleware.py @@ -1,11 +1,12 @@ import logging -import json -from typing import Callable, Dict, Any, Awaitable, Union, Optional +from typing import Callable, Dict, Any, Awaitable, Optional +from datetime import datetime, timezone from aiogram import BaseMiddleware -from aiogram.types import Update, Message, CallbackQuery, User +from aiogram.types import Update, User, Message, CallbackQuery +from sqlalchemy.ext.asyncio import AsyncSession -from db.database import log_user_action +from db.dal import message_log_dal, user_dal from config.settings import Settings @@ -19,72 +20,82 @@ class ActionLoggerMiddleware(BaseMiddleware): Awaitable[Any]], event: Update, data: Dict[str, Any]) -> Any: + result = await handler(event, data) + + session: AsyncSession = data["session"] 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 + target_user_id_for_log: Optional[int] = None 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: + if user_id in 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 AttributeError: + raw_update_snippet = str(event)[:1000] except Exception: raw_update_snippet = str(event)[:1000] + current_event_type = event.event_type + if event.message: - msg = event.message + msg: Message = 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}" + current_event_type = f"command:{msg.text.split()[0]}" + else: - content = f"[{msg.content_type}]" - + content = f"[{msg.content_type or 'unknown_content_type'}]" + current_event_type = f"message:{msg.content_type or 'unknown'}" elif event.callback_query: - cb = event.callback_query - event_type = "callback_query" + cb: CallbackQuery = event.callback_query content = cb.data + action_part = cb.data.split( + ":")[0] if cb.data and ":" in cb.data else cb.data + current_event_type = f"callback:{action_part}" - if user_id and event_type and content: + if user_id or current_event_type not in ["update"]: + + log_user_id_for_db = user_id + if user_id: + user_exists = await user_dal.get_user_by_id(session, user_id) + if not user_exists: + logging.warning( + f"ActionLoggerMiddleware: User {user_id} not found in DB. Logging action with user_id=NULL." + ) + log_user_id_for_db = None + + log_payload = { + "user_id": log_user_id_for_db, + "telegram_username": telegram_username, + "telegram_first_name": telegram_first_name, + "event_type": current_event_type, + "content": content[:1000] if content else "N/A", + "raw_update_preview": raw_update_snippet, + "is_admin_event": is_admin_event_flag, + "target_user_id": target_user_id_for_log, + "timestamp": datetime.now(timezone.utc) + } 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) + + await message_log_dal.create_message_log_no_commit( + session, log_payload) 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}", + f"ActionLoggerMiddleware: Failed to add log to session for user {user_id}, type {current_event_type}: {e_log}", exc_info=True) - return await handler(event, data) + return result diff --git a/bot/middlewares/ban_check_middleware.py b/bot/middlewares/ban_check_middleware.py index 625e05f..dc40f5c 100644 --- a/bot/middlewares/ban_check_middleware.py +++ b/bot/middlewares/ban_check_middleware.py @@ -1,15 +1,16 @@ import logging -from typing import Callable, Dict, Any, Awaitable, Union, Optional +from typing import Callable, Dict, Any, Awaitable, Optional, Union from aiogram import BaseMiddleware, Bot -from aiogram.types import Message, CallbackQuery, User, InlineKeyboardMarkup, Update -from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import Message, CallbackQuery, User, Update, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession 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 +from db.dal import user_dal + +from .i18n import JsonI18n +from ..keyboards.inline.user_keyboards import get_user_banned_keyboard class BanCheckMiddleware(BaseMiddleware): @@ -22,191 +23,88 @@ class BanCheckMiddleware(BaseMiddleware): async def __call__(self, handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], event: Update, data: Dict[str, Any]) -> Any: + session: AsyncSession = data["session"] event_user: Optional[User] = data.get("event_from_user") - if not event_user: return await handler(event, data) + bot_instance: Bot = data["bot"] + + if not event_user: + return await handler(event, data) 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) + db_user_model = await user_dal.get_user_by_id( + session, 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 db_user_data and db_user_data['is_banned'] == 1: + if db_user_model and db_user_model.is_banned: logging.info( - f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access and preparing notification." + f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access." ) - 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 + i18n_data_from_event = data.get("i18n_data", {}) + current_lang = i18n_data_from_event.get( + "current_language", self.settings.DEFAULT_LANGUAGE) + i18n_to_use: Optional[JsonI18n] = i18n_data_from_event.get( + "i18n_instance", self.i18n_main_instance) - if event.message: - actual_event_object = event.message + ban_message_text = "You are banned. Please contact support." + keyboard: Optional[InlineKeyboardMarkup] = None + + if i18n_to_use: + _ = lambda k, **kw: i18n_to_use.gettext(current_lang, k, **kw) + ban_message_text = _("user_is_banned") + keyboard = get_user_banned_keyboard(self.settings.SUPPORT_LINK, + current_lang, i18n_to_use) + elif self.settings.SUPPORT_LINK: + from aiogram.utils.keyboard import InlineKeyboardBuilder + builder = InlineKeyboardBuilder() + builder.button(text="Support", url=self.settings.SUPPORT_LINK) + keyboard = builder.as_markup() + + actual_event_object: Optional[Union[Message, CallbackQuery]] = None + if event.message: actual_event_object = event.message elif event.callback_query: actual_event_object = event.callback_query - 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}" - ) - - 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." - ) - - 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)." - ) - - target_message_obj = actual_event_object.message - if target_message_obj: - target_chat_id = target_message_obj.chat.id + if actual_event_object.message: 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( + await actual_event_object.message.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, + except (TelegramAPIError, AiogramError): + await bot_instance.send_message( + actual_event_object.from_user.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( + await bot_instance.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: + await bot_instance.send_message(event_user.id, + ban_message_text, + reply_markup=keyboard) + logging.info(f"Ban notification sent to user {event_user.id}.") + except TelegramForbiddenError: logging.warning( - f"BanCheck: TelegramForbiddenError sending ban msg to {event_user.id}: {e_forbidden}" - ) - except TelegramBadRequest as e_bad_req: + f"BanCheck: Bot is blocked by user {event_user.id}.") + except Exception as e_send: 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}", + f"BanCheck: Failed to notify banned user {event_user.id}: {type(e_send).__name__} - {e_send}", exc_info=True) - 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) diff --git a/bot/middlewares/i18n.py b/bot/middlewares/i18n.py index 116f95e..80bb2b8 100644 --- a/bot/middlewares/i18n.py +++ b/bot/middlewares/i18n.py @@ -1,13 +1,13 @@ import logging import json import os -import aiosqlite from typing import Any, Awaitable, Callable, Dict, Optional from aiogram import BaseMiddleware -from aiogram.types import TelegramObject, User, Update +from aiogram.types import User, Update +from sqlalchemy.ext.asyncio import AsyncSession -from db.database import get_user, get_db_connection_manager, _setup_db_connection +from db.dal import user_dal from config.settings import Settings @@ -25,7 +25,8 @@ class JsonI18n: def _load_locales(self): if not os.path.isdir(self.path): - logging.error(f"Locales path not found: {self.path}") + logging.error( + f"Locales path not found or not a directory: {self.path}") return for item in os.listdir(self.path): if item.endswith(".json"): @@ -34,52 +35,73 @@ class JsonI18n: try: with open(file_path, "r", encoding="utf-8") as f: self.locales_data[lang_code] = json.load(f) - except Exception as e: + except json.JSONDecodeError as e_json_load: logging.error( - f"Error loading locale {lang_code} from {file_path}: {e}" + f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" ) + except Exception as e_load: + logging.error( + f"Error loading locale {lang_code} from {file_path}: {e_load}", + exc_info=True) 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 + effective_lang_code = lang_code if lang_code and lang_code in self.locales_data else 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}'." + f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is." ) 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 + + if text is None: + logging.warning( + f"Translation key '{key}' not found for lang '{effective_lang_code}' or default '{self.default_lang}'. Returning key." + ) + return key.format(**kwargs) if kwargs else key try: return text.format(**kwargs) if kwargs else text - except KeyError as e: + except KeyError as e_format: logging.warning( - f"Missing format key {e} for key '{key}' (lang: {effective_lang_code}). Text: '{text}'" + f"Missing format key '{e_format}' for i18n key '{key}' (lang: {effective_lang_code}). Original text: '{text}'" ) return text - except Exception as e: + except Exception as e_general_format: logging.error( - f"Error formatting key '{key}' (lang: {effective_lang_code}): {e}. Text: '{text}'" - ) + f"General error formatting i18n key '{key}' (lang: {effective_lang_code}): {e_general_format}. Original text: '{text}'", + exc_info=True) return text -_i18n_instance: Optional[JsonI18n] = None +_i18n_instance_singleton: Optional[JsonI18n] = None 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) - return _i18n_instance + global _i18n_instance_singleton + if _i18n_instance_singleton is None: + + if not os.path.exists(path) or not os.path.isdir(path): + logging.error( + f"CRITICAL: Locales directory '{path}' not found. i18n will not work correctly." + ) + + _i18n_instance_singleton = JsonI18n(path=path, + default=default, + domain=domain) + else: + _i18n_instance_singleton = JsonI18n(path=path, + default=default, + domain=domain) + return _i18n_instance_singleton class I18nMiddleware(BaseMiddleware): @@ -92,65 +114,40 @@ class I18nMiddleware(BaseMiddleware): async def __call__(self, handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], event: Update, data: Dict[str, Any]) -> Any: + session: AsyncSession = data["session"] 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}") - 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: - - 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.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] + user_db_model = await user_dal.get_user_by_id( + session, event_user.id) + if user_db_model and user_db_model.language_code and user_db_model.language_code in self.i18n.locales_data: + current_language = user_db_model.language_code + elif event_user.language_code: + lang_prefix = event_user.language_code.split( + '-')[0].lower() 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 + elif event_user.language_code.lower( + ) in self.i18n.locales_data: + current_language = event_user.language_code.lower() + except Exception as e_db_lang: + logging.error( + f"I18nMiddleware: Error fetching user lang from DB for {event_user.id}: {e_db_lang}. Falling back.", + exc_info=True) + if event_user.language_code: + lang_prefix = event_user.language_code.split( + '-')[0].lower() + if lang_prefix in self.i18n.locales_data: + current_language = lang_prefix + elif event_user.language_code.lower( + ) in self.i18n.locales_data: + current_language = event_user.language_code.lower() data["i18n_data"] = { "i18n_instance": self.i18n, "current_language": current_language } - logging.debug( - f"I18nMiddleware: Final current_language for event: {current_language}" - ) - return await handler(event, data) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index cb59069..9faf8dc 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -2,95 +2,115 @@ import logging import asyncio from aiogram import Bot from apscheduler.schedulers.asyncio import AsyncIOScheduler -from datetime import datetime +from datetime import datetime, timezone from config.settings import Settings -from .subscription_service import SubscriptionService -from db.database import get_db_connection_manager +from sqlalchemy.orm import sessionmaker from bot.middlewares.i18n import JsonI18n -from .panel_api_service import PanelApiService +from bot.services.panel_api_service import PanelApiService +from bot.services.subscription_service import SubscriptionService 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. - """ + panel_service: PanelApiService, + async_session_factory: sessionmaker): + logging.info( - f"Scheduler job: Checking for expiring subscriptions at {datetime.now()}..." + f"Scheduler job 'send_expiration_warnings' started at {datetime.now(timezone.utc)} UTC." ) - sub_service = SubscriptionService(get_db_connection_manager, settings, - panel_service) - - 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 async_session_factory is None: + logging.error( + "NotificationService: AsyncSessionFactory not provided to send_expiration_warnings!" + ) 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}" - ) - 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'] - - 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))) - - _ = 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) + async with async_session_factory() as session: try: - await bot.send_message(user_id, message_text) - await sub_service.update_last_notification_sent( - user_id, end_date_str) + sub_service = SubscriptionService(settings, panel_service) + + expiring_subs_details_list = await sub_service.get_subscriptions_ending_soon( + session, settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS) + + if not expiring_subs_details_list: + logging.info( + "No subscriptions found ending soon for notification.") + return + logging.info( - f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}" + f"Found {len(expiring_subs_details_list)} subscriptions for expiration warning." ) - except Exception as e: + for sub_details in expiring_subs_details_list: + user_id = sub_details['user_id'] + user_lang = sub_details.get('language_code', + settings.DEFAULT_LANGUAGE) + first_name = sub_details.get('first_name', f"User {user_id}") + end_date_str_for_msg = sub_details.get('end_date_str', "N/A") + days_left_display = sub_details.get('days_left', "N/A") + + subscription_actual_end_date_obj: Optional[ + datetime] = sub_details.get( + 'subscription_end_date_iso_for_update') + + _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs + ) + message_text = _("subscription_ending_soon_notification", + user_name=first_name, + end_date=end_date_str_for_msg, + days_left=days_left_display) + try: + await bot.send_message(user_id, message_text) + logging.info( + f"Sent expiration warning to user {user_id} for subscription ending {end_date_str_for_msg}." + ) + + if subscription_actual_end_date_obj: + await sub_service.update_last_notification_sent( + session, user_id, subscription_actual_end_date_obj) + else: + logging.warning( + f"Could not find exact subscription end_date_obj for user {user_id} to update notification time." + ) + + except Exception as e: + logging.error( + f"Failed to send expiration warning or update notification status for user {user_id}: {e}", + exc_info=True) + + await asyncio.sleep(0.1) + + await session.commit() + logging.info( + "Finished processing expiration warnings. Session committed.") + + except Exception as e_session: logging.error( - f"Failed to send expiration warning to user {user_id}: {e}") - await asyncio.sleep(0.1) + f"Error during send_expiration_warnings session: {e_session}", + exc_info=True) + await session.rollback() + logging.info( + "Session rolled back due to error in send_expiration_warnings." + ) -async def schedule_subscription_notifications(bot: Bot, settings: Settings, - i18n: JsonI18n, - scheduler: AsyncIOScheduler): - """Schedules the daily job to send expiration warnings.""" +async def schedule_subscription_notifications( + bot: Bot, settings: Settings, i18n: JsonI18n, + scheduler: AsyncIOScheduler, panel_service: PanelApiService, + async_session_factory: sessionmaker): async def job_wrapper(): - panel_service = PanelApiService(settings) - try: - await send_expiration_warnings(bot, settings, i18n, panel_service) + try: + await send_expiration_warnings(bot, settings, i18n, panel_service, + async_session_factory) except Exception as e: logging.error( - f"Error in scheduled job 'send_expiration_warnings': {e}", + f"Unhandled error in scheduled job 'send_expiration_warnings' (job_wrapper): {e}", exc_info=True) - finally: - await panel_service.close_session() try: notification_hour = int(settings.SUBSCRIPTION_NOTIFICATION_HOUR_UTC) @@ -98,7 +118,7 @@ async def schedule_subscription_notifications(bot: Bot, settings: Settings, settings.SUBSCRIPTION_NOTIFICATION_MINUTE_UTC) except (ValueError, TypeError): logging.warning( - "SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid. Defaulting to 9:00 UTC." + "SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid in settings. Defaulting to 9:00 UTC." ) notification_hour = 9 notification_minute = 0 @@ -107,8 +127,9 @@ async def schedule_subscription_notifications(bot: Bot, settings: Settings, 'cron', hour=notification_hour, minute=notification_minute, - name="daily_subscription_expiration_warnings", - misfire_grace_time=60 * 15) + name="daily_subscription_expiration_warnings_v2", + misfire_grace_time=60 * 15, + replace_existing=True) logging.info( f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC." ) diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index fcdc5b0..8aab103 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -1,9 +1,16 @@ import aiohttp import logging +import json from typing import Optional, List, Dict, Any from datetime import datetime, timedelta, timezone +import asyncio +from urllib.parse import urlencode + +from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings +from db.dal import panel_sync_dal +from db.models import PanelSyncStatus class PanelApiService: @@ -17,153 +24,304 @@ class PanelApiService: async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: - self._session = aiohttp.ClientSession() + timeout = aiohttp.ClientTimeout(total=30) + self._session = aiohttp.ClientSession(timeout=timeout) 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.") + logging.info("Panel API service HTTP session closed.") async def _prepare_headers(self) -> Dict[str, str]: headers = { "Content-Type": "application/json", + "Accept": "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}" - return headers - async def _request(self, method: str, endpoint: str, + async def _request(self, + method: str, + endpoint: str, + log_full_response: bool = False, **kwargs) -> Optional[Dict[str, Any]]: if not self.base_url: - logging.error("Panel API URL not configured.") + logging.error( + "Panel API URL (PANEL_API_URL) not configured in settings.") return { "error": True, "status_code": 0, "message": "Panel API URL not configured." } - session = await self._get_session() + aiohttp_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." - ) + url_for_request = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" - url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" - json_payload_for_log = kwargs.get('json') if method in [ + current_params = kwargs.get("params") + url_with_params_for_log = url_for_request + if current_params: + try: + url_with_params_for_log += "?" + urlencode(current_params) + except Exception: + pass + + json_payload_for_log = kwargs.get('json') if method.upper() in [ "POST", "PATCH", "PUT" ] else None - log_prefix = f"Panel API {method} {url}" + log_prefix = f"Panel API Req: {method.upper()} {url_with_params_for_log}" if json_payload_for_log: - log_prefix += f" Payload: {json_payload_for_log}" - + try: + payload_str = json.dumps(json_payload_for_log) + log_prefix += f" | Payload: {payload_str[:300]}{'...' if len(payload_str) > 300 else ''}" + except Exception: + log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." try: - async with session.request(method, url, headers=headers, - **kwargs) as response: - if 200 <= response.status < 300: + async with aiohttp_session.request(method.upper(), + url_for_request, + headers=headers, + **kwargs) as response: + response_status = response.status + response_text = await response.text() + + log_suffix = f"| Status: {response_status}" + + if log_full_response or not (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." + parsed_json_for_log = json.loads(response_text) + pretty_response_text = json.dumps(parsed_json_for_log, + indent=2, + ensure_ascii=False) + logging.info( + f"{log_prefix} {log_suffix} | Full Response Body:\n{pretty_response_text}" + ) + except json.JSONDecodeError: + logging.info( + f"{log_prefix} {log_suffix} | Full Response Text (not JSON):\n{response_text[:2000]}{'...' if len(response_text) > 2000 else ''}" + ) + else: + logging.debug( + f"{log_prefix} {log_suffix} | OK. Response Body Preview: {response_text[:200]}{'...' if len(response_text) > 200 else ''}" + ) + + if 200 <= response_status < 300: + try: + if 'application/json' in response.headers.get( + 'Content-Type', '').lower(): + data = json.loads(response_text) + return data + else: + return { + "status": "success", + "code": response_status, + "data_text": response_text + } + except json.JSONDecodeError as e_json_ok: + logging.error( + f"{log_prefix} {log_suffix} | OK but JSON Parse Error. Error: {e_json_ok}. Body was logged above." ) return { - "status": "success", - "code": response.status, - "data_text": await response.text() + "status": "success_parse_error", + "code": response_status, + "data_text": response_text, + "parse_error": str(e_json_ok) } else: + error_details = { + "message": + f"Request failed with status {response_status}", + "raw_response_text": response_text + } 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 - } + if 'application/json' in response.headers.get( + 'Content-Type', '').lower(): + error_json_data = json.loads(response_text) + error_details.update(error_json_data) + except json.JSONDecodeError: + pass + return { + "error": True, + "status_code": response_status, + "details": error_details + } + + except aiohttp.ClientConnectorError as e: + logging.error( + f"Panel API ClientConnectorError to {url_for_request}: {e}") + return { + "error": True, + "status_code": -1, + "message": f"Connection error: {str(e)}" + } 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) + logging.error(f"Panel API ClientError to {url_for_request}: {e}") return { "error": True, "status_code": -2, + "message": f"Client error: {str(e)}" + } + except asyncio.TimeoutError: + logging.error(f"Panel API request to {url_for_request} timed out.") + return { + "error": True, + "status_code": -3, + "message": "Request timed out" + } + except Exception as e: + logging.error( + f"Unexpected Panel API request error to {url_for_request}: {e}", + exc_info=True) + return { + "error": True, + "status_code": -4, "message": f"Unexpected error: {str(e)}" } + async def get_all_panel_users( + self, + page_size: int = 100, + log_responses: bool = False) -> 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, + log_full_response=log_responses) + + if not response_data or response_data.get("error"): + logging.error( + f"Failed to fetch panel users batch (start: {start_offset}). Response: {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 + await asyncio.sleep(0.1) + logging.info(f"Fetched {len(all_users)} users from panel API.") + return all_users + + async def get_user_by_uuid( + self, + user_uuid: str, + log_response: bool = True) -> Optional[Dict[str, Any]]: + endpoint = f"/users/{user_uuid}" + full_response = await self._request("GET", + endpoint, + log_full_response=log_response) + if full_response and not full_response.get( + "error") and "response" in full_response: + return full_response.get("response") + + return None + 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.") - return None + telegram_id: Optional[int] = None, + username: Optional[str] = None, + email: Optional[str] = None, + log_response: bool = True) -> Optional[List[Dict[str, Any]]]: - params = {"username": username} - response_data = await self._request("GET", "/users", params=params) + response_data = None + filter_used_log = "No filter specified" - 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 telegram_id is not None: + filter_used_log = f"telegramId={telegram_id}" + endpoint = f"/users/by-telegram-id/{telegram_id}" + response_data = await self._request("GET", + endpoint, + log_full_response=log_response) + + if response_data and not response_data.get( + "error") and "response" in response_data and isinstance( + response_data["response"], list): + return response_data["response"] + elif response_data and response_data.get("errorCode") == "A062": + logging.info( + f"Panel API: Users not found for {filter_used_log}") + return [] + + elif username is not None: + filter_used_log = f"username={username}" + endpoint = f"/users/by-username/{username}" + response_data = await self._request("GET", + endpoint, + log_full_response=log_response) + + if response_data and not response_data.get( + "error") and "response" in response_data and isinstance( + response_data["response"], dict): + return [response_data["response"]] + elif response_data and response_data.get("errorCode") == "A062": + logging.info( + f"Panel API: User not found for {filter_used_log}") + return [] + + elif email is not None: + filter_used_log = f"email={email}" + endpoint = f"/users/by-email/{email}" + response_data = await self._request("GET", + endpoint, + log_full_response=log_response) + + if response_data and not response_data.get( + "error") and "response" in response_data and isinstance( + response_data["response"], list): + return response_data["response"] + elif response_data and response_data.get("errorCode") == "A062": + logging.info( + f"Panel API: Users not found for {filter_used_log}") + return [] + + if not telegram_id and not username and not email: + logging.warning( + "get_users_by_filter called without any specific filter criteria." ) - return users_list + return [] + logging.error( - f"Failed to fetch panel users with filter {params}. Response: {response_data}" + f"Failed to fetch panel users with filter ({filter_used_log}). Last API response: {response_data if not log_response else '(logged above)'}" ) 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]]: + self, + username_on_panel: 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, + description: Optional[str] = None, + tag: Optional[str] = None, + status: str = "ACTIVE", + log_response: 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": { + if not (6 <= len(username_on_panel) <= 34 and + username_on_panel.replace('_', '').replace('-', '').isalnum()): + if not (username_on_panel.startswith("tg_") + and username_on_panel.split("tg_")[-1].isdigit()): + msg = f"Panel username '{username_on_panel}' does not meet panel requirements." + logging.error(msg) + return { + "error": True, + "status_code": 400, "message": msg, - "errorCode": "VALIDATION_ERROR" + "errorCode": "VALIDATION_ERROR_USERNAME" } - } now = datetime.now(timezone.utc) expire_at_dt = now + timedelta(days=default_expire_days) @@ -171,83 +329,86 @@ class PanelApiService: timespec='milliseconds').replace('+00:00', 'Z') payload: Dict[str, Any] = { - "username": username, + "username": username_on_panel, + "status": status.upper(), "expireAt": expire_at_iso, - "trafficLimitStrategy": default_traffic_limit_strategy, + "trafficLimitStrategy": default_traffic_limit_strategy.upper(), "trafficLimitBytes": default_traffic_limit_bytes, } 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 + if description: payload["description"] = description + if tag: payload["tag"] = tag - return await self._request("POST", "/users", json=payload) + response = await self._request("POST", + "/users", + json=payload, + log_full_response=log_response) + if response and not response.get("error") and "response" in response: + logging.info( + f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response',{}).get('uuid')})." + ) + return response + + logging.error( + f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}" + ) + return response 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) + self, + user_uuid: str, + update_payload: Dict[str, Any], + log_response: bool = True) -> Optional[Dict[str, Any]]: + if 'uuid' not in update_payload: + update_payload['uuid'] = user_uuid full_response = await self._request("PATCH", "/users", - json=update_payload) + json=update_payload, + log_full_response=log_response) if full_response and not full_response.get( - "error") and full_response.get("response"): + "error") and "response" in full_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}" + f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}" ) 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) < page_size: break - start_offset += page_size - logging.info(f"Fetched {len(all_users)} users from panel API.") - return all_users + async def update_user_status_on_panel(self, + user_uuid: str, + enable: bool, + log_response: bool = True) -> bool: + action = "enable" if enable else "disable" + endpoint = f"/users/{user_uuid}/actions/{action}" + response_data = await self._request("POST", + endpoint, + log_full_response=log_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 + if response_data and not response_data.get( + "error") and "response" in response_data: + actual_status = response_data.get("response", {}).get("status") + expected_status = "ACTIVE" if enable else "DISABLED" + if actual_status == expected_status: + logging.info( + f"User {user_uuid} status on panel successfully set to {action} (Actual: {actual_status})." + ) + return True + else: + logging.warning( + f"User {user_uuid} status on panel action '{action}' called, but final status is '{actual_status}'." + ) + 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}" + f"Failed to {action} user {user_uuid} on panel. Response: {response_data if not log_response else '(logged above)'}" ) return False @@ -255,5 +416,25 @@ class PanelApiService: 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}" + if not self.settings.PANEL_API_URL: + logging.error( + "PANEL_API_URL not set, cannot generate subscription link.") + return None + base_sub_url = f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}" + if client_type: + return f"{base_sub_url}/{client_type.lower()}" + return base_sub_url + + async def update_bot_db_sync_status(self, + session: AsyncSession, + status: str, + details: str, + users_processed: int = 0, + subs_synced: int = 0): + await panel_sync_dal.update_panel_sync_status(session, status, details, + users_processed, + subs_synced) + + async def get_bot_db_last_sync_status( + self, session: AsyncSession) -> Optional[PanelSyncStatus]: + return await panel_sync_dal.get_panel_sync_status(session) diff --git a/bot/services/payment_service.py b/bot/services/payment_service.py index 1f48088..4bb96e6 100644 --- a/bot/services/payment_service.py +++ b/bot/services/payment_service.py @@ -1,10 +1,12 @@ import uuid import logging -from yookassa import Configuration, Payment -from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder - +import asyncio from typing import Optional, Dict, Any, List +from yookassa import Configuration, Payment as YooKassaPayment +from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder +from yookassa.domain.common.confirmation_type import ConfirmationType + from config.settings import Settings @@ -14,61 +16,84 @@ class YooKassaService: shop_id: Optional[str], secret_key: Optional[str], configured_return_url: Optional[str], - bot_username_for_default: Optional[str] = None, + bot_username_for_default_return: 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." - ) + "YooKassa SHOP_ID or SECRET_KEY not configured in settings. " + "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}") + try: + Configuration.configure(shop_id, secret_key) + self.configured = True + logging.info( + f"YooKassa SDK configured for shop_id: {shop_id[:5]}...") + except Exception as e: + logging.error(f"Failed to configure YooKassa SDK: {e}", + exc_info=True) + self.configured = False 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}" + elif bot_username_for_default_return: + self.return_url = f"https://t.me/{bot_username_for_default_return}" logging.info( - f"YOOKASSA_RETURN_URL not set, using dynamic default: {self.return_url}" + f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}" ) else: - self.return_url = "https://example.com/payment_error_no_return_url" + self.return_url = "https://example.com/payment_error_no_return_url_configured" logging.warning( - f"YOOKASSA_RETURN_URL not set AND bot username not provided. Using placeholder: {self.return_url}" + f"CRITICAL: YOOKASSA_RETURN_URL not set AND bot username not provided. " + f"Using placeholder: {self.return_url}. Payments may not complete correctly." ) logging.info( - f"YooKassa Service effective return_url: {self.return_url}") + f"YooKassa Service effective return_url for payments: {self.return_url}" + ) async def create_payment( - self, amount: float, currency: str, description: str, - metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]: + self, + amount: float, + currency: str, + description: str, + metadata: Dict[str, Any], + receipt_email: Optional[str] = None, + receipt_phone: Optional[str] = None) -> 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." + "YooKassaService: Settings object not available. Cannot create payment with receipt details." ) return { "error": True, "internal_message": - "YooKassa receipt email not configured by admin." + "Service settings (Settings object) not initialized." + } + + customer_contact_for_receipt = {} + if receipt_email: + customer_contact_for_receipt["email"] = receipt_email + elif receipt_phone: + customer_contact_for_receipt["phone"] = receipt_phone + elif self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL: + customer_contact_for_receipt[ + "email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL + else: + logging.error( + "CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set." + ) + return { + "error": + True, + "internal_message": + "YooKassa receipt customer contact (email/phone) missing and no default email configured." } try: @@ -79,15 +104,15 @@ class YooKassaService: }) builder.set_capture(True) builder.set_confirmation({ - "type": "redirect", + "type": ConfirmationType.REDIRECT, "return_url": self.return_url }) builder.set_description(description) builder.set_metadata(metadata) - receipt_items: List[Dict[str, Any]] = [{ + receipt_items_list: List[Dict[str, Any]] = [{ "description": - description, + description[:128], "quantity": "1.00", "amount": { @@ -102,87 +127,122 @@ class YooKassaService: 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 + receipt_data_dict: Dict[str, Any] = { + "customer": customer_contact_for_receipt, + "items": receipt_items_list } - builder.set_receipt(receipt_payload) + builder.set_receipt(receipt_data_dict) 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}" + f"Creating YooKassa payment (Idempotence-Key: {idempotence_key}). " + f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}" ) - res = Payment.create(payment_request, idempotence_key) + loop = asyncio.get_event_loop() + response = await loop.run_in_executor( + None, lambda: YooKassaPayment.create(payment_request, + idempotence_key)) + logging.info( - f"YooKassa Payment.create response: ID={res.id}, Status={res.status}" + f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" ) return { "id": - res.id, + response.id, "confirmation_url": - res.confirmation.confirmation_url - if res.confirmation else None, + response.confirmation.confirmation_url + if response.confirmation else None, "status": - res.status, + response.status, "metadata": - res.metadata, + response.metadata, "amount_value": - float(res.amount.value), + float(response.amount.value), "amount_currency": - res.amount.currency, - "idempotence_key": + response.amount.currency, + "idempotence_key_used": idempotence_key, "paid": - res.paid, + response.paid, "refundable": - res.refundable, + response.refundable, "created_at": - res.created_at.isoformat() if hasattr( - res.created_at, 'isoformat') else str(res.created_at) + response.created_at.isoformat() if hasattr( + response.created_at, 'isoformat') else str( + response.created_at), + "description_from_yk": + response.description, + "test_mode": + response.test if hasattr(response, 'test') else None } 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]]: + async def get_payment_info( + self, payment_id_in_yookassa: 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: + logging.info( + f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}" + ) + + loop = asyncio.get_event_loop() + payment_info_yk = await loop.run_in_executor( + None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa)) + + if payment_info_yk: + logging.info( + f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}" + ) 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, + "id": + payment_info_yk.id, + "status": + payment_info_yk.status, + "paid": + payment_info_yk.paid, + "amount_value": + float(payment_info_yk.amount.value), + "amount_currency": + payment_info_yk.amount.currency, + "metadata": + payment_info_yk.metadata, + "description": + payment_info_yk.description, + "refundable": + payment_info_yk.refundable, + "created_at": + payment_info_yk.created_at.isoformat() if hasattr( + payment_info_yk.created_at, 'isoformat') else str( + payment_info_yk.created_at), + "captured_at": + payment_info_yk.captured_at.isoformat() + if payment_info_yk.captured_at and hasattr( + payment_info_yk.captured_at, 'isoformat') else None, + "payment_method_type": + payment_info_yk.payment_method.type + if payment_info_yk.payment_method else None, + "test_mode": + payment_info_yk.test + if hasattr(payment_info_yk, 'test') else None } - return None + else: + logging.warning( + f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}" + ) + return None except Exception as e: logging.error( - f"YooKassa get payment info for {payment_id} failed: {e}") + f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}", + exc_info=True) return None diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py index 31a42d2..6b358d7 100644 --- a/bot/services/promo_code_service.py +++ b/bot/services/promo_code_service.py @@ -1,88 +1,77 @@ import logging -import aiosqlite -from datetime import datetime, timezone -from typing import Optional, Dict, Callable, Any, Tuple - +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Optional, Tuple, Dict 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 db.dal import promo_code_dal, user_dal, subscription_dal +from db.models import PromoCode, User + from .subscription_service import SubscriptionService from bot.middlewares.i18n import JsonI18n class PromoCodeService: - def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings, + def __init__(self, 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, + async def apply_promo_code(self, session: AsyncSession, 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() - 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) + promo_data = await promo_code_dal.get_active_promo_code_by_code_str( + session, code_input_upper) - 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) + existing_activation = await promo_code_dal.get_user_activation_for_promo( + session, promo_data.promo_code_id, user_id) + if existing_activation: + return False, _("promo_code_already_used_by_user", + 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") + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id) + if not active_sub: + return False, _("promo_code_no_active_subscription") - bonus_days = promo_data['bonus_days'] + 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}") + new_end_date = await self.subscription_service.extend_active_subscription_days( + session=session, + user_id=user_id, + bonus_days=bonus_days, + reason=f"promo code {code_input_upper}") - if new_end_date: + 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: + activation_recorded = await promo_code_dal.record_promo_activation( + session, promo_data.promo_code_id, user_id, payment_id=None) + promo_incremented = await promo_code_dal.increment_promo_code_usage( + session, promo_data.promo_code_id) - await db.rollback() - return False, _("promo_code_invalid_or_expired") - else: - await db.rollback() - return False, _("error_applying_promo_bonus") + if activation_recorded and promo_incremented: + + 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: - 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") + f"Failed to record activation or increment usage for promo {promo_data.code} by user {user_id}" + ) + return False, _("error_applying_promo_bonus") + else: + + return False, _("error_applying_promo_bonus") diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py index 15d89fe..6254c18 100644 --- a/bot/services/referral_service.py +++ b/bot/services/referral_service.py @@ -1,48 +1,39 @@ import logging -import aiosqlite -from typing import Optional, Dict, Callable, Any, TYPE_CHECKING, Tuple +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Optional, Dict, Any from aiogram import Bot -from datetime import datetime +from datetime import datetime, timezone, timedelta from config.settings import Settings -from db.database import get_user +from db.dal import user_dal +from db.models import User +from db.dal import subscription_dal from bot.middlewares.i18n import JsonI18n - -if TYPE_CHECKING: - from .subscription_service import SubscriptionService +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 + def __init__(self, settings: Settings, + subscription_service: SubscriptionService, bot: Bot, + i18n: JsonI18n): self.settings = settings - self.subscription_service = subscription_service_instance + self.subscription_service = subscription_service 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: - - 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]: - """Applies bonuses. Notifies inviter. Returns referee bonus details.""" + self, session: AsyncSession, referee_user_id: int, + purchased_subscription_months: int) -> Dict[str, Any]: + referee_final_end_date: Optional[datetime] = None referee_bonus_applied_days: Optional[int] = None + inviter_bonus_successfully_applied = False + 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: + referee_user_model = await user_dal.get_user_by_id( + session, referee_user_id) + if not referee_user_model or referee_user_model.referred_by_id is None: logging.debug( f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses." ) @@ -51,75 +42,177 @@ class ReferralService: "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) + inviter_user_id = referee_user_model.referred_by_id + inviter_user_model = await user_dal.get_user_by_id( + session, inviter_user_id) - referee_name = referee_user_row[ - 'first_name'] or f"User {referee_user_id}" + referee_name_for_msg = referee_user_model.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") + default_lang_for_placeholder = self.settings.DEFAULT_LANGUAGE + inviter_name_for_referee_msg = ( + inviter_user_model.first_name if inviter_user_model + and inviter_user_model.first_name else self.i18n.gettext( + default_lang_for_placeholder, "friend_placeholder")) - inviter_bonus = self.settings.referral_bonus_inviter.get( + inviter_bonus_days = self.settings.referral_bonus_inviter.get( purchased_subscription_months) - referee_bonus = self.settings.referral_bonus_referee.get( + referee_bonus_days = 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: + if inviter_bonus_days and inviter_bonus_days > 0: + if not inviter_user_model: + logging.warning( - f"Failed to apply bonus subscription extension for inviter {inviter_user_id}." + f"Inviter user {inviter_user_id} not found in local DB. Cannot apply inviter bonus." ) - - 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: + + inviter_panel_uuid, inviter_panel_sub_link_id, _, _ = await self.subscription_service._get_or_create_panel_user_link_details( + session, inviter_user_id, inviter_user_model) + + if not inviter_panel_uuid: + logging.warning( + f"Failed to get/create panel link for inviter {inviter_user_id}. Cannot apply inviter bonus directly to panel." + ) + + else: + new_end_date_inviter = await self.subscription_service.extend_active_subscription_days( + session=session, + user_id=inviter_user_id, + bonus_days=inviter_bonus_days, + reason=f"referral bonus from {referee_name_for_msg}" + ) + + if new_end_date_inviter: + inviter_bonus_successfully_applied = True + logging.info( + f"Bonus of {inviter_bonus_days} days successfully applied/extended for inviter {inviter_user_id}." + ) + + try: + inviter_lang = inviter_user_model.language_code or 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_days, + referee_name=referee_name_for_msg, + new_end_date=new_end_date_inviter. + strftime('%Y-%m-%d'))) + except Exception as e_notify_inviter: + logging.error( + f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" + ) + else: + + logging.info( + f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days." + ) + + bonus_start_date = datetime.now(timezone.utc) + bonus_end_date = bonus_start_date + timedelta( + days=inviter_bonus_days) + + if not inviter_panel_sub_link_id: + logging.error( + f"Cannot create bonus subscription for inviter {inviter_user_id}: panel_sub_link_id is missing even after link detail fetch." + ) + else: + bonus_sub_payload = { + "user_id": + inviter_user_id, + "panel_user_uuid": + inviter_panel_uuid, + "panel_subscription_uuid": + inviter_panel_sub_link_id, + "start_date": + bonus_start_date, + "end_date": + bonus_end_date, + "duration_months": + 0, + "is_active": + True, + "status_from_panel": + "ACTIVE_BONUS", + "traffic_limit_bytes": + self.settings. + PANEL_USER_DEFAULT_TRAFFIC_BYTES, + } + try: + await subscription_dal.deactivate_other_active_subscriptions( + session, inviter_panel_uuid, + inviter_panel_sub_link_id) + bonus_sub = await subscription_dal.upsert_subscription( + session, bonus_sub_payload) + + panel_update_success = await self.subscription_service.panel_service.update_user_details_on_panel( + inviter_panel_uuid, { + "expireAt": + bonus_end_date.isoformat( + timespec='milliseconds'). + replace('+00:00', 'Z'), + "status": + "ACTIVE", + }) + if panel_update_success: + inviter_bonus_successfully_applied = True + logging.info( + f"New bonus subscription for {inviter_bonus_days} days created for inviter {inviter_user_id}." + ) + + inviter_lang = inviter_user_model.language_code or 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_new_sub", + days=inviter_bonus_days, + referee_name= + referee_name_for_msg, + new_end_date=bonus_end_date. + strftime('%Y-%m-%d'))) + else: + logging.warning( + f"Failed to update panel for new bonus subscription for inviter {inviter_user_id}. Local bonus sub created (ID: {bonus_sub.subscription_id}) but may not be active on panel." + ) + + except Exception as e_create_bonus_sub: + logging.error( + f"Failed to create new bonus subscription for inviter {inviter_user_id}: {e_create_bonus_sub}", + exc_info=True) + + if referee_bonus_days and referee_bonus_days > 0: + + new_end_date_referee = await self.subscription_service.extend_active_subscription_days( + session=session, + user_id=referee_user_id, + bonus_days=referee_bonus_days, + reason= + f"referee bonus (invited by {inviter_name_for_referee_msg})" + ) + if new_end_date_referee: + referee_final_end_date = new_end_date_referee + referee_bonus_applied_days = referee_bonus_days + logging.info( + f"Bonus of {referee_bonus_days} days successfully applied to referee {referee_user_id}." + ) + else: + logging.warning( - f"Failed to apply bonus subscription extension for referee {referee_user_id}." + f"Failed to apply referee bonus for {referee_user_id} (could not extend their new subscription)." ) return { "referee_bonus_applied_days": referee_bonus_applied_days, - "referee_new_end_date": referee_final_end_date + "referee_new_end_date": referee_final_end_date, + "inviter_bonus_applied_flag": + inviter_bonus_successfully_applied } except Exception as e: logging.error( - f"Error in apply_referral_bonuses_for_payment (db_conn: {db_conn is not None}): {e}", + f"Error in apply_referral_bonuses_for_payment for referee {referee_user_id}: {e}", exc_info=True) raise diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 1e09071..4574c58 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -1,670 +1,639 @@ import logging -import aiosqlite +from sqlalchemy.ext.asyncio import AsyncSession from datetime import datetime, timedelta, timezone -from typing import Optional, Dict, Any, List, Callable, Awaitable +from typing import Optional, Dict, Any, List, Tuple + +from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal +from db.models import User, 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: - 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 + def __init__(self, settings: Settings, panel_service: PanelApiService): self.settings = settings self.panel_service = panel_service - async def _get_db( + async def get_user_language(self, session: AsyncSession, + user_id: int) -> str: + user_record = await user_dal.get_user_by_id(session, user_id) + return user_record.language_code if user_record and user_record.language_code else self.settings.DEFAULT_LANGUAGE + + async def has_had_any_subscription(self, session: AsyncSession, + user_id: int) -> bool: + + return await subscription_dal.has_any_subscription_for_user( + session, user_id) + + async def _get_or_create_panel_user_link_details( 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 + session: AsyncSession, + user_id: int, + db_user: Optional[User] = None + ) -> Tuple[Optional[str], Optional[str], Optional[str], bool]: + if not db_user: + db_user = await user_dal.get_user_by_id(session, user_id) - 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 + if not db_user: + logging.error( + f"_get_or_create_panel_user_link_details: User {user_id} not found in local DB. Cannot proceed." + ) + return None, None, None, False - 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) + current_local_panel_uuid = db_user.panel_user_uuid + panel_username_on_panel_standard = f"tg_{user_id}" - 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) + panel_user_obj_from_api = None + panel_user_created_or_linked_now = False - 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) + panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter( + telegram_id=user_id) + if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1: + panel_user_obj_from_api = panel_users_by_tg_id_list[0] + logging.info( + f"Found panel user by telegramId {user_id}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}" + ) + elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1: + logging.error( + f"CRITICAL: Multiple panel users found for telegramId {user_id}. Manual intervention needed." + ) + return None, None, None, False - 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) + if not panel_user_obj_from_api: + if current_local_panel_uuid: + + logging.info( + f"User {user_id} (local panel_uuid: {current_local_panel_uuid}) not found on panel by TG ID. Fetching by panel_uuid." + ) + panel_user_obj_from_api = await self.panel_service.get_user_by_uuid( + current_local_panel_uuid) + if not panel_user_obj_from_api: + logging.warning( + f"Local panel_uuid {current_local_panel_uuid} for TG user {user_id} also not found on panel. User might be deleted from panel or UUID desynced." + ) + + else: + + logging.info( + f"No panel user by TG ID & no local panel_uuid for TG user {user_id}. Creating new panel user '{panel_username_on_panel_standard}'." + ) + creation_response = await self.panel_service.create_panel_user( + username_on_panel=panel_username_on_panel_standard, + telegram_id=user_id, + ) + if creation_response and not creation_response.get( + "error") and creation_response.get("response"): + panel_user_obj_from_api = creation_response.get("response") + panel_user_created_or_linked_now = True + + elif creation_response and creation_response.get( + "errorCode") == "A019": + logging.warning( + f"Panel user '{panel_username_on_panel_standard}' already exists (errorCode A019). Fetching by username." + ) + fetched_by_username_list = await self.panel_service.get_users_by_filter( + username=panel_username_on_panel_standard) + if fetched_by_username_list and len( + fetched_by_username_list) == 1: + panel_user_obj_from_api = fetched_by_username_list[0] + + if not panel_user_obj_from_api: + logging.error( + f"Failed to create or link panel user for TG_ID {user_id} with panel username '{panel_username_on_panel_standard}'. Response: {creation_response if 'creation_response' in locals() else 'N/A'}" + ) + return None, None, None, False + + if not panel_user_obj_from_api: + logging.error( + f"Could not obtain panel user object for TG user {user_id} after all checks." + ) + + return current_local_panel_uuid if current_local_panel_uuid else None, None, None, panel_user_created_or_linked_now + + actual_panel_uuid_from_api = panel_user_obj_from_api.get('uuid') + actual_panel_username_from_api = panel_user_obj_from_api.get( + 'username') + panel_telegram_id_from_api = panel_user_obj_from_api.get('telegramId') + + if not actual_panel_uuid_from_api: + logging.error( + f"Panel user object for TG user {user_id} does not contain 'uuid'. Data: {panel_user_obj_from_api}" + ) + return current_local_panel_uuid, None, None, panel_user_created_or_linked_now + + needs_local_panel_uuid_update = False + if current_local_panel_uuid is None and actual_panel_uuid_from_api: + needs_local_panel_uuid_update = True + elif current_local_panel_uuid is not None and current_local_panel_uuid != actual_panel_uuid_from_api: + logging.warning( + f"Local panel_uuid for user {user_id} ('{current_local_panel_uuid}') " + f"differs from panel's UUID ('{actual_panel_uuid_from_api}') for their telegramId. " + f"Will attempt to update local to panel's version.") + needs_local_panel_uuid_update = True + + if needs_local_panel_uuid_update: + + conflicting_user_record = await user_dal.get_user_by_panel_uuid( + session, actual_panel_uuid_from_api) + if conflicting_user_record and conflicting_user_record.user_id != user_id: + logging.error( + f"CRITICAL CONFLICT: Panel UUID {actual_panel_uuid_from_api} (from panel for TG ID {user_id}) " + f"is ALREADY LINKED in local DB to a different TG User {conflicting_user_record.user_id}. " + f"Cannot update panel_user_uuid for user {user_id}. Manual data correction needed." + ) + + return None, None, None, False + else: + + update_data_for_local_user = { + "panel_user_uuid": actual_panel_uuid_from_api + } + + if actual_panel_username_from_api and actual_panel_username_from_api != panel_username_on_panel_standard and ( + db_user.username is None + or db_user.username != actual_panel_username_from_api): + update_data_for_local_user[ + "username"] = actual_panel_username_from_api + + await user_dal.update_user(session, user_id, + update_data_for_local_user) + db_user.panel_user_uuid = actual_panel_uuid_from_api + if "username" in update_data_for_local_user: + db_user.username = update_data_for_local_user["username"] + panel_user_created_or_linked_now = True + current_local_panel_uuid = actual_panel_uuid_from_api + else: + + pass + + panel_telegram_id_int = None + if panel_telegram_id_from_api is not None: + try: + panel_telegram_id_int = int(panel_telegram_id_from_api) + except ValueError: + pass + + if panel_user_obj_from_api and current_local_panel_uuid and panel_telegram_id_int != user_id: + logging.info( + f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{user_id}'." + ) + await self.panel_service.update_user_details_on_panel( + current_local_panel_uuid, {"telegramId": user_id}) + + panel_sub_link_id = panel_user_obj_from_api.get( + 'subscriptionUuid') or panel_user_obj_from_api.get('shortUuid') + panel_short_uuid = panel_user_obj_from_api.get('shortUuid') + + if not panel_sub_link_id and current_local_panel_uuid: + logging.warning( + f"No subscriptionUuid or shortUuid found on panel for panel_user_uuid {current_local_panel_uuid} (TG ID: {user_id})." + ) + + return current_local_panel_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_or_linked_now 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. - """ + self, session: AsyncSession, + user_id: int) -> Optional[Dict[str, Any]]: 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" - } + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user: + logging.error( + f"User {user_id} not found in DB, cannot activate trial.") + return { + "eligible": False, + "activated": False, + "message_key": "user_not_found_for_trial" + } - 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 await self.has_had_any_subscription(session, user_id): + return { + "eligible": False, + "activated": False, + "message_key": "trial_already_had_subscription_or_trial" + } - 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] + panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = await self._get_or_create_panel_user_link_details( + session, user_id, db_user) - 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') + if not panel_user_uuid or not panel_sub_link_id: + logging.error( + f"Failed to get panel link details for trial user {user_id}.") + return { + "eligible": True, + "activated": False, + "message_key": "trial_activation_failed_panel_link" + } - 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" - } + start_date = datetime.now(timezone.utc) + end_date = start_date + timedelta( + days=self.settings.TRIAL_DURATION_DAYS) - 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) + await subscription_dal.deactivate_other_active_subscriptions( + session, panel_user_uuid, panel_sub_link_id) - 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()}." - ) + trial_sub_data = { + "user_id": user_id, + "panel_user_uuid": panel_user_uuid, + "panel_subscription_uuid": panel_sub_link_id, + "start_date": start_date, + "end_date": end_date, + "duration_months": 0, + "is_active": True, + "status_from_panel": "TRIAL", + "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, + } + try: + await subscription_dal.upsert_subscription(session, trial_sub_data) + except Exception as e_upsert: + logging.error( + f"Failed to upsert trial subscription for user {user_id}: {e_upsert}", + exc_info=True) + await session.rollback() + return { + "eligible": True, + "activated": False, + "message_key": "trial_activation_failed_db" + } - 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 + panel_update_payload: Dict[str, Any] = { + "uuid": + panel_user_uuid, + "expireAt": + end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'), + "status": + "ACTIVE", + "trafficLimitBytes": + self.settings.trial_traffic_limit_bytes, + "trafficLimitStrategy": + self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY, + } + if self.settings.parsed_default_panel_user_inbound_uuids: + panel_update_payload[ + "activeUserInbounds"] = self.settings.parsed_default_panel_user_inbound_uuids + elif panel_user_created_now: + panel_update_payload["activateAllInbounds"] = True - 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') + updated_panel_user = await self.panel_service.update_user_details_on_panel( + panel_user_uuid, panel_update_payload) + if not updated_panel_user or updated_panel_user.get("error"): + logging.warning( + f"Panel user details update FAILED for trial user {panel_user_uuid}. Response: {updated_panel_user}" + ) + await session.rollback() + return { + "eligible": True, + "activated": False, + "message_key": "trial_activation_failed_panel_update" + } - 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 session.commit() + + final_subscription_url = updated_panel_user.get('subscriptionUrl') + final_panel_short_uuid = updated_panel_user.get( + 'shortUuid', panel_short_uuid) + + 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": final_panel_short_uuid, + "subscription_url": final_subscription_url + } 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 + self, + session: AsyncSession, + user_id: int, + months: int, + payment_amount: float, + payment_db_id: int, + promo_code_id_from_payment: Optional[int] = None + ) -> Optional[Dict[str, Any]]: - 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.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: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user: logging.error( - f"Error in activate_subscription (paid) for user {user_id}: {e}", + f"User {user_id} not found in DB for paid subscription activation." + ) + return None + + panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = await self._get_or_create_panel_user_link_details( + session, user_id, db_user) + + if not panel_user_uuid or not panel_sub_link_id: + logging.error( + f"Failed to ensure panel user for TG {user_id} during paid subscription." + ) + return None + + current_active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, panel_user_uuid) + start_date = datetime.now(timezone.utc) + if current_active_sub and current_active_sub.end_date and current_active_sub.end_date > start_date: + start_date = current_active_sub.end_date + + duration_days_total = months * 30 + applied_promo_bonus_days = 0 + + if promo_code_id_from_payment: + promo_model = await promo_code_dal.get_promo_code_by_id( + session, promo_code_id_from_payment) + if promo_model and promo_model.is_active and promo_model.current_activations < promo_model.max_activations: + applied_promo_bonus_days = promo_model.bonus_days + duration_days_total += applied_promo_bonus_days + + activation = await promo_code_dal.record_promo_activation( + session, + promo_code_id_from_payment, + user_id, + payment_id=payment_db_id) + if activation: + await promo_code_dal.increment_promo_code_usage( + session, promo_code_id_from_payment) + else: + logging.warning( + f"Promo code {promo_code_id_from_payment} was already activated by user {user_id}, but bonus applied via payment {payment_db_id}." + ) + else: + logging.warning( + f"Promo code ID {promo_code_id_from_payment} (from payment) not found or invalid." + ) + promo_code_id_from_payment = None + + final_end_date = start_date + timedelta(days=duration_days_total) + await subscription_dal.deactivate_other_active_subscriptions( + session, panel_user_uuid, panel_sub_link_id) + + sub_payload = { + "user_id": user_id, + "panel_user_uuid": panel_user_uuid, + "panel_subscription_uuid": panel_sub_link_id, + "start_date": start_date, + "end_date": final_end_date, + "duration_months": months, + "is_active": True, + "status_from_panel": "ACTIVE", + "traffic_limit_bytes": + self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES, + } + try: + new_or_updated_sub = await subscription_dal.upsert_subscription( + session, sub_payload) + except Exception as e_upsert_sub: + logging.error( + f"Failed to upsert paid subscription for user {user_id}: {e_upsert_sub}", exc_info=True) return None + panel_update_payload = { + "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 self.settings.parsed_default_panel_user_inbound_uuids: + panel_update_payload[ + "activeUserInbounds"] = self.settings.parsed_default_panel_user_inbound_uuids + elif panel_user_created_now: + panel_update_payload["activateAllInbounds"] = True + + updated_panel_user = await self.panel_service.update_user_details_on_panel( + panel_user_uuid, panel_update_payload) + if not updated_panel_user or updated_panel_user.get("error"): + logging.warning( + f"Panel user details update FAILED for paid sub user {panel_user_uuid}. Response: {updated_panel_user}" + ) + return None + + final_subscription_url = updated_panel_user.get('subscriptionUrl') + final_panel_short_uuid = updated_panel_user.get( + 'shortUuid', panel_short_uuid) + + return { + "subscription_id": new_or_updated_sub.subscription_id, + "end_date": final_end_date, + "is_active": True, + "panel_user_uuid": panel_user_uuid, + "panel_short_uuid": final_panel_short_uuid, + "subscription_url": final_subscription_url, + "applied_promo_bonus_days": applied_promo_bonus_days + } + async def extend_active_subscription_days( self, + session: AsyncSession, 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." - ) - 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()}" + user = await user_dal.get_user_by_id(session, user_id) + if not user or not user.panel_user_uuid: + logging.warning( + f"Cannot extend subscription for user {user_id}: User or panel_user_uuid not found." ) + return None - if panel_user_uuid_for_update: - panel_update_payload = { - "uuid": - panel_user_uuid_for_update, + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, user.panel_user_uuid) + if not active_sub or not active_sub.end_date: + logging.info( + f"No active extendable subscription found for user {user_id} (panel: {user.panel_user_uuid}) for reason: {reason}." + ) + return None + + current_end_date = active_sub.end_date + 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_obj = start_point_for_bonus + timedelta(days=bonus_days) + + updated_sub_model = await subscription_dal.update_subscription_end_date( + session, active_sub.subscription_id, new_end_date_obj) + + if updated_sub_model: + panel_update_success = await self.panel_service.update_user_details_on_panel( + user.panel_user_uuid, { "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: + new_end_date_obj.isoformat( + timespec='milliseconds').replace('+00:00', 'Z') + }) + if not panel_update_success: + logging.warning( + f"Panel expiry update failed for {user.panel_user_uuid} after {reason} bonus. Local DB was updated to {new_end_date_obj}." + ) + + logging.info( + f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date_obj}." + ) + return new_end_date_obj + else: logging.error( - f"Error extending subscription with {reason} bonus for user {user_id} (using provided db_conn): {e}", - exc_info=True) + f"Failed to update subscription end date locally for user {user_id}." + ) 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 + async def get_active_subscription_details( + self, session: AsyncSession, + user_id: int) -> Optional[Dict[str, Any]]: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + logging.info( + f"User {user_id} not found in DB or no panel_user_uuid for 'my_subscription'." + ) return None - finally: - await self._release_db(db, should_manage) + + panel_user_uuid = db_user.panel_user_uuid + local_active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, panel_user_uuid) + panel_user_data = await self.panel_service.get_user_by_uuid( + panel_user_uuid) + + if not panel_user_data: + logging.warning( + f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Using local data if available." + ) + if local_active_sub and local_active_sub.end_date and local_active_sub.end_date > datetime.now( + timezone.utc): + return { + "end_date": + local_active_sub.end_date, + "status_from_panel": + (local_active_sub.status_from_panel or "UNKNOWN") + + " (Panel Unreachable)", + "config_link": + await self.panel_service.get_subscription_link( + local_active_sub.panel_subscription_uuid) + if local_active_sub.panel_subscription_uuid else "N/A", + "traffic_limit_bytes": + local_active_sub.traffic_limit_bytes, + "traffic_used_bytes": + local_active_sub.traffic_used_bytes, + "user_bot_username": + db_user.username, + "is_panel_data": + False + } + return None + + if local_active_sub: + update_payload_local = {} + panel_status = panel_user_data.get('status', 'UNKNOWN').upper() + panel_expire_at_str = panel_user_data.get('expireAt') + panel_traffic_used = panel_user_data.get('usedTrafficBytes') + panel_traffic_limit = panel_user_data.get('trafficLimitBytes') + panel_sub_uuid_from_panel = panel_user_data.get( + 'subscriptionUuid') or panel_user_data.get('shortUuid') + + if local_active_sub.status_from_panel != panel_status: + update_payload_local['status_from_panel'] = panel_status + if panel_expire_at_str: + panel_expire_dt = datetime.fromisoformat( + panel_expire_at_str.replace("Z", "+00:00")) + if local_active_sub.end_date.replace( + microsecond=0) != panel_expire_dt.replace( + microsecond=0): + update_payload_local['end_date'] = panel_expire_dt + update_payload_local['last_notification_sent'] = None + if panel_traffic_used is not None and local_active_sub.traffic_used_bytes != panel_traffic_used: + update_payload_local['traffic_used_bytes'] = panel_traffic_used + if panel_traffic_limit is not None and local_active_sub.traffic_limit_bytes != panel_traffic_limit: + update_payload_local[ + 'traffic_limit_bytes'] = panel_traffic_limit + if panel_sub_uuid_from_panel and local_active_sub.panel_subscription_uuid != panel_sub_uuid_from_panel: + update_payload_local[ + 'panel_subscription_uuid'] = panel_sub_uuid_from_panel + + is_active_based_on_panel = panel_status == "ACTIVE" and ( + panel_expire_dt > datetime.now(timezone.utc) + if panel_expire_dt else False) + if local_active_sub.is_active != is_active_based_on_panel: + update_payload_local['is_active'] = is_active_based_on_panel + + if update_payload_local: + await subscription_dal.update_subscription( + session, local_active_sub.subscription_id, + update_payload_local) + + panel_end_date = datetime.fromisoformat( + panel_user_data['expireAt'].replace( + "Z", "+00:00")) if panel_user_data.get('expireAt') else None + + return { + "end_date": + panel_end_date, + "status_from_panel": + panel_user_data.get('status', 'UNKNOWN').upper(), + "config_link": + panel_user_data.get('subscriptionUrl') + or (await self.panel_service.get_subscription_link( + panel_user_data['shortUuid']) + if panel_user_data.get('shortUuid') else "N/A"), + "traffic_limit_bytes": + panel_user_data.get('trafficLimitBytes'), + "traffic_used_bytes": + panel_user_data.get('usedTrafficBytes'), + "user_bot_username": + db_user.username, + "is_panel_data": + True + } 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) + self, session: AsyncSession, + days_threshold: int) -> List[Dict[str, Any]]: + subs_models_with_users = await subscription_dal.get_subscriptions_near_expiration( + session, days_threshold) + results = [] + for sub_model in subs_models_with_users: + if sub_model.user and sub_model.end_date: + days_left = (sub_model.end_date - datetime.now( + timezone.utc)).total_seconds() / (24 * 3600) + results.append({ + 'user_id': + sub_model.user_id, + 'first_name': + sub_model.user.first_name or f"User {sub_model.user_id}", + 'language_code': + sub_model.user.language_code + or self.settings.DEFAULT_LANGUAGE, + 'end_date_str': + sub_model.end_date.strftime('%Y-%m-%d'), + 'days_left': + max(0, int(round(days_left))), + 'subscription_end_date_iso_for_update': + sub_model.end_date + }) + return results - 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}" + async def update_last_notification_sent(self, session: AsyncSession, + user_id: int, + subscription_end_date: datetime): + sub_to_update = await subscription_dal.find_subscription_for_notification_update( + session, user_id, subscription_end_date) + if sub_to_update: + await subscription_dal.update_subscription_notification_time( + session, sub_to_update.subscription_id, + datetime.now(timezone.utc)) + logging.info( + f"Updated last_notification_sent for user {user_id}, sub_id {sub_to_update.subscription_id}" + ) + else: + logging.warning( + f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time." ) - await db.rollback() - finally: - await self._release_db(db, should_manage) diff --git a/config/settings.py b/config/settings.py index 04699a3..a00a948 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,6 +1,6 @@ import logging from pydantic_settings import BaseSettings, SettingsConfigDict -from pydantic import Field, field_validator, ValidationError +from pydantic import Field, ValidationError, computed_field from typing import Optional, List, Dict, Any @@ -11,64 +11,67 @@ class Settings(BaseSettings): alias="ADMIN_IDS", description="Comma-separated list of admin Telegram User IDs") - DB_NAME: str = Field(default="bot_database.sqlite3") + POSTGRES_USER: str = Field(default="user") + POSTGRES_PASSWORD: str = Field(default="password") + POSTGRES_HOST: str = Field(default="localhost") + POSTGRES_PORT: int = Field(default=5432) + POSTGRES_DB: str = Field(default="vpn_shop_db") + 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)") - TERMS_OF_SERVICE_URL: Optional[str] = Field( - default=None, description="Link to Terms of Use page") + SUPPORT_LINK: Optional[str] = Field(default=None) + SERVER_STATUS_URL: Optional[str] = Field(default=None) + TERMS_OF_SERVICE_URL: Optional[str] = Field(default=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=1, description="YooKassa VAT code (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)") + YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None) + YOOKASSA_VAT_CODE: int = Field(default=1) + YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment") + YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service") 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 + PRICE_1_MONTH: Optional[int] = Field(default=None) + PRICE_3_MONTHS: Optional[int] = Field(default=None) + PRICE_6_MONTHS: Optional[int] = Field(default=None) + PRICE_12_MONTHS: Optional[int] = Field(default=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) - 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_INVITER_1_MONTH: Optional[int] = Field( + default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH") + REFERRAL_BONUS_DAYS_INVITER_3_MONTHS: Optional[int] = Field( + default=7, alias="REFERRAL_BONUS_DAYS_3_MONTHS") + REFERRAL_BONUS_DAYS_INVITER_6_MONTHS: Optional[int] = Field( + default=15, alias="REFERRAL_BONUS_DAYS_6_MONTHS") + REFERRAL_BONUS_DAYS_INVITER_12_MONTHS: Optional[int] = Field( + default=30, alias="REFERRAL_BONUS_DAYS_12_MONTHS") + + REFERRAL_BONUS_DAYS_REFEREE_1_MONTH: Optional[int] = Field( + default=1, alias="REFEREE_BONUS_DAYS_1_MONTH") + REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS: Optional[int] = Field( + default=3, alias="REFEREE_BONUS_DAYS_3_MONTHS") + REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS: Optional[int] = Field( + default=7, alias="REFEREE_BONUS_DAYS_6_MONTHS") + REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS: Optional[int] = Field( + default=15, alias="REFEREE_BONUS_DAYS_12_MONTHS") 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) + PANEL_USER_DEFAULT_INBOUND_UUIDS: Optional[str] = Field( + default=None, + description= + "Comma-separated UUIDs of inbounds to activate for new panel users") TRIAL_ENABLED: bool = Field(default=True) TRIAL_DURATION_DAYS: int = Field(default=3) @@ -78,45 +81,42 @@ class Settings(BaseSettings): WEB_SERVER_PORT: int = Field(default=8080) LOGS_PAGE_SIZE: int = Field(default=10) - _admin_ids_list: Optional[List[int]] = None + @computed_field + @property + def DATABASE_URL(self) -> str: + return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + @computed_field @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 + if self.ADMIN_IDS_STR: + try: + return [ + int(admin_id.strip()) + for admin_id in self.ADMIN_IDS_STR.split(',') + if admin_id.strip().isdigit() + ] + except ValueError: + logging.error( + f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers." + ) + return [] + return [] + @computed_field @property def PRIMARY_ADMIN_ID(self) -> Optional[int]: - admin_ids_list = self.ADMIN_IDS - if admin_ids_list: - return admin_ids_list[0] - return None + ids = self.ADMIN_IDS + return ids[0] if ids else None + @computed_field @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)) + @computed_field @property def parsed_default_panel_user_inbound_uuids(self) -> Optional[List[str]]: if self.PANEL_USER_DEFAULT_INBOUND_UUIDS: @@ -127,49 +127,61 @@ class Settings(BaseSettings): ] return None + @computed_field @property def yookassa_webhook_path(self) -> str: + return "/webhook/yookassa" + @computed_field @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}" return None + @computed_field @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, float]: + options: Dict[int, float] = {} + + if self.PRICE_1_MONTH is not None: + options[1] = float(self.PRICE_1_MONTH / 100.0) + if self.PRICE_3_MONTHS is not None: + options[3] = float(self.PRICE_3_MONTHS / 100.0) + if self.PRICE_6_MONTHS is not None: + options[6] = float(self.PRICE_6_MONTHS / 100.0) + if self.PRICE_12_MONTHS is not None: + options[12] = float(self.PRICE_12_MONTHS / 100.0) return options + @computed_field @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 + if self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH is not None: + bonuses[1] = self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH + if self.REFERRAL_BONUS_DAYS_INVITER_3_MONTHS is not None: + bonuses[3] = self.REFERRAL_BONUS_DAYS_INVITER_3_MONTHS + if self.REFERRAL_BONUS_DAYS_INVITER_6_MONTHS is not None: + bonuses[6] = self.REFERRAL_BONUS_DAYS_INVITER_6_MONTHS + if self.REFERRAL_BONUS_DAYS_INVITER_12_MONTHS is not None: + bonuses[12] = self.REFERRAL_BONUS_DAYS_INVITER_12_MONTHS return bonuses + @computed_field @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 + if self.REFERRAL_BONUS_DAYS_REFEREE_1_MONTH is not None: + bonuses[1] = self.REFERRAL_BONUS_DAYS_REFEREE_1_MONTH + if self.REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS is not None: + bonuses[3] = self.REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS + if self.REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS is not None: + bonuses[6] = self.REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS + if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None: + bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS return bonuses model_config = SettingsConfigDict(env_file='.env', @@ -188,10 +200,23 @@ def get_settings() -> Settings: _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." + "CRITICAL: ADMIN_IDS not set or contains no valid integer IDs in .env. " + "Admin functionality will be restricted.") + + if not _settings_instance.PANEL_API_URL: + logging.warning( + "CRITICAL: PANEL_API_URL is not set. Panel integration will not work." ) + if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY: + logging.warning( + "CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work." + ) + except ValidationError as e: logging.critical( f"Pydantic validation error while loading settings: {e}") - raise SystemExit(f"CRITICAL SETTINGS ERROR: {e}") + + raise SystemExit( + f"CRITICAL SETTINGS ERROR: {e}. Please check your .env file and Settings model." + ) return _settings_instance diff --git a/db/dal/message_log_dal.py b/db/dal/message_log_dal.py new file mode 100644 index 0000000..576a1a3 --- /dev/null +++ b/db/dal/message_log_dal.py @@ -0,0 +1,76 @@ +import logging +from typing import Optional, List +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import func, or_ + +from ..models import MessageLog, User + + +async def create_message_log(session: AsyncSession, + log_data: dict) -> Optional[MessageLog]: + + try: + log_entry = await create_message_log_no_commit(session, log_data) + await session.commit() + await session.refresh(log_entry) + return log_entry + except Exception as e: + await session.rollback() + logging.error(f"Failed to create and commit message log: {e}", + exc_info=True) + return None + + +async def get_all_message_logs(session: AsyncSession, limit: int, + offset: int) -> List[MessageLog]: + stmt = select(MessageLog).order_by( + MessageLog.timestamp.desc()).limit(limit).offset(offset) + result = await session.execute(stmt) + return result.scalars().all() + + +async def count_all_message_logs(session: AsyncSession) -> int: + stmt = select(func.count()).select_from(MessageLog) + result = await session.execute(stmt) + return result.scalar_one() + + +async def get_user_message_logs(session: AsyncSession, user_id_to_search: int, + limit: int, offset: int) -> List[MessageLog]: + stmt = (select(MessageLog).where( + or_(MessageLog.user_id == user_id_to_search, + MessageLog.target_user_id == user_id_to_search)).order_by( + MessageLog.timestamp.desc()).limit(limit).offset(offset)) + result = await session.execute(stmt) + return result.scalars().all() + + +async def count_user_message_logs(session: AsyncSession, + user_id_to_search: int) -> int: + stmt = (select(func.count()).select_from(MessageLog).where( + or_(MessageLog.user_id == user_id_to_search, + MessageLog.target_user_id == user_id_to_search))) + result = await session.execute(stmt) + return result.scalar_one() + + +async def create_message_log_no_commit(session: AsyncSession, + log_data: dict) -> MessageLog: + + if log_data.get("target_user_id"): + from .user_dal import get_user_by_id + target_user = await get_user_by_id(session, log_data["target_user_id"]) + if not target_user: + logging.warning( + f"Target user {log_data['target_user_id']} not found for message log. Setting to NULL." + ) + log_data["target_user_id"] = None + + new_log = MessageLog(**log_data) + session.add(new_log) + + logging.debug( + f"Message log added to session: user {log_data.get('user_id')}, event {log_data.get('event_type')}" + ) + return new_log diff --git a/db/dal/panel_sync_dal.py b/db/dal/panel_sync_dal.py new file mode 100644 index 0000000..a1ec684 --- /dev/null +++ b/db/dal/panel_sync_dal.py @@ -0,0 +1,50 @@ +import logging +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import update +from datetime import datetime, timezone + +from db.models import PanelSyncStatus + +SINGLETON_ID = 1 + + +async def get_panel_sync_status( + session: AsyncSession) -> Optional[PanelSyncStatus]: + return await session.get(PanelSyncStatus, SINGLETON_ID) + + +async def update_panel_sync_status( + session: AsyncSession, + status: str, + details: str, + users_processed: int = 0, + subs_synced: int = 0, + last_sync_time: Optional[datetime] = None) -> PanelSyncStatus: + if last_sync_time is None: + last_sync_time = datetime.now(timezone.utc) + + sync_record = await get_panel_sync_status(session) + if sync_record: + sync_record.last_sync_time = last_sync_time + sync_record.status = status + sync_record.details = details + sync_record.users_processed_from_panel = users_processed + sync_record.subscriptions_synced = subs_synced + else: + sync_record = PanelSyncStatus( + id=SINGLETON_ID, + last_sync_time=last_sync_time, + status=status, + details=details, + users_processed_from_panel=users_processed, + subscriptions_synced=subs_synced) + session.add(sync_record) + + await session.flush() + await session.refresh(sync_record) + logging.info( + f"Panel sync status updated: {status}, Users: {users_processed}, Subs: {subs_synced}" + ) + return sync_record diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py new file mode 100644 index 0000000..47cb147 --- /dev/null +++ b/db/dal/payment_dal.py @@ -0,0 +1,115 @@ +import logging +from typing import Optional, List, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import update, func +from sqlalchemy.orm import selectinload + +from db.models import Payment, User + + +async def create_payment_record(session: AsyncSession, + payment_data: Dict[str, Any]) -> Payment: + + from .user_dal import get_user_by_id + user = await get_user_by_id(session, payment_data["user_id"]) + if not user: + + raise ValueError( + f"User with id {payment_data['user_id']} not found for creating payment." + ) + + if payment_data.get("promo_code_id"): + from .promo_code_dal import get_promo_code_by_id + promo = await get_promo_code_by_id(session, + payment_data["promo_code_id"]) + if not promo: + raise ValueError( + f"Promo code with id {payment_data['promo_code_id']} not found." + ) + + new_payment = Payment(**payment_data) + session.add(new_payment) + await session.flush() + await session.refresh(new_payment) + logging.info( + f"Payment record {new_payment.payment_id} created for user {new_payment.user_id}" + ) + return new_payment + + +async def get_payment_by_yookassa_id( + session: AsyncSession, yookassa_payment_id: str) -> Optional[Payment]: + stmt = select(Payment).where( + Payment.yookassa_payment_id == yookassa_payment_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def get_payment_by_db_id(session: AsyncSession, + payment_db_id: int) -> Optional[Payment]: + + stmt = select(Payment).where(Payment.payment_id == payment_db_id).options( + selectinload(Payment.user), selectinload(Payment.promo_code_used)) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def get_payment_by_db_id_with_promo( + session: AsyncSession, payment_db_id: int) -> Optional[Payment]: + + stmt = select(Payment).where(Payment.payment_id == payment_db_id).options( + selectinload(Payment.promo_code_used)) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def update_payment_status_by_db_id( + session: AsyncSession, + payment_db_id: int, + new_status: str, + yk_payment_id: Optional[str] = None) -> Optional[Payment]: + payment = await get_payment_by_db_id(session, payment_db_id) + if payment: + payment.status = new_status + payment.updated_at = func.now() + if yk_payment_id and payment.yookassa_payment_id is None: + payment.yookassa_payment_id = yk_payment_id + await session.flush() + await session.refresh(payment) + logging.info( + f"Payment record {payment.payment_id} status updated to {new_status}." + ) + else: + logging.warning( + f"Payment record with DB ID {payment_db_id} not found for status update." + ) + return payment + + +async def update_payment_status_by_yk_id(session: AsyncSession, + yookassa_payment_id: str, + new_status: str) -> Optional[Payment]: + payment = await get_payment_by_yookassa_id(session, yookassa_payment_id) + if payment: + payment.status = new_status + payment.updated_at = func.now() + await session.flush() + await session.refresh(payment) + logging.info( + f"Payment record with YK ID {yookassa_payment_id} status updated to {new_status}." + ) + else: + logging.warning( + f"Payment record with YK ID {yookassa_payment_id} not found for status update." + ) + return payment + + +async def get_recent_payment_logs_with_user(session: AsyncSession, + limit: int = 20, + offset: int = 0) -> List[Payment]: + stmt = (select(Payment).options(selectinload(Payment.user)).order_by( + Payment.created_at.desc()).limit(limit).offset(offset)) + result = await session.execute(stmt) + return result.scalars().all() diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py new file mode 100644 index 0000000..522d189 --- /dev/null +++ b/db/dal/promo_code_dal.py @@ -0,0 +1,126 @@ +import logging +from typing import Optional, List, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import update, func, and_, or_ +from datetime import datetime, timezone + +from db.models import PromoCode, PromoCodeActivation, User, Payment + + +async def create_promo_code(session: AsyncSession, + promo_data: Dict[str, Any]) -> PromoCode: + + new_promo = PromoCode(**promo_data) + session.add(new_promo) + await session.flush() + await session.refresh(new_promo) + logging.info( + f"Promo code '{new_promo.code}' created with ID {new_promo.promo_code_id}" + ) + return new_promo + + +async def get_promo_code_by_id(session: AsyncSession, + promo_code_id: int) -> Optional[PromoCode]: + return await session.get(PromoCode, promo_code_id) + + +async def get_active_promo_code_by_code_str( + session: AsyncSession, code_str: str) -> Optional[PromoCode]: + stmt = select(PromoCode).where( + PromoCode.code == code_str.upper(), PromoCode.is_active == True, + PromoCode.current_activations < PromoCode.max_activations, + or_(PromoCode.valid_until == None, PromoCode.valid_until + > datetime.now(timezone.utc))) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def get_all_active_promo_codes(session: AsyncSession, + limit: int = 20, + offset: int = 0) -> List[PromoCode]: + stmt = (select(PromoCode).where( + PromoCode.is_active == True, + or_(PromoCode.valid_until == None, PromoCode.valid_until + > datetime.now(timezone.utc))).order_by( + PromoCode.created_at.desc()).limit(limit).offset(offset)) + result = await session.execute(stmt) + return result.scalars().all() + + +async def increment_promo_code_usage( + session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]: + promo = await get_promo_code_by_id(session, promo_code_id) + if promo: + if promo.current_activations < promo.max_activations: + promo.current_activations += 1 + await session.flush() + await session.refresh(promo) + return promo + else: + logging.warning( + f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations." + ) + return None + return None + + +async def get_user_activation_for_promo( + session: AsyncSession, promo_code_id: int, + user_id: int) -> Optional[PromoCodeActivation]: + + stmt = select(PromoCodeActivation).where( + PromoCodeActivation.promo_code_id == promo_code_id, + PromoCodeActivation.user_id == user_id).limit(1) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def record_promo_activation( + session: AsyncSession, + promo_code_id: int, + user_id: int, + payment_id: Optional[int] = None) -> Optional[PromoCodeActivation]: + + existing_activation = await get_user_activation_for_promo( + session, promo_code_id, user_id) + if existing_activation: + logging.info( + f"User {user_id} has already activated promo code {promo_code_id}. Activation ID: {existing_activation.activation_id}" + ) + return existing_activation + + from .user_dal import get_user_by_id + user = await get_user_by_id(session, user_id) + promo = await get_promo_code_by_id(session, promo_code_id) + if not user or not promo: + logging.error( + f"Cannot record promo activation: User {user_id} or Promo {promo_code_id} not found." + ) + return None + + if payment_id: + from .payment_dal import get_payment_by_db_id + payment = await get_payment_by_db_id(session, payment_id) + if not payment: + logging.error( + f"Cannot record promo activation: Payment {payment_id} not found." + ) + + return None + + activation_data = { + "promo_code_id": promo_code_id, + "user_id": user_id, + "payment_id": payment_id, + "activated_at": datetime.now(timezone.utc) + } + new_activation = PromoCodeActivation(**activation_data) + session.add(new_activation) + await session.flush() + await session.refresh(new_activation) + logging.info( + f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}" + ) + return new_activation diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py new file mode 100644 index 0000000..c621cfa --- /dev/null +++ b/db/dal/subscription_dal.py @@ -0,0 +1,205 @@ +import logging +from typing import Optional, List, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import update, delete, func, and_, or_ +from sqlalchemy.orm import selectinload +from datetime import datetime, timezone, timedelta + +from db.models import Subscription, User + + +async def get_active_subscription_by_user_id( + session: AsyncSession, + user_id: int, + panel_user_uuid: Optional[str] = None) -> Optional[Subscription]: + stmt = select(Subscription).where( + Subscription.user_id == user_id, Subscription.is_active == True, + Subscription.end_date > datetime.now(timezone.utc)) + if panel_user_uuid: + stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid) + stmt = stmt.order_by(Subscription.end_date.desc()) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def get_subscription_by_panel_subscription_uuid( + session: AsyncSession, panel_sub_uuid: str) -> Optional[Subscription]: + stmt = select(Subscription).where( + Subscription.panel_subscription_uuid == panel_sub_uuid) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def create_subscription(session: AsyncSession, + sub_data: Dict[str, Any]) -> Subscription: + from .user_dal import get_user_by_id + + if "user_id" not in sub_data or sub_data["user_id"] is None: + raise ValueError( + "user_id is required to create a subscription directly.") + user = await get_user_by_id(session, sub_data["user_id"]) + if not user: + raise ValueError( + f"User with id {sub_data['user_id']} not found for creating subscription." + ) + + new_sub = Subscription(**sub_data) + session.add(new_sub) + await session.flush() + await session.refresh(new_sub) + logging.info( + f"Subscription {new_sub.subscription_id} created for user {new_sub.user_id}" + ) + return new_sub + + +async def update_subscription( + session: AsyncSession, subscription_id: int, + update_data: Dict[str, Any]) -> Optional[Subscription]: + sub = await session.get(Subscription, subscription_id) + if sub: + for key, value in update_data.items(): + setattr(sub, key, value) + await session.flush() + await session.refresh(sub) + return sub + + +async def upsert_subscription(session: AsyncSession, + sub_payload: Dict[str, Any]) -> Subscription: + panel_sub_uuid = sub_payload.get("panel_subscription_uuid") + if not panel_sub_uuid: + raise ValueError("panel_subscription_uuid is required for upsert.") + + existing_sub = await get_subscription_by_panel_subscription_uuid( + session, panel_sub_uuid) + + if existing_sub: + logging.info( + f"Updating existing subscription {existing_sub.subscription_id} by panel_sub_uuid {panel_sub_uuid}" + ) + for key, value in sub_payload.items(): + if hasattr(existing_sub, key): + setattr(existing_sub, key, value) + await session.flush() + await session.refresh(existing_sub) + return existing_sub + else: + logging.info( + f"Creating new subscription with panel_sub_uuid {panel_sub_uuid}") + + if sub_payload.get( + "user_id") is None and "panel_user_uuid" not in sub_payload: + raise ValueError( + "For a new subscription without user_id, panel_user_uuid is required." + ) + if "end_date" not in sub_payload: + raise ValueError("Missing 'end_date' for new subscription.") + if sub_payload.get("user_id") is not None: + from .user_dal import get_user_by_id + user = await get_user_by_id(session, sub_payload["user_id"]) + if not user: + raise ValueError( + f"User {sub_payload['user_id']} not found for new subscription with panel_uuid {panel_sub_uuid}." + ) + + new_sub = Subscription(**sub_payload) + session.add(new_sub) + await session.flush() + await session.refresh(new_sub) + return new_sub + + +async def deactivate_other_active_subscriptions( + session: AsyncSession, panel_user_uuid: str, + current_panel_subscription_uuid: Optional[str]): + stmt = (update(Subscription).where( + Subscription.panel_user_uuid == panel_user_uuid, + Subscription.is_active == True, + ).values(is_active=False, status_from_panel="INACTIVE_BY_BOT_SYNC")) + if current_panel_subscription_uuid: + stmt = stmt.where(Subscription.panel_subscription_uuid != + current_panel_subscription_uuid) + + result = await session.execute(stmt) + if result.rowcount > 0: + logging.info( + f"Deactivated {result.rowcount} other active subscriptions for panel_user_uuid {panel_user_uuid}." + ) + + +async def update_subscription_end_date( + session: AsyncSession, subscription_id: int, + new_end_date: datetime) -> Optional[Subscription]: + + return await update_subscription( + session, subscription_id, { + "end_date": new_end_date, + "last_notification_sent": None, + "is_active": True, + "status_from_panel": "ACTIVE_EXTENDED_BY_BOT" + }) + + +async def has_any_subscription_for_user(session: AsyncSession, + user_id: int) -> bool: + stmt = select(Subscription.subscription_id).where( + Subscription.user_id == user_id).limit(1) + result = await session.execute(stmt) + return result.scalar_one_or_none() is not None + + +async def get_subscriptions_near_expiration( + session: AsyncSession, days_threshold: int) -> List[Subscription]: + now_utc = datetime.now(timezone.utc) + threshold_date = now_utc + timedelta(days=days_threshold) + + stmt = (select(Subscription).join(Subscription.user).where( + Subscription.is_active == True, Subscription.end_date > now_utc, + Subscription.end_date <= threshold_date, + or_( + Subscription.last_notification_sent == None, + func.date(Subscription.last_notification_sent) + < func.date(now_utc))).order_by( + Subscription.end_date.asc()).options( + selectinload(Subscription.user))) + result = await session.execute(stmt) + return result.scalars().all() + + +async def update_subscription_notification_time( + session: AsyncSession, subscription_id: int, + notification_time: datetime) -> Optional[Subscription]: + return await update_subscription( + session, subscription_id, + {"last_notification_sent": notification_time}) + + +async def get_user_active_subscription_end_date_str( + session: AsyncSession, user_id: int) -> Optional[str]: + stmt = (select(Subscription.end_date).where( + Subscription.user_id == user_id, Subscription.is_active == True, + Subscription.end_date > datetime.now(timezone.utc)).order_by( + Subscription.end_date.desc()).limit(1)) + result = await session.execute(stmt) + end_date_obj = result.scalar_one_or_none() + return end_date_obj.strftime('%Y-%m-%d') if end_date_obj else None + + +async def find_subscription_for_notification_update( + session: AsyncSession, user_id: int, + subscription_end_date_to_match: datetime) -> Optional[Subscription]: + + if subscription_end_date_to_match.tzinfo is None: + subscription_end_date_to_match = subscription_end_date_to_match.replace( + tzinfo=timezone.utc) + + stmt = select(Subscription).where( + Subscription.user_id == user_id, Subscription.is_active == True, + Subscription.end_date + >= subscription_end_date_to_match - timedelta(seconds=1), + Subscription.end_date + <= subscription_end_date_to_match + timedelta(seconds=1)).limit(1) + result = await session.execute(stmt) + return result.scalar_one_or_none() diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py new file mode 100644 index 0000000..526d4ef --- /dev/null +++ b/db/dal/user_dal.py @@ -0,0 +1,124 @@ +import logging +from typing import Optional, List, Dict, Any, Tuple +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload +from sqlalchemy import update, delete, func, and_ +from datetime import datetime + +from ..models import User, Subscription + + +async def get_user_by_id(session: AsyncSession, + user_id: int) -> Optional[User]: + stmt = select(User).where(User.user_id == user_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def get_user_by_username(session: AsyncSession, + username: str) -> Optional[User]: + clean_username = username.lstrip('@').lower() + stmt = select(User).where(func.lower(User.username) == clean_username) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def get_user_by_panel_uuid(session: AsyncSession, + panel_uuid: str) -> Optional[User]: + stmt = select(User).where(User.panel_user_uuid == panel_uuid) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def create_user(session: AsyncSession, user_data: Dict[str, + Any]) -> User: + + if 'registration_date' not in user_data: + user_data['registration_date'] = datetime.now() + + new_user = User(**user_data) + session.add(new_user) + await session.flush() + await session.refresh(new_user) + logging.info( + f"New user {new_user.user_id} created in DAL. Referred by: {new_user.referred_by_id or 'N/A'}." + ) + return new_user + + +async def update_user(session: AsyncSession, user_id: int, + update_data: Dict[str, Any]) -> Optional[User]: + user = await get_user_by_id(session, user_id) + if user: + for key, value in update_data.items(): + setattr(user, key, value) + await session.flush() + await session.refresh(user) + return user + + +async def update_user_language(session: AsyncSession, user_id: int, + lang_code: str) -> bool: + stmt = update(User).where(User.user_id == user_id).values( + language_code=lang_code) + result = await session.execute(stmt) + return result.rowcount > 0 + + +async def set_user_ban_status(session: AsyncSession, user_id: int, + is_banned: bool) -> bool: + user = await get_user_by_id(session, user_id) + if user: + user.is_banned = is_banned + await session.flush() + await session.refresh(user) + return True + return False + + +async def get_banned_users_paginated(session: AsyncSession, limit: int, + offset: int) -> Tuple[List[User], int]: + stmt_users = select(User).where(User.is_banned == True).order_by( + User.registration_date.desc()).limit(limit).offset(offset) + result_users = await session.execute(stmt_users) + users_list = result_users.scalars().all() + + stmt_count = select( + func.count()).select_from(User).where(User.is_banned == True) + result_count = await session.execute(stmt_count) + total_banned = result_count.scalar_one() + + return users_list, total_banned + + +async def get_all_active_user_ids_for_broadcast( + session: AsyncSession) -> List[int]: + stmt = select(User.user_id).where(User.is_banned == False) + result = await session.execute(stmt) + return result.scalars().all() + + +async def get_user_count_stats_dal(session: AsyncSession) -> Dict[str, int]: + total_users_stmt = select(func.count(User.user_id)).select_from(User) + banned_users_stmt = select(func.count( + User.user_id)).select_from(User).where(User.is_banned == True) + + active_subs_stmt = (select(func.count( + func.distinct(Subscription.user_id))).join( + User, Subscription.user_id == User.user_id).where( + Subscription.is_active == True).where( + Subscription.end_date > datetime.now())) + + total_users = (await + session.execute(total_users_stmt)).scalar_one_or_none() or 0 + banned_users = ( + await session.execute(banned_users_stmt)).scalar_one_or_none() or 0 + active_subs_users = ( + await session.execute(active_subs_stmt)).scalar_one_or_none() or 0 + + return { + "total_users": total_users, + "banned_users": banned_users, + "users_with_active_subscriptions": active_subs_users, + } diff --git a/db/database.py b/db/database.py deleted file mode 100644 index 9a3f38a..0000000 --- a/db/database.py +++ /dev/null @@ -1,1081 +0,0 @@ -import aiosqlite -import logging -from typing import Optional, Dict, Any, List, Tuple -from datetime import datetime, timezone - -from config.settings import get_settings - -try: - settings = get_settings() - DB_NAME = settings.DB_NAME -except Exception as e: - logging.critical(f"Could not load settings for database.py: {e}", - exc_info=True) - DB_NAME = "bot_database.sqlite3" - -DB_BUSY_TIMEOUT_SECONDS = 15.0 - - -def get_db_connection_manager(): - """ - Возвращает awaitable/async context manager для соединения с SQLite. - """ - return aiosqlite.connect(DB_NAME, timeout=DB_BUSY_TIMEOUT_SECONDS) - - -async def _setup_db_connection(db: aiosqlite.Connection): - """Применяет необходимые PRAGMA и row_factory к установленному соединению.""" - try: - await db.execute("PRAGMA journal_mode=WAL;") - await db.execute( - f"PRAGMA busy_timeout = {int(DB_BUSY_TIMEOUT_SECONDS * 1000)};") - db.row_factory = aiosqlite.Row - except Exception as e: - logging.error( - f"Failed to set PRAGMAs or row_factory on connection: {e}", - exc_info=True) - - -async def init_db(): - """Инициализирует схему БД.""" - try: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - - await db.execute(""" - CREATE TABLE IF NOT EXISTS users ( - user_id INTEGER PRIMARY KEY, - username TEXT, - first_name TEXT, - last_name TEXT, - language_code TEXT DEFAULT 'en', - registration_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string, e.g. YYYY-MM-DD HH:MM:SS */ - is_banned INTEGER DEFAULT 0, - panel_user_uuid TEXT UNIQUE, - referred_by_id INTEGER, - FOREIGN KEY (referred_by_id) REFERENCES users(user_id) - ) - """) - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_users_panel_user_uuid ON users (panel_user_uuid);" - ) - - await db.execute(""" - CREATE TABLE IF NOT EXISTS subscriptions ( - subscription_id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - panel_user_uuid TEXT NOT NULL, - panel_subscription_uuid TEXT UNIQUE, - start_date TEXT, /* Storing as ISO TEXT */ - end_date TEXT NOT NULL, /* Storing as ISO TEXT */ - duration_months INTEGER, - is_active INTEGER DEFAULT 1, - status_from_panel TEXT, - traffic_limit_bytes INTEGER, - traffic_used_bytes INTEGER, - last_notification_sent TEXT /* Storing as TEXT YYYY-MM-DD */ - )""") - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions (user_id);" - ) - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_subscriptions_panel_user_uuid ON subscriptions (panel_user_uuid);" - ) - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_subscriptions_end_date ON subscriptions (end_date);" - ) - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_subscriptions_is_active ON subscriptions (is_active);" - ) - - await db.execute(""" - CREATE TABLE IF NOT EXISTS payments ( - payment_id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - yookassa_payment_id TEXT UNIQUE, - idempotence_key TEXT UNIQUE, - amount REAL NOT NULL, - currency TEXT NOT NULL, - status TEXT NOT NULL, - description TEXT, - subscription_duration_months INTEGER, - promo_code_id INTEGER, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */ - updated_at TIMESTAMP, /* TEXT as ISO8601 string */ - FOREIGN KEY (user_id) REFERENCES users(user_id), - FOREIGN KEY (promo_code_id) REFERENCES promo_codes(promo_code_id) - )""") - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments (user_id);" - ) - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_payments_yookassa_payment_id ON payments (yookassa_payment_id);" - ) - - await db.execute(""" - CREATE TABLE IF NOT EXISTS promo_codes ( - promo_code_id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT UNIQUE NOT NULL, - bonus_days INTEGER NOT NULL, - max_activations INTEGER NOT NULL, - current_activations INTEGER DEFAULT 0, - is_active INTEGER DEFAULT 1, - created_by_admin_id INTEGER NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */ - valid_until TEXT NULL /* Storing as ISO TEXT YYYY-MM-DD HH:MM:SS */ - )""") - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_promo_codes_code ON promo_codes (code);" - ) - - await db.execute(""" - CREATE TABLE IF NOT EXISTS promo_code_activations ( - activation_id INTEGER PRIMARY KEY AUTOINCREMENT, - promo_code_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - activated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */ - payment_id INTEGER, - FOREIGN KEY (promo_code_id) REFERENCES promo_codes(promo_code_id), - FOREIGN KEY (user_id) REFERENCES users(user_id), - FOREIGN KEY (payment_id) REFERENCES payments(payment_id), - UNIQUE (promo_code_id, user_id) - )""") - - await db.execute(""" - CREATE TABLE IF NOT EXISTS message_logs ( - log_id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - telegram_username TEXT, - telegram_first_name TEXT, - event_type TEXT NOT NULL, - content TEXT, - raw_update_preview TEXT, - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */ - is_admin_event INTEGER DEFAULT 0, - target_user_id INTEGER, - FOREIGN KEY (user_id) REFERENCES users(user_id) - ) - """) - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_message_logs_user_id ON message_logs (user_id);" - ) - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_message_logs_event_type ON message_logs (event_type);" - ) - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_message_logs_timestamp ON message_logs (timestamp);" - ) - - await db.execute(""" - CREATE TABLE IF NOT EXISTS panel_sync_status ( - id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), - last_sync_time TIMESTAMP, /* TEXT as ISO8601 string */ - status TEXT, - details TEXT, - users_processed_from_panel INTEGER DEFAULT 0, - subscriptions_synced INTEGER DEFAULT 0 - )""") - await db.execute( - "INSERT OR IGNORE INTO panel_sync_status (id, status, details) VALUES (1, 'never_run', 'System initialized')" - ) - - await db.commit() - logging.info("Database initialized/checked successfully.") - except Exception as e: - logging.error(f"Failed to initialize database: {e}", exc_info=True) - raise - - -async def add_user_if_not_exists( - user_id: int, - username: Optional[str], - first_name: Optional[str], - last_name: Optional[str], - lang_code: str = 'en', - referred_by_id: Optional[int] = None, - panel_user_uuid: Optional[str] = None) -> Tuple[bool, bool]: - """Adds user if not exists. Sets referred_by_id for new users. Returns (success, was_new_user_flag).""" - was_new_user = False - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - try: - cursor = await db.execute( - "SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned FROM users WHERE user_id = ?", - (user_id, )) - existing_user = await cursor.fetchone() - await cursor.close() - if existing_user: - update_fields = {} - if username is not None and username != existing_user[ - 'username']: - update_fields['username'] = username - if first_name is not None and first_name != existing_user[ - 'first_name']: - update_fields['first_name'] = first_name - if last_name is not None and last_name != existing_user[ - 'last_name']: - update_fields['last_name'] = last_name - - if panel_user_uuid and existing_user['panel_user_uuid'] is None: - update_fields['panel_user_uuid'] = panel_user_uuid - - if lang_code != existing_user['language_code']: - update_fields['language_code'] = lang_code - if update_fields: - set_clause = ", ".join( - [f"{field} = ?" for field in update_fields.keys()]) - params = list(update_fields.values()) + [user_id] - await db.execute( - f"UPDATE users SET {set_clause} WHERE user_id = ?", - tuple(params)) - else: - await db.execute( - """INSERT INTO users (user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned) VALUES (?, ?, ?, ?, ?, ?, ?, 0)""", - (user_id, username, first_name, last_name, lang_code, - referred_by_id, panel_user_uuid)) - logging.info( - f"New user {user_id} added. Referred by: {referred_by_id or 'N/A'}." - ) - was_new_user = True - await db.commit() - return True, was_new_user - except aiosqlite.IntegrityError as e: - if "UNIQUE constraint failed: users.panel_user_uuid" in str( - e) and panel_user_uuid: - res = await db.execute( - "UPDATE users SET user_id = ?, username = ?, first_name = ?, last_name = ?, language_code = COALESCE(?, language_code) WHERE panel_user_uuid = ? AND user_id IS NULL", - (user_id, username, first_name, last_name, lang_code, - panel_user_uuid)) - if res.rowcount > 0: - await db.commit() - logging.info( - f"Linked panel user {panel_user_uuid} to TG {user_id}." - ) - return True, False - else: - logging.error( - f"Conflict: Panel UUID {panel_user_uuid} exists.") - await db.rollback() - return False, False - else: - logging.error(f"DB integrity error for user {user_id}: {e}") - await db.rollback() - return False, False - except Exception as e: - logging.error( - f"DB error in add_user_if_not_exists for user {user_id}: {e}", - exc_info=True) - await db.rollback() - return False, False - - -async def get_user( - user_id: int, - db_conn: Optional[aiosqlite.Connection] = None -) -> Optional[aiosqlite.Row]: - sql = "SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE user_id = ?" - if db_conn: - cursor = await db_conn.execute(sql, (user_id, )) - row = await cursor.fetchone() - if cursor: - await cursor.close() - return row - else: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute(sql, (user_id, )) - row = await cursor.fetchone() - if cursor: - await cursor.close() - return row - - -async def get_user_by_telegram_username( - username: str, - db_conn: Optional[aiosqlite.Connection] = None -) -> Optional[aiosqlite.Row]: - sql = "SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE LOWER(username) = LOWER(?)" - clean_username = username.lstrip('@') - if db_conn: - cursor = await db_conn.execute(sql, (clean_username, )) - row = await cursor.fetchone() - if cursor: - await cursor.close() - return row - else: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute(sql, (clean_username, )) - row = await cursor.fetchone() - if cursor: - await cursor.close() - return row - - -async def get_user_by_panel_uuid( - panel_user_uuid: str, - db_conn: Optional[aiosqlite.Connection] = None -) -> Optional[aiosqlite.Row]: - sql = "SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE panel_user_uuid = ?" - if db_conn: - cursor = await db_conn.execute(sql, (panel_user_uuid, )) - row = await cursor.fetchone() - if cursor: - await cursor.close() - return row - else: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute(sql, (panel_user_uuid, )) - row = await cursor.fetchone() - if cursor: - await cursor.close() - return row - - -async def get_banned_users_list_paginated( - limit: int, offset: int) -> Tuple[List[aiosqlite.Row], int]: - sql_users = "SELECT user_id, username, first_name, last_name FROM users WHERE is_banned = 1 ORDER BY registration_date DESC LIMIT ? OFFSET ?" - sql_count = "SELECT COUNT(*) as total_banned FROM users WHERE is_banned = 1" - users_list: List[aiosqlite.Row] = [] - total_banned = 0 - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor_users = await db.execute(sql_users, (limit, offset)) - users_list = await cursor_users.fetchall() - await cursor_users.close() - cursor_count = await db.execute(sql_count) - count_row = await cursor_count.fetchone() - await cursor_count.close() - if count_row: total_banned = count_row['total_banned'] - return users_list, total_banned - - -async def get_user_active_subscription_end_date( - user_id: int, - db_conn: Optional[aiosqlite.Connection] = None) -> Optional[str]: - sql = "SELECT strftime('%Y-%m-%d', end_date) as end_date_str FROM subscriptions WHERE user_id = ? AND is_active = 1 AND DATETIME(end_date) > DATETIME('now', 'localtime') ORDER BY end_date DESC LIMIT 1" - if db_conn: - cursor = await db_conn.execute(sql, (user_id, )) - row = await cursor.fetchone() - if cursor: - await cursor.close() - return row['end_date_str'] if row else None - else: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute(sql, (user_id, )) - row = await cursor.fetchone() - if cursor: - await cursor.close() - return row['end_date_str'] if row else None - - -async def update_user_language_code(user_id: int, lang_code: str): - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - await db.execute( - "UPDATE users SET language_code = ? WHERE user_id = ?", - (lang_code, user_id)) - await db.commit() - - -async def set_user_ban_status_db(user_id: int, is_banned: bool) -> bool: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - try: - await db.execute( - "UPDATE users SET is_banned = ? WHERE user_id = ?", - (1 if is_banned else 0, user_id)) - await db.commit() - return True - except Exception as e: - logging.error(f"Error setting ban status for {user_id}: {e}") - await db.rollback() - return False - - -async def update_user_panel_uuid( - user_id: int, - panel_user_uuid: str, - db_conn: Optional[aiosqlite.Connection] = None): - - async def _operation(db_op: aiosqlite.Connection): - try: - await db_op.execute( - "UPDATE users SET panel_user_uuid = ? WHERE user_id = ?", - (panel_user_uuid, user_id)) - await db_op.commit() - except aiosqlite.IntegrityError: - logging.error( - f"Failed to update panel_uuid for user {user_id} (UNIQUE constraint)." - ) - await db_op.rollback() - except Exception as e: - logging.error( - f"Error in update_user_panel_uuid for {user_id}: {e}") - await db_op.rollback() - - if db_conn: - await _setup_db_connection(db_conn - ) if not db_conn.row_factory else None - await _operation(db_conn) - else: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - await _operation(db) - - -async def update_or_create_user_from_panel( - panel_data: Dict[str, Any], - db_conn: Optional[aiosqlite.Connection] = None) -> bool: - panel_uuid = panel_data.get('uuid') - username = panel_data.get('username') - if not panel_uuid: - logging.warning( - "update_or_create_user_from_panel: panel_uuid missing.") - return False - - async def _operation(db_op: aiosqlite.Connection): - existing_user = await get_user_by_panel_uuid(panel_uuid, db_conn=db_op) - if not existing_user: - reg_date_str = panel_data.get('createdAt', - datetime.utcnow().isoformat()) - if isinstance(reg_date_str, datetime): - reg_date_str = reg_date_str.isoformat() - await db_op.execute( - "INSERT INTO users (panel_user_uuid, username, registration_date, is_banned) VALUES (?, ?, ?, 0)", - (panel_uuid, username, reg_date_str)) - await db_op.commit() - return True - - try: - if db_conn: - await _setup_db_connection(db_conn - ) if not db_conn.row_factory else None - return await _operation(db_conn) - else: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - return await _operation(db) - except Exception as e: - logging.error( - f"Error in update_or_create_user_from_panel panel_uuid {panel_uuid}: {e}", - exc_info=True) - return False - - -async def update_or_create_subscription_from_panel( - panel_user_uuid: str, - sub_data: Dict[str, Any], - db_conn: Optional[aiosqlite.Connection] = None) -> bool: - - async def _operation(db_op: aiosqlite.Connection): - cursor = await db_op.execute( - "SELECT user_id FROM users WHERE panel_user_uuid = ?", - (panel_user_uuid, )) - user_row = await cursor.fetchone() - await cursor.close() - bot_user_id = user_row['user_id'] if user_row else None - panel_sub_link_uuid = sub_data.get('subscriptionUuid') or sub_data.get( - 'shortUuid') - if not panel_sub_link_uuid: - logging.warning( - f"No panel_subscription_uuid or shortUuid for panel user {panel_user_uuid}" - ) - return False - end_date_str = sub_data.get('expireAt') - end_date = None - if end_date_str: - try: - end_date = datetime.fromisoformat( - end_date_str.replace("Z", "+00:00")) - except ValueError: - logging.warning( - f"Bad expireAt: {end_date_str} for {panel_user_uuid}") - return False - if not end_date: - logging.warning(f"No end_date for {panel_user_uuid}") - return False - status_panel = sub_data.get('status', 'UNKNOWN').upper() - is_active_panel = 1 if status_panel == 'ACTIVE' else 0 - traffic_limit = sub_data.get('trafficLimitBytes') - traffic_used = sub_data.get('usedTrafficBytes', - sub_data.get('lifetimeUsedTrafficBytes')) - start_date_iso = sub_data.get('createdAt', - datetime.utcnow().isoformat()) - if isinstance(start_date_iso, datetime): - start_date_iso = start_date_iso.isoformat() - duration_months_val = sub_data.get('duration_months') - upsert_sql = """ INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel, traffic_limit_bytes, traffic_used_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = excluded.duration_months, is_active = excluded.is_active, status_from_panel = excluded.status_from_panel, traffic_limit_bytes = excluded.traffic_limit_bytes, traffic_used_bytes = excluded.traffic_used_bytes, last_notification_sent = NULL; """ - params = (bot_user_id, panel_user_uuid, panel_sub_link_uuid, - start_date_iso, end_date.isoformat(), duration_months_val, - is_active_panel, status_panel, traffic_limit, traffic_used) - await db_op.execute(upsert_sql, params) - await db_op.commit() - return True - - try: - if db_conn: - await _setup_db_connection(db_conn - ) if not db_conn.row_factory else None - return await _operation(db_conn) - else: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - return await _operation(db) - except Exception as e: - logging.error( - f"Error in update_or_create_subscription_from_panel for {panel_user_uuid}: {e}", - exc_info=True) - return False - - -async def update_sync_status(status: str, - details: str, - users_processed: int = 0, - subs_synced: int = 0): - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - await db.execute( - "UPDATE panel_sync_status SET last_sync_time=CURRENT_TIMESTAMP, status=?, details=?, users_processed_from_panel=?, subscriptions_synced=? WHERE id=1", - (status, details, users_processed, subs_synced)) - await db.commit() - - -async def get_last_sync_status() -> Optional[aiosqlite.Row]: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute( - "SELECT * FROM panel_sync_status WHERE id = 1") - row = await cursor.fetchone() - await cursor.close() - return row - - -async def get_all_users_for_broadcast() -> List[aiosqlite.Row]: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute( - "SELECT user_id FROM users WHERE user_id IS NOT NULL AND is_banned = 0" - ) - rows = await cursor.fetchall() - await cursor.close() - return rows - - -async def get_user_count_stats() -> Dict[str, int]: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute( - "SELECT COUNT(DISTINCT user_id) as count FROM users WHERE user_id IS NOT NULL" - ) - total_users_c_row = await cursor.fetchone() - await cursor.close() - cursor = await db.execute( - "SELECT COUNT(DISTINCT user_id) as count FROM users WHERE user_id IS NOT NULL AND is_banned = 1" - ) - banned_users_c_row = await cursor.fetchone() - await cursor.close() - cursor = await db.execute( - "SELECT COUNT(DISTINCT user_id) as count FROM subscriptions WHERE user_id IS NOT NULL AND is_active = 1 AND DATETIME(end_date) > DATETIME('now', 'localtime')" - ) - active_subs_c_row = await cursor.fetchone() - await cursor.close() - return { - "total_users": - total_users_c_row['count'] if total_users_c_row else 0, - "banned_users": - banned_users_c_row['count'] if banned_users_c_row else 0, - "users_with_active_subscriptions": - active_subs_c_row['count'] if active_subs_c_row else 0 - } - - -async def get_payment_logs(limit: int = 20, - offset: int = 0) -> List[aiosqlite.Row]: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute( - """SELECT p.payment_id, p.user_id, u.username, p.amount, p.currency, p.status, p.description, strftime('%Y-%m-%d %H:%M:%S', p.created_at) as created_at FROM payments p LEFT JOIN users u ON p.user_id = u.user_id ORDER BY p.created_at DESC LIMIT ? OFFSET ?""", - (limit, offset)) - rows = await cursor.fetchall() - await cursor.close() - return rows - - -async def log_user_action(user_id: Optional[int], - event_type: str, - content: Optional[str] = None, - telegram_username: Optional[str] = None, - telegram_first_name: Optional[str] = None, - is_admin_event: bool = False, - target_user_id: Optional[int] = None, - raw_update_preview: Optional[str] = None): - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - try: - await db.execute( - """INSERT INTO message_logs (user_id, telegram_username, telegram_first_name, event_type, content, raw_update_preview, is_admin_event, target_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", - (user_id, telegram_username, telegram_first_name, event_type, - content, raw_update_preview, 1 if is_admin_event else 0, - target_user_id)) - await db.commit() - except Exception as e: - logging.error( - f"Failed to log action for user {user_id}, type {event_type}: {e}", - exc_info=True) - - -async def get_message_logs_db(limit: int = 20, - offset: int = 0) -> List[aiosqlite.Row]: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute( - """SELECT log_id, user_id, telegram_username, telegram_first_name, event_type, content, strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str, is_admin_event FROM message_logs ORDER BY timestamp DESC LIMIT ? OFFSET ?""", - (limit, offset)) - rows = await cursor.fetchall() - await cursor.close() - return rows - - -async def count_all_message_logs() -> int: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute("SELECT COUNT(*) as count FROM message_logs") - row = await cursor.fetchone() - await cursor.close() - return row['count'] if row else 0 - - -async def get_user_message_logs_paginated(user_id_to_search: int, limit: int, - offset: int) -> List[aiosqlite.Row]: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute( - """SELECT log_id, user_id, telegram_username, telegram_first_name, event_type, content, strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str FROM message_logs WHERE user_id = ? OR target_user_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?""", - (user_id_to_search, user_id_to_search, limit, offset)) - rows = await cursor.fetchall() - await cursor.close() - return rows - - -async def count_user_message_logs(user_id_to_search: int) -> int: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute( - "SELECT COUNT(*) as count FROM message_logs WHERE user_id = ? OR target_user_id = ?", - (user_id_to_search, user_id_to_search)) - row = await cursor.fetchone() - await cursor.close() - return row['count'] if row else 0 - - -async def add_payment_record(user_id: int, - yookassa_payment_id: Optional[str], - idempotence_key: Optional[str], - amount: float, - currency: str, - status: str, - description: str, - sub_months: int, - promo_id: Optional[int] = None) -> Optional[int]: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - try: - cursor = await db.execute( - """INSERT INTO payments (user_id, yookassa_payment_id, idempotence_key, amount, currency, status, description, subscription_duration_months, promo_code_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (user_id, yookassa_payment_id, idempotence_key, amount, - currency, status, description, sub_months, promo_id)) - await db.commit() - return cursor.lastrowid - except Exception as e: - logging.error( - f"Failed to add payment record user {user_id}, yk_id {yookassa_payment_id}: {e}" - ) - await db.rollback() - return None - - -async def update_payment_status( - payment_db_id: Optional[int] = None, - yookassa_payment_id: Optional[str] = None, - new_status: Optional[str] = None, - db_conn: Optional[aiosqlite.Connection] = None): - - async def _operation(db_op: aiosqlite.Connection): - if new_status is None: return - if payment_db_id: - await db_op.execute( - "UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ?", - (new_status, payment_db_id)) - elif yookassa_payment_id: - await db_op.execute( - "UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE yookassa_payment_id = ?", - (new_status, yookassa_payment_id)) - else: - logging.warning( - "update_payment_status called without payment_id or yookassa_payment_id" - ) - return - await db_op.commit() - - if db_conn: - await _setup_db_connection(db_conn - ) if not db_conn.row_factory else None - await _operation(db_conn) - else: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - await _operation(db) - - -async def get_payment_by_yookassa_id( - yookassa_payment_id: str) -> Optional[aiosqlite.Row]: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute( - "SELECT * FROM payments WHERE yookassa_payment_id = ?", - (yookassa_payment_id, )) - row = await cursor.fetchone() - await cursor.close() - return row - - -async def create_promo_code_db( - code: str, - bonus_days: int, - max_activations: int, - admin_id: int, - valid_until_dt: Optional[datetime] = None) -> Optional[int]: - valid_until_iso: Optional[str] = valid_until_dt.isoformat( - ) if valid_until_dt else None - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - try: - cursor = await db.execute( - "INSERT INTO promo_codes (code, bonus_days, max_activations, created_by_admin_id, valid_until) VALUES (?, ?, ?, ?, ?)", - (code.upper(), bonus_days, max_activations, admin_id, - valid_until_iso)) - await db.commit() - return cursor.lastrowid - except aiosqlite.IntegrityError: - logging.warning(f"Promo code {code.upper()} exists.") - await db.rollback() - return None - except Exception as e: - logging.error(f"Error creating promo {code.upper()}: {e}") - await db.rollback() - return None - - -async def get_promo_codes_db(is_active_only: bool = True, - limit: int = 20, - offset: int = 0) -> List[aiosqlite.Row]: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - query = "SELECT promo_code_id, code, bonus_days, max_activations, current_activations, is_active, created_by_admin_id, created_at, valid_until FROM promo_codes" - params = [] - conditions = [] - if is_active_only: conditions.append("is_active = 1") - conditions.append( - "(valid_until IS NULL OR DATETIME(valid_until) > DATETIME('now', 'localtime'))" - ) - if conditions: query += " WHERE " + " AND ".join(conditions) - query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" - params.extend([limit, offset]) - cursor = await db.execute(query, tuple(params)) - rows = await cursor.fetchall() - await cursor.close() - return rows - - -async def get_promo_code_by_code( - code: str, - db_conn: Optional[aiosqlite.Connection] = None -) -> Optional[aiosqlite.Row]: - sql = "SELECT * FROM promo_codes WHERE code = ? AND is_active = 1 AND (DATETIME(valid_until) IS NULL OR DATETIME(valid_until) > DATETIME('now', 'localtime'))" - if db_conn: - cursor = await db_conn.execute(sql, (code.upper(), )) - row = await cursor.fetchone() - if cursor: - await cursor.close() - return row - else: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute(sql, (code.upper(), )) - row = await cursor.fetchone() - if cursor: - await cursor.close() - return row - - -async def increment_promo_activation(promo_code_id: int, - user_id: int, - db_conn: aiosqlite.Connection, - payment_id: Optional[int] = None) -> bool: - - db = db_conn - try: - cursor = await db.execute( - "SELECT 1 FROM promo_code_activations WHERE promo_code_id = ? AND user_id = ?", - (promo_code_id, user_id)) - existing = await cursor.fetchone() - await cursor.close() - if existing: - logging.info(f"User {user_id} already used promo {promo_code_id}.") - return False - await db.execute( - "INSERT INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)", - (promo_code_id, user_id, payment_id)) - await db.execute( - "UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?", - (promo_code_id, )) - return True - except aiosqlite.IntegrityError: - logging.warning( - f"IntegrityError on promo activation p:{promo_code_id} u:{user_id}." - ) - return False - except Exception as e: - logging.error( - f"Error promo activation p:{promo_code_id} u:{user_id}: {e}", - exc_info=True) - return False - - -async def get_all_message_logs_paginated(limit: int, - offset: int) -> List[aiosqlite.Row]: - """ - Fetches a paginated list of all message logs, ordered by the newest first. - """ - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - - sql = """ - SELECT - log_id, - user_id, - telegram_username, - telegram_first_name, - event_type, - content, - strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str, - is_admin_event, - target_user_id - FROM message_logs - ORDER BY timestamp DESC - LIMIT ? OFFSET ? - """ - try: - cursor = await db.execute(sql, (limit, offset)) - rows = await cursor.fetchall() - await cursor.close() - return rows - except Exception as e: - logging.error(f"Error fetching all message logs: {e}", - exc_info=True) - return [] - - -async def has_had_any_subscription( - user_id: int, db_conn: Optional[aiosqlite.Connection] = None) -> bool: - """Checks if a user has ever had any subscription (trial or paid).""" - sql = "SELECT 1 FROM subscriptions WHERE user_id = ? LIMIT 1" - if db_conn: - cursor = await db_conn.execute(sql, (user_id, )) - row = await cursor.fetchone() - await cursor.close() - return row is not None - else: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - cursor = await db.execute(sql, (user_id, )) - row = await cursor.fetchone() - await cursor.close() - return row is not None - - -async def sync_panel_user_data( - panel_user_dict: Dict[str, Any], - db_conn_passed: Optional[aiosqlite.Connection] = None) -> bool: - """ - Synchronizes a single panel user's data (user info and subscription) with the local bot database. - Prioritizes matching/creating bot user by panel_user_dict['telegramId']. - Manages its own transaction if db_conn_passed is None. - """ - panel_uuid = panel_user_dict.get('uuid') - telegram_id_from_panel = panel_user_dict.get('telegramId') - panel_username = panel_user_dict.get('username') - - if not panel_uuid: - logging.warning( - f"Sync: Panel user data missing 'uuid'. Data: {panel_user_dict}") - return False - if not telegram_id_from_panel: - logging.info( - f"Sync: Panel user {panel_uuid} (username: {panel_username}) has no 'telegramId'. Skipping TG ID based sync for this user." - ) - - return False - - async def _operation(db: aiosqlite.Connection): - - bot_user_id = int(telegram_id_from_panel) - - existing_bot_user = await get_user(bot_user_id, db_conn=db) - - if existing_bot_user: - - if existing_bot_user['panel_user_uuid'] != panel_uuid: - if existing_bot_user['panel_user_uuid'] is not None: - logging.warning( - f"Sync: TG User {bot_user_id} already linked to panel_uuid {existing_bot_user['panel_user_uuid']}, but panel now provides {panel_uuid} for this TG ID. Updating to new panel_uuid." - ) - - cursor_conflict = await db.execute( - "SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?", - (panel_uuid, bot_user_id)) - conflicting_user = await cursor_conflict.fetchone() - await cursor_conflict.close() - if conflicting_user: - logging.error( - f"Sync: CRITICAL CONFLICT! New panel_uuid {panel_uuid} (for TG ID {bot_user_id}) is already linked to different TG User {conflicting_user['user_id']}. Skipping user update for panel_uuid." - ) - else: - await db.execute( - "UPDATE users SET panel_user_uuid = ?, username = ? WHERE user_id = ?", - (panel_uuid, panel_username, bot_user_id)) - logging.info( - f"Sync: Updated panel_uuid for existing TG user {bot_user_id} to {panel_uuid}." - ) - else: - - if panel_username and existing_bot_user[ - 'username'] != panel_username: - await db.execute( - "UPDATE users SET username = ? WHERE user_id = ?", - (panel_username, bot_user_id)) - logging.info( - f"Sync: Updated username for TG user {bot_user_id} from panel username {panel_username}." - ) - else: - - cursor_conflict = await db.execute( - "SELECT user_id FROM users WHERE panel_user_uuid = ?", - (panel_uuid, )) - conflicting_user = await cursor_conflict.fetchone() - await cursor_conflict.close() - if conflicting_user: - logging.error( - f"Sync: CRITICAL CONFLICT! Panel UUID {panel_uuid} (for new TG ID {bot_user_id}) is already linked to existing TG User {conflicting_user['user_id']}. Skipping new user creation." - ) - return False - - reg_date_str = panel_user_dict.get( - 'createdAt', - datetime.now(timezone.utc).isoformat()) - if isinstance(reg_date_str, datetime): - reg_date_str = reg_date_str.isoformat() - await db.execute( - """INSERT INTO users (user_id, username, panel_user_uuid, registration_date, is_banned, language_code) - VALUES (?, ?, ?, ?, 0, ?)""", - (bot_user_id, panel_username, panel_uuid, reg_date_str, - settings.DEFAULT_LANGUAGE)) - logging.info( - f"Sync: New user created in bot DB from panel data: TG ID {bot_user_id}, Panel UUID {panel_uuid}, Panel Username {panel_username}" - ) - - panel_sub_link_uuid = panel_user_dict.get( - 'subscriptionUuid') or panel_user_dict.get('shortUuid') - if not panel_sub_link_uuid: - logging.warning( - f"Sync: Panel user {panel_uuid} (TG ID: {bot_user_id}) has no 'subscriptionUuid' or 'shortUuid'. Cannot sync subscription link." - ) - - await db.commit() - return True - - end_date_str = panel_user_dict.get('expireAt') - end_date_obj = None - if end_date_str: - try: - end_date_obj = datetime.fromisoformat( - end_date_str.replace("Z", "+00:00")) - except ValueError: - logging.warning( - f"Sync: Bad expireAt '{end_date_str}' for panel user {panel_uuid}. Skipping subscription update." - ) - return True - - if not end_date_obj: - logging.warning( - f"Sync: No valid end_date for panel user {panel_uuid}. Skipping subscription update." - ) - return True - - status_panel = panel_user_dict.get('status', 'UNKNOWN').upper() - is_active_panel = 1 if status_panel == 'ACTIVE' else 0 - traffic_limit = panel_user_dict.get('trafficLimitBytes') - traffic_used = panel_user_dict.get( - 'usedTrafficBytes', - panel_user_dict.get('lifetimeUsedTrafficBytes')) - - start_date_iso = panel_user_dict.get( - 'createdAt', - datetime.now(timezone.utc).isoformat()) - if isinstance(start_date_iso, datetime): - start_date_iso = start_date_iso.isoformat() - - duration_months_val = None - - await db.execute( - "UPDATE subscriptions SET is_active = 0 WHERE user_id = ? AND panel_user_uuid = ? AND is_active = 1 AND panel_subscription_uuid != ?", - (bot_user_id, panel_uuid, panel_sub_link_uuid)) - - upsert_sub_sql = """ - INSERT INTO subscriptions ( - user_id, panel_user_uuid, panel_subscription_uuid, - start_date, end_date, duration_months, - is_active, status_from_panel, traffic_limit_bytes, traffic_used_bytes, - last_notification_sent - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) - ON CONFLICT(panel_subscription_uuid) DO UPDATE SET - user_id = excluded.user_id, - panel_user_uuid = excluded.panel_user_uuid, - start_date = excluded.start_date, - end_date = excluded.end_date, - duration_months = excluded.duration_months, - is_active = excluded.is_active, - status_from_panel = excluded.status_from_panel, - traffic_limit_bytes = excluded.traffic_limit_bytes, - traffic_used_bytes = excluded.traffic_used_bytes, - last_notification_sent = NULL; - """ - sub_params = (bot_user_id, panel_uuid, panel_sub_link_uuid, - start_date_iso, end_date_obj.isoformat(), - duration_months_val, is_active_panel, status_panel, - traffic_limit, traffic_used) - await db.execute(upsert_sub_sql, sub_params) - logging.info( - f"Sync: Subscription upserted for TG ID {bot_user_id}, Panel UUID {panel_uuid}, Link ID {panel_sub_link_uuid}" - ) - - await db.commit() - return True - - if db_conn_passed: - return await _operation(db_conn_passed) - else: - async with get_db_connection_manager() as db: - await _setup_db_connection(db) - try: - return await _operation(db) - - except Exception as e: - logging.error( - f"Sync: General DB error during sync_panel_user_data for panel UUID {panel_uuid}: {e}", - exc_info=True) - await db.rollback() - return False diff --git a/db/database_setup.py b/db/database_setup.py new file mode 100644 index 0000000..0b48c3f --- /dev/null +++ b/db/database_setup.py @@ -0,0 +1,85 @@ +import logging +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import sessionmaker + +from config.settings import Settings +from .models import Base + +async_engine = None + + +def init_db_connection(settings: Settings) -> sessionmaker: + global async_engine + + if async_engine is None: + logging.info( + f"Attempting to create SQLAlchemy engine with URL: {settings.DATABASE_URL}" + ) + async_engine = create_async_engine( + settings.DATABASE_URL, + echo=False, + pool_pre_ping=True, + ) + + local_async_session_factory = async_sessionmaker( + bind=async_engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, + ) + logging.info( + f"SQLAlchemy Async Engine and SessionFactory configured for PostgreSQL." + ) + return local_async_session_factory + + +async def get_async_session(session_factory: sessionmaker) -> AsyncSession: + + if session_factory is None: + raise RuntimeError( + "AsyncSessionFactory is not provided or initialized.") + + async_session = session_factory() + try: + yield async_session + finally: + await async_session.close() + + +async def init_db(settings: Settings, session_factory: sessionmaker): + + global async_engine + if async_engine is None: + + logging.warning( + "init_db: async_engine was None, re-initializing via init_db_connection." + ) + + raise RuntimeError( + "async_engine is not initialized. Call init_db_connection and get session_factory first." + ) + + async with async_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logging.info( + "PostgreSQL database initialized/checked successfully using SQLAlchemy." + ) + + async with session_factory() as session: + from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status + try: + current_status = await get_panel_sync_status(session) + if current_status is None: + logging.info("Initializing panel_sync_status record.") + await update_panel_sync_status(session, + status="never_run", + details="System initialized", + users_processed=0, + subs_synced=0) + await session.commit() + except Exception as e_sync_init: + await session.rollback() + logging.error( + f"Failed to initialize PanelSyncStatus: {e_sync_init}", + exc_info=True) diff --git a/db/models.py b/db/models.py new file mode 100644 index 0000000..72695f3 --- /dev/null +++ b/db/models.py @@ -0,0 +1,193 @@ +from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger +from sqlalchemy.orm import relationship, DeclarativeBase +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.sql import func +from datetime import datetime + + +class Base(AsyncAttrs, DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + user_id = Column(BigInteger, primary_key=True, index=True) + username = Column(String, nullable=True, index=True) + first_name = Column(String, nullable=True) + last_name = Column(String, nullable=True) + language_code = Column(String, default="ru") + registration_date = Column(DateTime(timezone=True), + server_default=func.now()) + is_banned = Column(Boolean, default=False) + panel_user_uuid = Column(String, nullable=True, unique=True, index=True) + referred_by_id = Column(BigInteger, + ForeignKey("users.user_id"), + nullable=True) + + referrer = relationship("User", remote_side=[user_id], backref="referrals") + subscriptions = relationship("Subscription", + back_populates="user", + cascade="all, delete-orphan") + payments = relationship("Payment", + back_populates="user", + cascade="all, delete-orphan") + promo_code_activations = relationship("PromoCodeActivation", + back_populates="user", + cascade="all, delete-orphan") + message_logs_authored = relationship("MessageLog", + foreign_keys="MessageLog.user_id", + back_populates="author_user", + cascade="all, delete-orphan") + message_logs_targeted = relationship( + "MessageLog", + foreign_keys="MessageLog.target_user_id", + back_populates="target_user", + cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + +class Subscription(Base): + __tablename__ = "subscriptions" + + subscription_id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, + ForeignKey("users.user_id"), + nullable=False, + index=True) + panel_user_uuid = Column(String, nullable=False, index=True) + panel_subscription_uuid = Column(String, + unique=True, + index=True, + nullable=True) + start_date = Column(DateTime(timezone=True), nullable=True) + end_date = Column(DateTime(timezone=True), nullable=False, index=True) + duration_months = Column(Integer, nullable=True) + is_active = Column(Boolean, default=True, index=True) + status_from_panel = Column(String, nullable=True) + traffic_limit_bytes = Column(BigInteger, nullable=True) + traffic_used_bytes = Column(BigInteger, nullable=True) + last_notification_sent = Column(DateTime(timezone=True), nullable=True) + + user = relationship("User", back_populates="subscriptions") + + def __repr__(self): + return f"" + + +class Payment(Base): + __tablename__ = "payments" + + payment_id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, + ForeignKey("users.user_id"), + nullable=False, + index=True) + yookassa_payment_id = Column(String, + unique=True, + index=True, + nullable=True) + idempotence_key = Column(String, unique=True, nullable=True) + amount = Column(Float, nullable=False) + currency = Column(String, nullable=False) + status = Column(String, nullable=False, index=True) + description = Column(String, nullable=True) + subscription_duration_months = Column(Integer, nullable=True) + promo_code_id = Column(Integer, + ForeignKey("promo_codes.promo_code_id"), + nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), + onupdate=func.now(), + nullable=True) + + user = relationship("User", back_populates="payments") + promo_code_used = relationship("PromoCode", + back_populates="payments_where_used") + + +class PromoCode(Base): + __tablename__ = "promo_codes" + + promo_code_id = Column(Integer, primary_key=True, autoincrement=True) + code = Column(String, unique=True, nullable=False, index=True) + bonus_days = Column(Integer, nullable=False) + max_activations = Column(Integer, nullable=False) + current_activations = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + created_by_admin_id = Column(BigInteger, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + valid_until = Column(DateTime(timezone=True), nullable=True) + + activations = relationship("PromoCodeActivation", + back_populates="promo_code", + cascade="all, delete-orphan") + payments_where_used = relationship("Payment", + back_populates="promo_code_used") + + +class PromoCodeActivation(Base): + __tablename__ = "promo_code_activations" + + activation_id = Column(Integer, primary_key=True, autoincrement=True) + promo_code_id = Column(Integer, + ForeignKey("promo_codes.promo_code_id"), + nullable=False) + user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False) + activated_at = Column(DateTime(timezone=True), server_default=func.now()) + payment_id = Column(Integer, + ForeignKey("payments.payment_id"), + nullable=True) + + promo_code = relationship("PromoCode", back_populates="activations") + user = relationship("User", back_populates="promo_code_activations") + payment = relationship("Payment") + + __table_args__ = (UniqueConstraint('promo_code_id', + 'user_id', + name='uq_promo_user_activation'), ) + + +class MessageLog(Base): + __tablename__ = "message_logs" + + log_id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, + ForeignKey("users.user_id"), + nullable=True, + index=True) + telegram_username = Column(String, nullable=True) + telegram_first_name = Column(String, nullable=True) + event_type = Column(String, nullable=False, index=True) + content = Column(Text, nullable=True) + raw_update_preview = Column(Text, nullable=True) + timestamp = Column(DateTime(timezone=True), + server_default=func.now(), + index=True) + is_admin_event = Column(Boolean, default=False) + target_user_id = Column(BigInteger, + ForeignKey("users.user_id"), + nullable=True, + index=True) + + author_user = relationship("User", + foreign_keys=[user_id], + back_populates="message_logs_authored") + target_user = relationship("User", + foreign_keys=[target_user_id], + back_populates="message_logs_targeted") + + +class PanelSyncStatus(Base): + __tablename__ = "panel_sync_status" + + id = Column(Integer, primary_key=True, default=1, autoincrement=False) + last_sync_time = Column(DateTime(timezone=True), nullable=True) + status = Column(String, nullable=True) + details = Column(Text, nullable=True) + users_processed_from_panel = Column(Integer, default=0) + subscriptions_synced = Column(Integer, default=0) + + __table_args__ = (UniqueConstraint('id'), ) diff --git a/locales/ru.json b/locales/ru.json index 2b32121..e36b1c3 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1,188 +1,205 @@ { "welcome": "Добро пожаловать, {user_name}!", - "choose_language": "Пожалуйста, выберите ваш язык:", - "language_set": "Язык установлен на Русский.", - "language_set_alert": "Язык обновлен!", - "main_menu_greeting": "Привет, {user_name}!\nЧто бы вы хотели сделать?", - "error_service_unavailable": "⚠️ Сервис временно недоступен. Попробуйте позже.", - "error_occurred_processing_request": "⚠️ Произошла ошибка. Попробуйте еще раз.", - "unknown_command_private": "Неизвестная команда. Используйте /start.", - "error_try_again": "Произошла ошибка. Попробуйте снова.", + "main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?", - "menu_subscribe_inline": "🚀 Купить", - "menu_my_subscription_inline": "📆 Моя подписка", - "menu_referral_inline": "🎁 Рефералы", - "menu_apply_promo_button": "🎁 Промокод", "menu_activate_trial_button": "🆓 Пробный период", + "menu_subscribe_inline": "🚀 Купить", + "menu_my_subscription_inline": "🔐 Моя подписка", + "menu_referral_inline": "🎁 Рефералы", + "menu_apply_promo_button": "🎟 Промокод", "menu_language_settings_inline": "🌐 Язык", "menu_server_status_button": "📊 Статус", "menu_support_button": "💬 Поддержка", "menu_terms_button": "📄 Условия сервиса", "back_to_main_menu_button": "⬅️ Назад", + + "choose_language": "Выберите язык / Select language:", + "language_set_alert": "Язык изменен!", + "error_occurred_try_again": "Произошла ошибка, попробуйте снова.", + "error_displaying_menu": "Ошибка отображения меню.", + "main_menu_unknown_action": "Неизвестное действие.", - "no_subscription_options_available": "Варианты подписки сейчас недоступны.", - "select_subscription_period": "Выберите период подписки:", + "select_subscription_period": "Выберите срок подписки:", + "no_subscription_options_available": "В данный момент нет доступных вариантов подписки.", "subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}", - "confirm_subscription_prompt": "Подписка на {months} мес. за {price} {currency_symbol}.\n\nПерейти к оплате?", + "confirm_subscription_prompt": "Подтвердите покупку подписки:\nСрок: {months} мес.\nЦена: {price} {currency_symbol}", + "pay_button": "💳 Оплатить", "confirm_payment_button": "✅ Да ({price} {currency_symbol})", "cancel_button": "❌ Отмена", - "payment_service_unavailable": "⚠️ Платежный сервис недоступен. Попробуйте позже.", - "error_creating_payment_record": "⚠️ Ошибка подготовки платежа. Попробуйте снова.", + "payment_description_subscription": "Оплата подписки на {months} мес.", + "payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.", + "payment_service_unavailable_alert": "Платежный сервис недоступен", + "error_creating_payment_record": "Ошибка при создании записи о платеже. Попробуйте позже.", + "error_payment_gateway_link_failed": "Не удалось получить ссылку на оплату. Пожалуйста, свяжитесь с поддержкой.", "payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:", - "pay_button": "💳 Оплатить", - "error_payment_gateway": "⚠️ Ошибка платежного шлюза. Попробуйте или напишите в поддержку.", - "payment_successful": "✅ Оплата успешна! Ваша подписка на {months} мес. активна до {end_date}.", - "payment_failed": "❌ Оплата не удалась/отменена. Если деньги списаны, напишите в поддержку.", - "payment_description_subscription": "Подписка на {months} мес.", - "my_subscription_details": "ℹ️ Ваша подписка\n\n🗓️ До: {end_date} (Дней осталось: {days_left})\n\n🔗 Ссылка: {config_link}\n\n📊 Трафик: {traffic_used} / {traffic_limit}\n💡 Статус: {status}", - "subscription_not_active": "Активной подписки нет. Оформите сейчас!", - "config_link_not_available": "Н/Д (Попробуйте /sync или поддержка)", - "traffic_unlimited": "Безлимит", - "traffic_na": "Н/Д", + "error_payment_gateway": "Ошибка платежного шлюза. Попробуйте позже или свяжитесь с поддержкой.", + "payment_successful": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.", + "payment_successful_with_promo": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (с учетом промокода на +{bonus_days} дней) активна до {end_date}.", + "payment_successful_with_referral_bonus": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.", + "payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.", + "payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.", + "error_processing_your_payment": "Произошла ошибка при обработке вашего платежа после его успеха. Пожалуйста, свяжитесь с поддержкой, указав детали.", + + "my_subscription_details": "🔐 Моя подписка:\n\nСтатус: {status}\nАктивна до: {end_date} (осталось дней: {days_left})\n\nТрафик: {traffic_used} из {traffic_limit}\n\nСсылка на конфигурацию:\n{config_link}", + "config_link_not_available": "недоступна, обратитесь в поддержку", + "subscription_not_active": "У вас нет активной подписки. \nХотите приобрести?", "status_active": "Активна", "status_expired": "Истекла", "status_disabled": "Отключена", - "status_limited": "Ограничена", - "status_unknown": "Неизвестен", - "subscription_ending_soon_notification": "{user_name},\nВаша подписка истекает {end_date} (дней осталось: {days_left}).\nНе забудьте продлить!", - "payment_successful_with_referral_bonus": "✅ Оплата на {months} мес. успешна! Базовая дата: {base_end_date}.\n\n🎉 Вас пригласил(а) {inviter_name}, вы получили дополнительно {bonus_days} бонусных дней!\nИтоговая дата окончания: {final_end_date}.", - "payment_successful_error_details": "Оплата прошла успешно, но возникла ошибка отображения деталей подписки. Пожалуйста, проверьте раздел 'Моя подписка' или свяжитесь с поддержкой.", - "error_processing_your_payment": "Ошибка обработки платежа. Обратитесь в поддержку, если проблема не решится.", - - "trial_confirm_prompt": "Активировать бесплатный пробный период на {days} дней с лимитом трафика {traffic_gb} ГБ?\n\nЭто можно сделать только один раз, если у вас ранее не было подписок.", - "trial_confirm_activate_button": "✅ Активировать!", - "trial_already_had_subscription_or_trial": "Пробный период доступен только если у вас ранее не было никаких подписок (включая пробные).", - "trial_activation_failed": "⚠️ К сожалению, не удалось активировать ваш пробный период. Пожалуйста, свяжитесь с поддержкой.", - "trial_feature_disabled": "Пробные периоды в данный момент недоступны.", - "trial_cancelled": "Активация пробного периода отменена.", - "trial_activated_alert": "✅ Пробный период активирован!", - "trial_activated_details_message": "🎉 Ваш бесплатный пробный период на {days} дней активирован!\nДействителен до: {end_date}.\nСсылка на конфигурацию: {config_link}\nТрафик: {traffic_gb}\nПриятного использования!", + "traffic_unlimited": "Безлимитный", + "traffic_na": "н/д", + "error_service_unavailable": "Сервис временно недоступен. Пожалуйста, попробуйте позже.", + + "promo_code_prompt": "Пожалуйста, введите ваш промокод:", + "promo_code_not_found": "Промокод {code} не найден, истек или уже использован максимальное количество раз.", + "promo_code_already_used_by_user": "Вы уже активировали промокод {code}.", + "promo_code_no_active_subscription": "Для активации этого промокода у вас должна быть активная подписка.", + "promo_code_applied_success": "✅ Промокод {code} успешно применен!\nВаша подписка продлена на {bonus_days} дней и теперь активна до {new_end_date}.", + "error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.", + "promo_input_cancelled_short": "Ввод промокода отменен.", + + "trial_feature_disabled": "Пробный период в данный момент недоступен.", + "trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.", + "trial_confirm_prompt": "Хотите активировать бесплатный пробный период на {days} дней с лимитом трафика {traffic_gb}?", + "trial_activated_alert": "Пробный период активирован!", + "trial_activated_details_message": "🚀 Ваш пробный период на {days} дней активирован!\n\nДействует до: {end_date}\nТрафик: {traffic_gb}\n\nВаша ссылка на конфигурацию:\n{config_link}", + "trial_activation_failed": "Не удалось активировать пробный период. Пожалуйста, попробуйте позже.", + "trial_activation_failed_panel_link": "Не удалось связать ваш аккаунт с сервером для активации пробного периода. Попробуйте позже.", + "trial_activation_failed_db": "Ошибка базы данных при активации пробного периода. Попробуйте позже.", + "trial_activation_failed_panel_update": "Не удалось обновить детали на сервере для пробного периода. Попробуйте позже.", + "user_not_found_for_trial": "Ваш аккаунт не найден в системе. Пожалуйста, сначала выполните команду /start.", + "yes_button": "Да", + "no_button": "Нет", + "trial_cancelled_short": "Активация отменена.", "referral_program_info_new": "Приглашайте друзей и получайте бонусы!\n\nВаша реф. ссылка:\n{referral_link}\n\nБонусы:{bonus_details}", "referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: {inviter_bonus_days} дн.\n ➢ Друг: {referee_bonus_days} дн.", "no_bonus_days": "0", "referral_no_bonuses_configured": "\nРеферальные бонусы не настроены.", "referral_link_for_copying_reminder": "Ссылка выше. Нажмите и удерживайте для копирования.", - "referral_bonus_inviter_notification_extended": "🎉 Ура, {referee_name} (ваш реферал) подписался!\nВам начислено {days} бонусных дней.\nНовая дата окончания: {new_end_date}.", "friend_placeholder": "друг", - - "error_user_not_synced_for_config": "Аккаунт не синхронизирован. Попробуйте /sync или поддержка.", - "error_fetching_config_link_details": "Ошибка получения данных для ссылки. Попробуйте или поддержка.", - "error_fetching_config_link_details_no_uuid": "ID для ссылки не найден. Поддержка.", - "error_generating_config_link": "Не удалось создать ссылку. Попробуйте позже.", + "referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.", + "referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.", - "promo_code_prompt": "Введите ваш промокод:", - "promo_code_invalid_or_expired": "❌ Неверный, истекший или уже использованный промокод.", - "promo_code_no_active_subscription": "❌ Для применения этого промокода у вас должна быть активная подписка.", - "promo_code_applied_success": "✅ Промокод '{code}' применен! {bonus_days} бонусных дней добавлено к вашей подписке.\nТеперь ваша подписка активна до: {new_end_date}.", - "promo_code_max_activations_reached": "❌ К сожалению, промокод '{code}' достиг максимального числа активаций.", - "promo_code_not_found": "❌ Промокод '{code}' не найден.", - "error_applying_promo_bonus": "⚠️ Произошла ошибка при применении промо-бонуса. Попробуйте или поддержка.", - "promo_input_cancelled_no_message": "Ввод промокода отменен.", + "user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.", - "user_is_banned": "⚠️ Ваш доступ к боту заблокирован.\nЕсли это ошибка, обратитесь в поддержку.", - "contact_support_button": "💬 Поддержка", - "suspicious_input_ban_message": "⚠️ Обнаружен подозрительный ввод. В целях безопасности ваш доступ к боту ограничен. Обратитесь в поддержку, если это ошибка.", - - "admin_panel_title": "👑 Админ-панель", - "admin_panel_button_reply": "👑 Админ-панель", - "back_to_admin_panel_button": "⬅️ В админку", - "admin_unknown_action": "Неизвестное действие.", - "admin_action_cancelled_default": "Действие отменено.", - "prev_page_button": "Пред.", - "next_page_button": "След.", - "yes_button": "✅ Да", - "no_button": "❌ Нет", - - "admin_ban_user_button": "🚫 Бан", - "admin_unban_user_button": "✅ Разбан", - "admin_view_banned_users_button": "📜 Бан-лист", - "admin_ban_user_prompt": "ID или @username для бана:", - "admin_unban_user_prompt": "ID или @username для разбана:", - "admin_invalid_user_id_format": "Неверный ID. Нужно число.", - "admin_invalid_username_format": "Неверный username. Формат: @юзернейм (5-32 симв.).", - "admin_user_not_found_by_username": "Юзер @{username} не найден в боте.", - "admin_cannot_ban_self_or_admin": "Нельзя банить себя или админа.", - "admin_user_not_found_in_bot_db": "User ID {user_id} не найден.", - "admin_user_banned_success_panel_too": "✅ Юзер {user_id_or_username} забанен (БД и панель).", - "admin_user_banned_local_panel_fail": "⚠️ Юзер {user_id_or_username} забанен в БД, ошибка панели.", - "admin_user_banned_local_no_panel_uuid": "✅ Юзер {user_id_or_username} забанен в БД (нет ID панели).", - "admin_user_unbanned_success_panel_too": "✅ Юзер {user_id_or_username} разбанен (БД и панель).", - "admin_user_unbanned_local_panel_fail": "⚠️ Юзер {user_id_or_username} разбанен в БД, ошибка панели.", - "admin_user_unbanned_local_no_panel_uuid": "✅ Юзер {user_id_or_username} разбанен в БД (нет ID панели).", - "admin_user_already_banned": "Пользователь {user_id_or_username} уже забанен.", - "admin_user_not_banned": "Пользователь {user_id_or_username} не забанен.", - "admin_confirm_unban_prompt": "Вы уверены, что хотите разбанить пользователя {user_display} (ID: {user_id})?", - "admin_confirm_ban_prompt": "Вы уверены, что хотите забанить пользователя {user_display} (ID: {user_id})?", - "admin_user_unbanned_from_card": "✅ Пользователь {user_display} (ID: {user_id}) был разбанен.", - "admin_user_banned_from_card": "✅ Пользователь {user_display} (ID: {user_id}) был забанен.", - "admin_banned_list_title": "🚫 Список забаненных (Стр. {current_page}/{total_pages})", - "admin_no_banned_users": "В настоящее время нет забаненных пользователей.", - "admin_user_card_title": "👤 Карточка: {user_display}", - "user_card_info": "TG ID: {user_id}\nUsername: @{username}\nИмя: {first_name} {last_name}\nЯзык: {language_code}\nPanel UUID: {panel_user_uuid}\nСтатус: {ban_status}\nРегистрация: {reg_date}\nПодписка до: {sub_end_date}", - "user_card_banned": "🔴 ЗАБАНЕН", - "user_card_active": "🟢 Активен", - "user_card_sub_na": "Н/Д", - "user_card_unban_button": "✅ Разбанить", - "user_card_ban_button": "🚫 Забанить", - "user_card_back_to_banned_list_button": "⬅️ К списку", - - "admin_broadcast_button": "📢 Рассылка", - "admin_broadcast_enter_message": "Сообщение для рассылки (HTML):", - "admin_broadcast_confirm_prompt": "Превью:\n\n---\n{message_preview}\n---\n\nОтправить всем?", - "confirm_broadcast_send_button": "✅ Отправить", - "cancel_broadcast_button": "❌ Отмена", - "admin_broadcast_sending_started": "Рассылка запущена...", - "admin_broadcast_error_no_message": "Ошибка: нет сообщения для рассылки.", - "admin_broadcast_finished_stats": "📢 Рассылка завершена.\nОтправлено: {sent_count}\nОшибок: {failed_count}", - "admin_broadcast_cancelled": "Рассылка отменена.", - - "admin_create_promo_button": "🎁 Новое промо", - "admin_view_promos_button": "📜 Промокоды", - "admin_promo_create_prompt": "Промо: КОД ДНИ МАКС [СРОК_ДН]\nНапример: LETO24 7 100 30 (действ. 30дн)\nНапример: NEWBIE 5 500 (бессроч.)", - "admin_promo_invalid_format": "Формат: КОД ДНИ ИСП [СРОК_ДН]", - "admin_promo_invalid_values": "Неверные значения. Дни/исп. > 0. Ошибка: {error}", - "admin_promo_created_success": "✅ Промо '{code}': {bonus_days}дн, {max_activations} исп, {valid_until_str}.", - "admin_promo_valid_until_display": "До: {date}", - "admin_promo_valid_indefinitely": "Бессрочно", - "admin_promo_creation_failed": "❌ Ошибка создания промо (существует или БД).", - "admin_no_active_promos": "Активных промокодов нет.", - "admin_active_promos_list_header": "📜 Активные промокоды:", - "admin_promo_list_item": "- {code}: {bonus}дн, {current}/{max} исп, До: {valid_until}", - + "admin_panel_title": "Панель администратора", "admin_stats_button": "📊 Статистика", - "admin_stats_header": "📊 Статистика бота:", - "admin_stats_users": "\n👥 Пользователи:\n Всего: {total_users}\n Бан: {banned_users}\n Активные подписки (БД): {active_subs}", - "admin_stats_recent_payments_header": "\n💳 Недавние платежи (БД бота):", - "admin_stats_payment_item": " {status_emoji} {amount} {currency} от {user_info} ({p_status}) {p_date}", - "admin_stats_no_payments_found": "\nПлатежей в БД бота нет.", - "admin_stats_last_sync_header": "\n🔄 Синхр. с панелью:", + "admin_broadcast_button": "📢 Рассылка", + "admin_create_promo_button": "🎁 Создать промо", + "admin_view_promos_button": "👀 Спиок промо", + "admin_ban_user_button": "🚫 Забанить", + "admin_unban_user_button": "✅ Разбанить", + "admin_view_banned_users_button": "📜 Бан-лист", + "admin_view_logs_menu_button": "📄 Логи", + "admin_sync_panel_button": "🔄 Синхронизация", + "admin_unknown_action": "Неизвестное действие администратора.", + "admin_action_cancelled_default": "Действие отменено. Возврат в меню.", + "admin_action_cancelled_default_alert": "Действие отменено", + "back_to_admin_panel_button": "⬅️ В админку", + + "admin_stats_header": "📊 Статистика Бота", + "admin_stats_users": "👥 Пользователи: Всего - {total_users}, Забанено - {banned_users}, С активной подпиской - {active_subs}", + "admin_stats_recent_payments_header": "Последние платежи:", + "admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]", + "admin_stats_no_payments_found": "Платежей пока нет.", + "admin_stats_last_sync_header": "Последняя синхронизация с панелью:", "admin_stats_sync_time": "Время", "admin_stats_sync_status": "Статус", - "admin_stats_sync_users_processed": "Обработано с панели", - "admin_stats_sync_subs_synced": "Подписок синхр.", + "admin_stats_sync_users_processed": "Обработано юзеров с панели", + "admin_stats_sync_subs_synced": "Синхронизировано подписок", "admin_stats_sync_details_label": "Детали", - "admin_sync_status_never_run": "Синхронизация не проводилась.", + "admin_sync_status_never_run": "Синхронизация с панелью еще не проводилась.", - "admin_view_logs_menu_button": "📜 Логи", - "admin_logs_menu_title": "📜 Логи Активности", - "admin_view_all_logs_button": "Все логи", - "admin_view_user_logs_prompt_button": "Логи юзера", - "admin_prompt_for_user_id_or_username_logs": "ID или @username для логов:", - "admin_all_logs_title": "📜 Активность (Стр. {current_page}/{total_pages})", - "admin_user_logs_title": "📜 Активность {user_display} (Стр. {current_page}/{total_pages})", - "admin_no_logs_found": "Логи не найдены.", - "admin_log_entry_format": "[{timestamp_str}] Юзер: {user_display} (ID: {user_id})\nТип: {event_type}\nИнфо: {content_preview}", - "admin_log_user_not_found": "Юзер '{input}' не найден для логов.", - "system_or_unknown_user": "Система/Неизв.", - - "admin_sync_panel_button": "🔄 Синхр.", - "sync_started": "Синхронизация с панелью начата...", - "sync_completed": "Синхронизация с панелью завершена.\nСтатус: {status}\nДетали: {details}", - "sync_failed": "Синхронизация с панелью не удалась.\nДетали: {details}", - "admin_sync_initiated_from_panel": "Синхронизация с панелью запущена!", - "error_cannot_initiate_sync_no_message": "Ошибка: не могу начать синхронизацию без контекста сообщения.", + "admin_broadcast_enter_message": "Введите сообщение для рассылки (HTML поддерживается):", + "admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение (первые 200 символов):\n\n{message_preview}\n\nПодтверждаете отправку?", + "confirm_broadcast_send_button": "✅ Отправить", + "cancel_broadcast_button": "❌ Отмена", + "admin_broadcast_sending_started": "Начинаю рассылку...", + "admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.", + "admin_broadcast_error_no_message_alert": "Сообщение для рассылки пустое!", + "admin_broadcast_finished_stats": "🏁 Рассылка завершена!\nОтправлено: {sent_count}\nНе удалось отправить: {failed_count}", + "admin_broadcast_cancelled": "Рассылка отменена.", + "admin_broadcast_cancelled_alert": "Рассылка отменена!", + "admin_broadcast_cancelled_nav_back": "Рассылка отменена. Вы возвращены в админ-панель.", - "admin_suspicious_promo_attempt_notification": "⚠️ Подозрительный ввод промокода! ⚠️\n\nПользователь: {user_first_name} (@{user_username}, ID: {user_id})\nВвод:
{promo_code_input}
", - "admin_suspicious_promo_attempt_notification_no_username": "⚠️ Подозрительный ввод промокода! ⚠️\n\nПользователь: {user_first_name} (ID: {user_id})\nВвод:
{promo_code_input}
" + "admin_promo_create_prompt": "Введите детали промокода в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК_ДЕЙСТВИЯ_В_ДНЯХ_ОТ_СЕЙЧАС]\nПример: {example_format}\n(Срок действия необязателен, по умолчанию - бессрочный)", + "admin_promo_invalid_format": "Неверный формат ввода. Пожалуйста, используйте: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [ДНИ_ДЕЙСТВИЯ]", + "admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.", + "admin_promo_invalid_bonus_or_activations": "Количество бонусных дней и максимальных активаций должны быть положительными числами.", + "admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.", + "admin_promo_invalid_values": "Неверные значения. {error}", + "admin_promo_invalid_format_general": "Ошибка парсинга деталей промокода. Проверьте формат.", + "admin_promo_created_success": "✅ Промокод {code} успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}", + "admin_promo_creation_failed_duplicate": "❌ Ошибка: Промокод {code} уже существует.", + "admin_promo_creation_failed": "❌ Не удалось создать промокод. Пожалуйста, попробуйте позже.", + "admin_active_promos_list_header": "Активные промокоды:", + "admin_no_active_promos": "Нет активных промокодов.", + "admin_promo_list_item": "{code}: +{bonus}дн, {current}/{max} акт., до {valid_until}", + "admin_promo_valid_indefinitely": "бессрочно", + "admin_promo_valid_until_display": "до {date}", + + "admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:", + "admin_user_not_found_in_bot_db": "Пользователь {user_id} не найден в базе данных бота.", + "admin_cannot_ban_self_or_admin": "Вы не можете заблокировать себя или другого администратора.", + "admin_user_already_banned": "Пользователь {user_id_or_username} уже заблокирован.", + "admin_user_banned_success_combined": "✅ Пользователь {user_id_or_username} успешно заблокирован в боте {panel_status_part}.", + "admin_panel_ban_success_part": "и на панели", + "admin_panel_ban_fail_part": ", но не удалось заблокировать на панели", + "admin_user_ban_failed_local_db_error": "❌ Не удалось заблокировать пользователя в локальной БД.", + "admin_unban_user_prompt": "Введите ID или @username пользователя для разблокировки:", + "admin_user_not_banned": "Пользователь {user_id_or_username} не заблокирован.", + "admin_user_unbanned_success_combined": "✅ Пользователь {user_id_or_username} успешно разблокирован в боте {panel_status_part}.", + "admin_panel_unban_success_part": "и на панели", + "admin_panel_unban_fail_part": ", но не удалось разблокировать на панели", + "admin_user_unban_failed_local_db_error": "❌ Не удалось разблокировать пользователя в локальной БД.", + "admin_no_banned_users": "В данный момент нет заблокированных пользователей.", + "admin_banned_list_title": "Список забаненных (стр. {current_page}/{total_pages}):", + "admin_banned_user_button_text": "{user_display} (ID: {user_id})", + "prev_page_button": "⬅️ Пред.", + "next_page_button": "След. ➡️", + "admin_user_card_title": "Карточка пользователя: {user_display}", + "user_card_info": "ID: {user_id}\nUsername: @{username}\nИмя: {first_name} {last_name}\nЯзык: {language_code}\nPanel UUID: {panel_user_uuid}\nСтатус: {ban_status}\nРегистрация: {reg_date}\nПодписка до: {sub_end_date}", + "user_card_banned": "ЗАБЛОКИРОВАН", + "user_card_active": "Активен", + "user_card_sub_na": "н/д", + "user_card_ban_button": "🚫 Заблокировать", + "user_card_unban_button": "✅ Разблокировать", + "user_card_back_to_banned_list": "⬅️ К списку забаненных", + "admin_confirm_action_title": "Подтверждение: {action_text}", + "ban_verb_l": "блокировка", + "unban_verb_l": "разблокировка", + "admin_confirm_ban_prompt": "Вы уверены, что хотите заблокировать пользователя {user_display} (ID: {user_id})?", + "admin_confirm_unban_prompt": "Вы уверены, что хотите разблокировать пользователя {user_display} (ID: {user_id})?", + "admin_user_banned_from_card_alert": "Пользователь {user_display} (ID: {user_id}) заблокирован.", + "admin_user_unbanned_from_card_alert": "Пользователь {user_display} (ID: {user_id}) разблокирован.", + "admin_user_ban_failed_db_error": "Ошибка блокировки пользователя в БД.", + "admin_user_unban_failed_db_error": "Ошибка разблокировки пользователя в БД.", + "admin_panel_status_update_fail_part": ", но статус на панели не обновлен", + + "admin_logs_menu_title": "Меню логов:", + "admin_view_all_logs_button": "📜 Все логи сообщений", + "admin_view_user_logs_prompt_button": "👤 Логи пользователя", + "admin_all_logs_title": "Все логи (стр. {current_page}/{total_pages}):", + "admin_no_logs_found": "Логи не найдены.", + "admin_log_entry_format": "{timestamp_str} - {user_display} (ID: {user_id})\n {event_type}: {content_preview}", + "system_or_unknown_user": "Система/Неизв.", + "admin_prompt_for_user_id_or_username_logs": "Введите ID или @username пользователя для просмотра его логов:", + "admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.", + "admin_user_logs_title": "Логи пользователя {user_display} (стр. {current_page}/{total_pages}):", + + "sync_started": "🔄 Начинаю синхронизацию данных с панелью...", + "sync_failed": "❌ Ошибка синхронизации с панелью. Детали: {details}", + "sync_completed": "✅ Синхронизация с панелью завершена. Статус: {status}. Детали: {details}", + "sync_completed_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.", + "sync_completed_with_errors_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.\nОшибок: {errors_count}.\n\nПервые ошибки:\n{error_details_preview}", + "no_errors_placeholder": "нет", + "admin_sync_initiated_from_panel": "Синхронизация запущена...", + "error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.", + "error_displaying_statistics": "Ошибка отображения статистики.", + "stub_page_display": "Страница", + + "subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.", + + "error_unknown": "Произошла неизвестная ошибка." } diff --git a/main.py b/main.py index 7be473a..c2744ed 100644 --- a/main.py +++ b/main.py @@ -2,20 +2,24 @@ import asyncio import logging import sys -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 db.database_setup import init_db, init_db_connection async def main(): load_dotenv() settings = get_settings() - await init_db() + session_factory = init_db_connection(settings) + if not session_factory: + logging.critical( + "Failed to initialize DB connection and session factory. Exiting.") + return + + await init_db(settings, session_factory) await run_bot(settings) @@ -29,3 +33,7 @@ if __name__ == "__main__": asyncio.run(main()) except (KeyboardInterrupt, SystemExit): logging.info("Bot stopped manually") + except Exception as e_global: + logging.critical(f"Global unhandled exception in main: {e_global}", + exc_info=True) + sys.exit(1) diff --git a/requirements.txt b/requirements.txt index 47548ca..9e4d123 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,11 @@ -aiogram==3.7.0 +aiogram==3.20.0 python-dotenv==1.0.1 -aiosqlite==0.20.0 aiohttp==3.9.5 pydantic==2.7.1 -# Babel==2.15.0 # Using simple JSON i18n for now, uncomment if switching yookassa==3.5.0 pycountry==23.12.11 apscheduler==3.10.4 -pydantic_settings \ No newline at end of file +pydantic_settings +sqlalchemy[asyncio]==2.0.29 +asyncpg==0.29.0 +alembic==1.13.1 \ No newline at end of file