diff --git a/.env.example b/.env.example index c2e417c..9d7cc33 100644 --- a/.env.example +++ b/.env.example @@ -36,8 +36,11 @@ CRYPTOPAY_TOKEN= CRYPTOPAY_NETWORK=mainnet CRYPTOPAY_CURRENCY_TYPE=fiat CRYPTOPAY_ASSET=RUB -# API key for verifying Tribute webhook signatures -TRIBUTE_API_KEY= + +# Tribute Payment Gateway Configuration +TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures +TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments +TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments # Payment Method Toggles YOOKASSA_ENABLED=True @@ -107,3 +110,18 @@ WEB_SERVER_PORT=8080 # Admin Panel Log Pagination LOGS_PAGE_SIZE=10 + +# Admin Logging Configuration +LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications +LOG_THREAD_ID= # Optional: Thread ID for supergroup messages +LOG_NEW_USERS=True +LOG_PAYMENTS=True +LOG_PROMO_ACTIVATIONS=True +LOG_TRIAL_ACTIVATIONS=True +LOG_SUSPICIOUS_ACTIVITY=True + +# Inline Mode Thumbnails +INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png +INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png +INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png +INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png diff --git a/.github/workflows/dev-docker-build.yml b/.github/workflows/dev-docker-build.yml new file mode 100644 index 0000000..96a0358 --- /dev/null +++ b/.github/workflows/dev-docker-build.yml @@ -0,0 +1,59 @@ +name: Build and Push Dev Docker Image + +on: + push: + branches: + - dev + pull_request: + branches: + - dev + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix={{branch}}- + flavor: | + latest=false + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Image digest + run: echo ${{ steps.meta.outputs.digest }} \ No newline at end of file diff --git a/bot/handlers/admin/__init__.py b/bot/handlers/admin/__init__.py index ddfe2bb..2444ab2 100644 --- a/bot/handlers/admin/__init__.py +++ b/bot/handlers/admin/__init__.py @@ -2,7 +2,7 @@ from aiogram import Router from . import common from . import broadcast -from . import promo_codes +from .promo import promo_router_aggregate from . import user_management from . import statistics from . import sync_admin @@ -12,7 +12,7 @@ admin_router_aggregate = Router(name="admin_features_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(promo_router_aggregate) admin_router_aggregate.include_router(user_management.router) admin_router_aggregate.include_router(statistics.router) admin_router_aggregate.include_router(sync_admin.router) diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index e23df9d..e051dd6 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -6,13 +6,19 @@ 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.keyboards.inline.admin_keyboards import ( + get_admin_panel_keyboard, get_stats_monitoring_keyboard, + get_user_management_keyboard, get_ban_management_keyboard, + get_promo_marketing_keyboard, get_system_functions_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 +from .promo import create as admin_promo_create_handlers +from .promo import manage as admin_promo_manage_handlers +from .promo import bulk as admin_promo_bulk_handlers from . import user_management as admin_user_mgmnt_handlers from . import statistics as admin_stats_handlers from . import sync_admin as admin_sync_handlers @@ -73,13 +79,16 @@ async def admin_panel_actions_callback_handler( await admin_broadcast_handlers.broadcast_message_prompt_handler( callback, state, i18n_data, settings, session) elif action == "create_promo": - await admin_promo_handlers.create_promo_prompt_handler( + await admin_promo_create_handlers.create_promo_prompt_handler( + callback, state, i18n_data, settings, session) + elif action == "create_bulk_promo": + await admin_promo_bulk_handlers.create_bulk_promo_prompt_handler( callback, state, i18n_data, settings, session) elif action == "manage_promos": - await admin_promo_handlers.manage_promo_codes_handler( + await admin_promo_manage_handlers.manage_promo_codes_handler( callback, i18n_data, settings, session) elif action == "view_promos": - await admin_promo_handlers.view_promo_codes_handler( + await admin_promo_manage_handlers.view_promo_codes_handler( callback, i18n_data, settings, session) elif action == "ban_user_prompt": await admin_user_mgmnt_handlers.ban_user_prompt_handler( @@ -87,6 +96,10 @@ async def admin_panel_actions_callback_handler( elif action == "unban_user_prompt": await admin_user_mgmnt_handlers.unban_user_prompt_handler( callback, state, i18n_data, settings, session) + elif action == "users_management": + from . import user_management as admin_user_management_handlers + await admin_user_management_handlers.user_management_menu_handler( + callback, state, i18n_data, settings, session) elif action == "view_banned": await admin_user_mgmnt_handlers.view_banned_users_handler( @@ -94,6 +107,9 @@ async def admin_panel_actions_callback_handler( elif action == "view_logs_menu": await admin_logs_handlers.display_logs_menu(callback, i18n_data, settings, session) + elif action == "promo_management": + await admin_promo_manage_handlers.promo_management_handler( + callback, i18n_data, settings, session) elif action == "sync_panel": await admin_sync_handlers.sync_command_handler( @@ -121,3 +137,58 @@ async def admin_panel_actions_callback_handler( f"Unknown admin_action received: {action} from callback {callback.data}" ) await callback.answer(_("admin_unknown_action"), show_alert=True) + + +@router.callback_query(F.data.startswith("admin_section:")) +async def admin_section_handler(callback: types.CallbackQuery, state: FSMContext, + settings: Settings, i18n_data: dict, session: AsyncSession): + section = callback.data.split(":")[1] + 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) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + if not callback.message: + await callback.answer("Error: message context lost.", show_alert=True) + return + + try: + if section == "stats_monitoring": + await callback.message.edit_text( + _("admin_stats_and_monitoring_section"), + reply_markup=get_stats_monitoring_keyboard(i18n, current_lang) + ) + elif section == "user_management": + await callback.message.edit_text( + _("admin_user_management_section"), + reply_markup=get_user_management_keyboard(i18n, current_lang) + ) + elif section == "ban_management": + await callback.message.edit_text( + _("admin_ban_management_section"), + reply_markup=get_ban_management_keyboard(i18n, current_lang) + ) + elif section == "promo_marketing": + await callback.message.edit_text( + _("admin_promo_marketing_section"), + reply_markup=get_promo_marketing_keyboard(i18n, current_lang) + ) + elif section == "system_functions": + await callback.message.edit_text( + _("admin_system_functions_section"), + reply_markup=get_system_functions_keyboard(i18n, current_lang) + ) + else: + await callback.answer(_("admin_unknown_action"), show_alert=True) + return + + await callback.answer() + except Exception as e: + logging.error(f"Error handling admin section {section}: {e}") + await callback.message.answer( + _("error_occurred_try_again"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) + ) + await callback.answer() diff --git a/bot/handlers/admin/logs_admin.py b/bot/handlers/admin/logs_admin.py index 5f43c08..f12034b 100644 --- a/bot/handlers/admin/logs_admin.py +++ b/bot/handlers/admin/logs_admin.py @@ -1,6 +1,9 @@ import logging import math import re +import csv +import io +from datetime import datetime from aiogram import Router, F, types, Bot from aiogram.fsm.context import FSMContext from typing import Optional, List, Dict, Any @@ -321,3 +324,106 @@ async def cancel_log_user_input_state_to_menu(callback: types.CallbackQuery, await state.clear() await display_logs_menu(callback, i18n_data, settings, session) + + +@router.callback_query(F.data == "admin_logs:export_csv") +async def export_logs_csv_handler(callback: types.CallbackQuery, + settings: Settings, i18n_data: dict, + 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 processing CSV export.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + await callback.answer(_( + "admin_logs_csv_export_started", + default="🔄 Начинаю экспорт логов в CSV..." + )) + + try: + # Get all logs (limit to 10000 for performance) + logs_models = await message_log_dal.get_all_message_logs( + session, limit=10000, offset=0) + + if not logs_models: + await callback.message.answer(_( + "admin_logs_csv_no_data", + default="❌ Нет данных для экспорта" + )) + return + + # Create CSV content + csv_buffer = io.StringIO() + csv_writer = csv.writer(csv_buffer, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) + + # Write header + headers = [ + _("admin_csv_header_log_id", default="Log ID"), + _("admin_csv_header_timestamp", default="Timestamp"), + _("admin_csv_header_user_id", default="User ID"), + _("admin_csv_header_telegram_username", default="Telegram Username"), + _("admin_csv_header_telegram_first_name", default="Telegram First Name"), + _("admin_csv_header_event_type", default="Event Type"), + _("admin_csv_header_content", default="Content"), + _("admin_csv_header_is_admin_event", default="Is Admin Event"), + _("admin_csv_header_target_user_id", default="Target User ID"), + _("admin_csv_header_raw_update_preview", default="Raw Update Preview") + ] + csv_writer.writerow(headers) + + # Write data rows + for log in logs_models: + # Format timestamp + timestamp_str = log.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if log.timestamp else '' + + # Clean content and raw_update_preview (remove newlines and quotes for CSV) + content_clean = (log.content or '').replace('\n', ' ').replace('\r', ' ').strip() + raw_update_clean = (log.raw_update_preview or '').replace('\n', ' ').replace('\r', ' ').strip() + + row = [ + log.log_id or '', + timestamp_str, + log.user_id or '', + log.telegram_username or '', + log.telegram_first_name or '', + log.event_type or '', + content_clean, + 'Yes' if log.is_admin_event else 'No', + log.target_user_id or '', + raw_update_clean + ] + csv_writer.writerow(row) + + # Create file + csv_content = csv_buffer.getvalue() + csv_buffer.close() + + # Generate filename with current timestamp + now = datetime.now() + filename = f"message_logs_{now.strftime('%Y%m%d_%H%M%S')}.csv" + + # Send as document + csv_file = types.BufferedInputFile( + csv_content.encode('utf-8-sig'), # BOM for Excel compatibility + filename=filename + ) + + await callback.message.answer_document( + csv_file, + caption=_( + "admin_logs_csv_export_success", + default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}", + count=len(logs_models), + date=now.strftime('%Y-%m-%d %H:%M:%S') + ) + ) + + except Exception as e: + logging.error(f"Error exporting logs to CSV: {e}", exc_info=True) + await callback.message.answer(_( + "admin_logs_csv_export_failed", + default="❌ Ошибка при экспорте логов: {error}", + error=str(e) + )) diff --git a/bot/handlers/admin/promo/__init__.py b/bot/handlers/admin/promo/__init__.py new file mode 100644 index 0000000..aeca5f9 --- /dev/null +++ b/bot/handlers/admin/promo/__init__.py @@ -0,0 +1,13 @@ +from aiogram import Router + +from . import create +from . import manage +from . import bulk + +promo_router_aggregate = Router(name="promo_features_router") + +promo_router_aggregate.include_router(create.router) +promo_router_aggregate.include_router(manage.router) +promo_router_aggregate.include_router(bulk.router) + +__all__ = ("promo_router_aggregate",) \ No newline at end of file diff --git a/bot/handlers/admin/promo/bulk.py b/bot/handlers/admin/promo/bulk.py new file mode 100644 index 0000000..8a2f7c7 --- /dev/null +++ b/bot/handlers/admin/promo/bulk.py @@ -0,0 +1,511 @@ +import logging +import random +import string +from aiogram import Router, F, types +from aiogram.filters import StateFilter +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.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, get_admin_panel_keyboard +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from bot.middlewares.i18n import JsonI18n + +router = Router(name="promo_bulk_router") + + +async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, 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 or not callback.message: + await callback.answer("Error preparing bulk promo creation.", + show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + # Step 1: Ask for quantity + prompt_text = _( + "admin_bulk_promo_step1_quantity", + default="🎟 Массовое создание промокодов\n\nШаг 1 из 4: Количество\n\nВведите количество промокодов для создания (1-100):" + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") + except Exception as e: + logging.warning( + f"Could not edit message for bulk promo prompt: {e}. Sending new.") + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") + await callback.answer() + await state.set_state(AdminStates.waiting_for_bulk_promo_quantity) + + +def generate_unique_promo_code(length: int = 8) -> str: + """Generate a unique random promo code""" + characters = string.ascii_uppercase + string.digits + return ''.join(random.choice(characters) for _ in range(length)) + + +# Step 1: Process quantity +@router.message(AdminStates.waiting_for_bulk_promo_quantity, F.text) +async def process_bulk_promo_quantity_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + quantity = int(message.text.strip()) + if not (1 <= quantity <= 100): + await message.answer(_( + "admin_bulk_promo_invalid_quantity", + default="❌ Количество промокодов должно быть от 1 до 100" + )) + return + + await state.update_data(quantity=quantity) + + # Step 2: Ask for bonus days + prompt_text = _( + "admin_bulk_promo_step2_bonus_days", + default="🎟 Массовое создание промокодов\n\nШаг 2 из 4: Бонусные дни\n\nКоличество: {quantity}\n\nВведите количество бонусных дней для каждого промокода (1-365):", + quantity=quantity + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_bulk_promo_bonus_days) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing bulk promo quantity: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 2: Process bonus days +@router.message(AdminStates.waiting_for_bulk_promo_bonus_days, F.text) +async def process_bulk_promo_bonus_days_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + bonus_days = int(message.text.strip()) + if not (1 <= bonus_days <= 365): + await message.answer(_( + "admin_promo_invalid_bonus_days", + default="❌ Количество бонусных дней должно быть от 1 до 365" + )) + return + + await state.update_data(bonus_days=bonus_days) + + # Step 3: Ask for max activations + data = await state.get_data() + prompt_text = _( + "admin_bulk_promo_step3_max_activations", + default="🎟 Массовое создание промокодов\n\nШаг 3 из 4: Лимит активаций\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций для каждого промокода (1-10000):", + quantity=data.get("quantity"), + bonus_days=bonus_days + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_bulk_promo_max_activations) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing bulk promo bonus days: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 3: Process max activations +@router.message(AdminStates.waiting_for_bulk_promo_max_activations, F.text) +async def process_bulk_promo_max_activations_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + max_activations = int(message.text.strip()) + if not (1 <= max_activations <= 10000): + await message.answer(_( + "admin_promo_invalid_max_activations", + default="❌ Максимальное количество активаций должно быть от 1 до 10000" + )) + return + + await state.update_data(max_activations=max_activations) + + # Step 4: Ask for validity + data = await state.get_data() + prompt_text = _( + "admin_bulk_promo_step4_validity", + default="🎟 Массовое создание промокодов\n\nШаг 4 из 4: Срок действия\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВыберите срок действия промокодов:", + quantity=data.get("quantity"), + bonus_days=data.get("bonus_days"), + max_activations=max_activations + ) + + # Create keyboard for validity options + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"), + callback_data="bulk_promo_unlimited_validity" + ) + ) + builder.row( + InlineKeyboardButton( + text=_("admin_promo_set_validity_days", default="📅 Указать дни"), + callback_data="bulk_promo_set_validity" + ) + ) + builder.row( + InlineKeyboardButton( + text=_("admin_back_to_panel", default="🔙 В админ панель"), + callback_data="admin_action:main" + ) + ) + + await message.answer( + prompt_text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_bulk_promo_validity_days) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing bulk promo max activations: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 4: Handle unlimited validity +@router.callback_query(F.data == "bulk_promo_unlimited_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days)) +async def process_bulk_promo_unlimited_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + await state.update_data(validity_days=None) + await create_bulk_promo_codes_final(callback, state, i18n_data, settings, session) + + +# Step 4: Handle set validity +@router.callback_query(F.data == "bulk_promo_set_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days)) +async def process_bulk_promo_set_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error processing validity.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + prompt_text = _( + "admin_bulk_promo_enter_validity_days", + default="🎟 Массовое создание промокодов\n\nШаг 4 из 4: Срок действия\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВведите количество дней действия промокодов (1-365):", + quantity=data.get("quantity"), + bonus_days=data.get("bonus_days"), + max_activations=data.get("max_activations") + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await callback.answer() + + +# Step 4: Process validity days +@router.message(AdminStates.waiting_for_bulk_promo_validity_days, F.text) +async def process_bulk_promo_validity_days_handler(message: types.Message, + state: FSMContext, + 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: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + validity_days = int(message.text.strip()) + if not (1 <= validity_days <= 365): + await message.answer(_( + "admin_promo_invalid_validity_days", + default="❌ Срок действия должен быть от 1 до 365 дней" + )) + return + + await state.update_data(validity_days=validity_days) + await create_bulk_promo_codes_final(message, state, i18n_data, settings, session) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing bulk promo validity days: {e}") + await message.answer(_("error_occurred_try_again")) + + +async def create_bulk_promo_codes_final(callback_or_message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + """Final step - create multiple promo codes in database""" + 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) + + try: + data = await state.get_data() + quantity = data["quantity"] + + # Show progress message + progress_text = _( + "admin_bulk_promo_creating", + default="🔄 Создание {quantity} промокодов...", + quantity=quantity + ) + + if hasattr(callback_or_message, 'message'): # CallbackQuery + try: + await callback_or_message.message.edit_text(progress_text, parse_mode="HTML") + except Exception: + await callback_or_message.message.answer(progress_text, parse_mode="HTML") + await callback_or_message.answer() + else: # Message + await callback_or_message.answer(progress_text, parse_mode="HTML") + + # Generate and create promo codes + created_codes = [] + failed_codes = [] + + for i in range(quantity): + try: + # Generate unique code + attempts = 0 + while attempts < 10: # Max 10 attempts to generate unique code + promo_code = generate_unique_promo_code() + existing_promo = await promo_code_dal.get_promo_code_by_code(session, promo_code) + if not existing_promo: + break + attempts += 1 + + if attempts >= 10: + failed_codes.append(f"Код #{i+1} (не удалось сгенерировать уникальный)") + continue + + # Prepare promo code data + promo_data = { + "code": promo_code, + "bonus_days": data["bonus_days"], + "max_activations": data["max_activations"], + "current_activations": 0, + "is_active": True, + "created_by_admin_id": callback_or_message.from_user.id, + "created_at": datetime.now(timezone.utc) + } + + # Set validity + if data.get("validity_days"): + promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"]) + else: + promo_data["valid_until"] = None + + # Create promo code + created_promo = await promo_code_dal.create_promo_code(session, promo_data) + created_codes.append(created_promo.code) + + except Exception as e: + logging.error(f"Error creating bulk promo code #{i+1}: {e}") + failed_codes.append(f"Код #{i+1} ({str(e)[:50]})") + + await session.commit() + + # Success message + success_lines = [ + _( + "admin_bulk_promo_created_title", + default="✅ Массовое создание завершено!\n" + ), + _( + "admin_bulk_promo_created_stats", + default="📊 Создано: {created} из {total}", + created=len(created_codes), + total=quantity + ) + ] + + if data.get("validity_days"): + validity_text = f"{data['validity_days']} дней" + else: + validity_text = _("admin_promo_unlimited", default="Без ограничений") + + success_lines.append( + _( + "admin_bulk_promo_settings", + default="🎁 Бонусные дни: {bonus_days}\n" + "📊 Макс. активаций: {max_activations}\n" + "⏰ Срок действия: {validity}", + bonus_days=data["bonus_days"], + max_activations=data["max_activations"], + validity=validity_text + ) + ) + + if created_codes: + success_lines.append("\n🎟 Созданные коды:") + # Show first 20 codes, then indicate if there are more + codes_to_show = created_codes[:20] + for code in codes_to_show: + success_lines.append(f"{code}") + + if len(created_codes) > 20: + success_lines.append(f"... и еще {len(created_codes) - 20} кодов") + + if failed_codes: + success_lines.append(f"\n❌ Ошибки ({len(failed_codes)}):") + for error in failed_codes[:5]: # Show first 5 errors + success_lines.append(error) + if len(failed_codes) > 5: + success_lines.append(f"... и еще {len(failed_codes) - 5} ошибок") + + success_text = "\n".join(success_lines) + + if hasattr(callback_or_message, 'message'): # CallbackQuery + try: + await callback_or_message.message.edit_text( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + except Exception: + await callback_or_message.message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + else: # Message + await callback_or_message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + + await state.clear() + + except Exception as e: + logging.error(f"Error creating bulk promo codes: {e}") + error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.") + + if hasattr(callback_or_message, 'message'): # CallbackQuery + await callback_or_message.message.answer(error_text) + else: # Message + await callback_or_message.answer(error_text) + + await state.clear() + + +# Cancel bulk promo creation +@router.callback_query( + F.data == "admin_action:main", + StateFilter( + AdminStates.waiting_for_bulk_promo_quantity, + AdminStates.waiting_for_bulk_promo_bonus_days, + AdminStates.waiting_for_bulk_promo_max_activations, + AdminStates.waiting_for_bulk_promo_validity_days, + ), +) +async def cancel_bulk_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( + _(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) + ) + except Exception: + await callback.message.answer( + _(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) + ) + + await callback.answer(_("admin_bulk_promo_creation_cancelled", default="Массовое создание промокодов отменено")) + await state.clear() \ No newline at end of file diff --git a/bot/handlers/admin/promo/create.py b/bot/handlers/admin/promo/create.py new file mode 100644 index 0000000..7c7137f --- /dev/null +++ b/bot/handlers/admin/promo/create.py @@ -0,0 +1,432 @@ +import logging +from aiogram import Router, F, types +from aiogram.filters import StateFilter +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.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, get_admin_panel_keyboard +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from bot.middlewares.i18n import JsonI18n + +router = Router(name="promo_create_router") + + +async def create_promo_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, 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 or not callback.message: + await callback.answer("Error preparing promo creation.", + show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + # Step 1: Ask for promo code + prompt_text = _( + "admin_promo_step1_code", + default="🎟 Создание промокода\n\nШаг 1 из 4: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):" + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") + 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), + parse_mode="HTML") + await callback.answer() + await state.set_state(AdminStates.waiting_for_promo_code) + + +# Step 1: Process promo code +@router.message(AdminStates.waiting_for_promo_code, F.text) +async def process_promo_code_handler(message: types.Message, + state: FSMContext, + 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: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + code_str = message.text.strip().upper() + if not (3 <= len(code_str) <= 30 and code_str.isalnum()): + await message.answer(_( + "admin_promo_invalid_code_format", + default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)" + )) + return + + # Check if code already exists + existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str) + if existing_promo: + await message.answer(_( + "admin_promo_code_already_exists", + default="❌ Промокод с таким кодом уже существует" + )) + return + + await state.update_data(promo_code=code_str) + + # Step 2: Ask for bonus days + prompt_text = _( + "admin_promo_step2_bonus_days", + default="🎟 Создание промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", + code=code_str + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_bonus_days) + + except Exception as e: + logging.error(f"Error processing promo code: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 2: Process bonus days +@router.message(AdminStates.waiting_for_promo_bonus_days, F.text) +async def process_promo_bonus_days_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + bonus_days = int(message.text.strip()) + if not (1 <= bonus_days <= 365): + await message.answer(_( + "admin_promo_invalid_bonus_days", + default="❌ Количество бонусных дней должно быть от 1 до 365" + )) + return + + await state.update_data(bonus_days=bonus_days) + + # Step 3: Ask for max activations + data = await state.get_data() + prompt_text = _( + "admin_promo_step3_max_activations", + default="🎟 Создание промокода\n\nШаг 3 из 4: Лимит активаций\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", + code=data.get("promo_code"), + bonus_days=bonus_days + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_max_activations) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing promo bonus days: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 3: Process max activations +@router.message(AdminStates.waiting_for_promo_max_activations, F.text) +async def process_promo_max_activations_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + max_activations = int(message.text.strip()) + if not (1 <= max_activations <= 10000): + await message.answer(_( + "admin_promo_invalid_max_activations", + default="❌ Максимальное количество активаций должно быть от 1 до 10000" + )) + return + + await state.update_data(max_activations=max_activations) + + # Step 4: Ask for validity + data = await state.get_data() + prompt_text = _( + "admin_promo_step4_validity", + default="🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВыберите срок действия промокода:", + code=data.get("promo_code"), + bonus_days=data.get("bonus_days"), + max_activations=max_activations + ) + + # Create keyboard for validity options + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"), + callback_data="promo_unlimited_validity" + ) + ) + builder.row( + InlineKeyboardButton( + text=_("admin_promo_set_validity_days", default="📅 Указать дни"), + callback_data="promo_set_validity" + ) + ) + builder.row( + InlineKeyboardButton( + text=_("admin_back_to_panel", default="🔙 В админ панель"), + callback_data="admin_action:main" + ) + ) + + await message.answer( + prompt_text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_validity_days) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing promo max activations: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 4: Handle unlimited validity +@router.callback_query(F.data == "promo_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)) +async def process_promo_unlimited_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + await state.update_data(validity_days=None) + await create_promo_code_final(callback, state, i18n_data, settings, session) + + +# Step 4: Handle set validity +@router.callback_query(F.data == "promo_set_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)) +async def process_promo_set_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error processing validity.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + prompt_text = _( + "admin_promo_enter_validity_days", + default="🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВведите количество дней действия промокода (1-365):", + code=data.get("promo_code"), + bonus_days=data.get("bonus_days"), + max_activations=data.get("max_activations") + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await callback.answer() + + +# Step 4: Process validity days +@router.message(AdminStates.waiting_for_promo_validity_days, F.text) +async def process_promo_validity_days_handler(message: types.Message, + state: FSMContext, + 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: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + validity_days = int(message.text.strip()) + if not (1 <= validity_days <= 365): + await message.answer(_( + "admin_promo_invalid_validity_days", + default="❌ Срок действия должен быть от 1 до 365 дней" + )) + return + + await state.update_data(validity_days=validity_days) + await create_promo_code_final(message, state, i18n_data, settings, session) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing promo validity days: {e}") + await message.answer(_("error_occurred_try_again")) + + +async def create_promo_code_final(callback_or_message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + """Final step - create the promo code in database""" + 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) + + try: + data = await state.get_data() + + # Prepare promo code data + promo_data = { + "code": data["promo_code"], + "bonus_days": data["bonus_days"], + "max_activations": data["max_activations"], + "current_activations": 0, + "is_active": True, + "created_by_admin_id": callback_or_message.from_user.id, + "created_at": datetime.now(timezone.utc) + } + + # Set validity + if data.get("validity_days"): + promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"]) + else: + promo_data["valid_until"] = None + + # Create promo code + created_promo = await promo_code_dal.create_promo_code(session, promo_data) + await session.commit() + + # Success message + success_text = _( + "admin_promo_created_success", + default="✅ Промокод успешно создан!\n\n" + "🎟 Код: {code}\n" + "🎁 Бонусные дни: {bonus_days}\n" + "📊 Макс. активаций: {max_activations}\n" + "⏰ Срок действия: {validity}", + code=data["promo_code"], + bonus_days=data["bonus_days"], + max_activations=data["max_activations"], + validity=_("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней" + ) + + if hasattr(callback_or_message, 'message'): # CallbackQuery + try: + await callback_or_message.message.edit_text( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + except Exception: + await callback_or_message.message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await callback_or_message.answer() + else: # Message + await callback_or_message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + + await state.clear() + + except Exception as e: + logging.error(f"Error creating promo code: {e}") + error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.") + + if hasattr(callback_or_message, 'message'): # CallbackQuery + await callback_or_message.message.answer(error_text) + await callback_or_message.answer() + else: # Message + await callback_or_message.answer(error_text) + + await state.clear() + + +# Cancel promo creation +@router.callback_query( + F.data == "admin_action:main", + StateFilter( + AdminStates.waiting_for_promo_code, + AdminStates.waiting_for_promo_bonus_days, + AdminStates.waiting_for_promo_max_activations, + AdminStates.waiting_for_promo_validity_days, + ), +) +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( + _(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) + ) + except Exception: + await callback.message.answer( + _(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) + ) + + await callback.answer(_("admin_promo_creation_cancelled", default="Создание промокода отменено")) + await state.clear() \ No newline at end of file diff --git a/bot/handlers/admin/promo/manage.py b/bot/handlers/admin/promo/manage.py new file mode 100644 index 0000000..1b00406 --- /dev/null +++ b/bot/handlers/admin/promo/manage.py @@ -0,0 +1,422 @@ +import logging +from aiogram import Router, F, types +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +from datetime import datetime, timedelta, timezone +from typing import Optional, List +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from db.dal import promo_code_dal +from db.models import PromoCode, PromoCodeActivation +from bot.states.admin_states import AdminStates +from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from bot.middlewares.i18n import JsonI18n + +router = Router(name="promo_manage_router") + + +async def view_promo_codes_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession): + """View all active promo codes""" + 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 processing request.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_models = await promo_code_dal.get_all_active_promo_codes(session, + limit=20, + offset=0) + + if not promo_models: + text = _( + "admin_no_promo_codes", + default="📋 Активные промокоды\n\n❌ Нет активных промокодов" + ) + else: + lines = [_("admin_active_promo_codes_title", + default="📋 Активные промокоды\n")] + + for promo in promo_models: + validity_str = _("admin_promo_unlimited", default="Без ограничений") + if promo.valid_until: + validity_str = promo.valid_until.strftime("%d.%m.%Y") + + lines.append( + f"🎟 {promo.code} | " + f"🎁 {promo.bonus_days}д | " + f"📊 {promo.current_activations}/{promo.max_activations} | " + f"⏰ {validity_str}" + ) + + text = "\n".join(lines) + + try: + await callback.message.edit_text( + text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await callback.answer() + + except Exception as e: + logging.error(f"Error viewing promo codes: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +async def promo_management_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession): + """Main promo management interface""" + 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 processing request.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=50, offset=0) + + if not promo_models: + text = _( + "admin_no_promo_codes_to_manage", + default="🛠 Управление промокодами\n\n❌ Нет промокодов для управления" + ) + await callback.message.edit_text( + text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await callback.answer() + return + + builder = InlineKeyboardBuilder() + text_lines = [_("admin_promo_management_title", default="🛠 Управление промокодами\n")] + + for promo in promo_models: + status_emoji = "✅" if promo.is_active else "❌" + validity_str = _("admin_promo_unlimited", default="∞") + if promo.valid_until: + validity_str = promo.valid_until.strftime("%d.%m") + + text_lines.append( + f"{status_emoji} {promo.code} | " + f"🎁{promo.bonus_days}д | " + f"📊{promo.current_activations}/{promo.max_activations} | " + f"⏰{validity_str}" + ) + + builder.row( + InlineKeyboardButton( + text=f"📝 {promo.code}", + callback_data=f"promo_detail:{promo.promo_code_id}" + ) + ) + + builder.row( + InlineKeyboardButton( + text=_("admin_back_to_panel", default="🔙 В админ панель"), + callback_data="admin_action:main" + ) + ) + + text = "\n".join(text_lines) + + try: + await callback.message.edit_text( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + await callback.answer() + + except Exception as e: + logging.error(f"Error in promo management: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +@router.callback_query(F.data.startswith("promo_detail:")) +async def promo_detail_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession): + """Show detailed promo code information with management options""" + 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 processing request.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_id = int(callback.data.split(":")[1]) + promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) + + if not promo: + await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + return + + # Format promo details + status_text = _("admin_promo_active", default="✅ Активен") if promo.is_active else _("admin_promo_inactive", default="❌ Неактивен") + validity_text = _("admin_promo_unlimited", default="Без ограничений") + if promo.valid_until: + if promo.valid_until > datetime.now(timezone.utc): + validity_text = promo.valid_until.strftime("%d.%m.%Y %H:%M") + else: + validity_text = f"{promo.valid_until.strftime('%d.%m.%Y %H:%M')} (истек)" + + created_at = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A" + + text = _( + "admin_promo_detail_info", + default="🎟 Детали промокода\n\n" + "📝 Код: {code}\n" + "🎁 Бонусные дни: {bonus_days}\n" + "📊 Использований: {current}/{max}\n" + "📋 Статус: {status}\n" + "⏰ Действует до: {validity}\n" + "👤 Создал: {admin_id}\n" + "🕐 Создан: {created}", + code=promo.code, + bonus_days=promo.bonus_days, + current=promo.current_activations, + max=promo.max_activations, + status=status_text, + validity=validity_text, + admin_id=promo.created_by_admin_id, + created=created_at + ) + + # Create management buttons + builder = InlineKeyboardBuilder() + + # Edit button + builder.row( + InlineKeyboardButton( + text=_("admin_promo_edit", default="✏️ Редактировать"), + callback_data=f"promo_edit:{promo_id}" + ) + ) + + # Toggle active/inactive + toggle_text = _("admin_promo_deactivate", default="❌ Деактивировать") if promo.is_active else _("admin_promo_activate", default="✅ Активировать") + builder.row( + InlineKeyboardButton( + text=toggle_text, + callback_data=f"promo_toggle:{promo_id}" + ) + ) + + # View activations + builder.row( + InlineKeyboardButton( + text=_("admin_promo_view_activations", default="📋 Активации"), + callback_data=f"promo_activations:{promo_id}" + ) + ) + + # Delete button + builder.row( + InlineKeyboardButton( + text=_("admin_promo_delete", default="🗑 Удалить"), + callback_data=f"promo_delete:{promo_id}" + ) + ) + + # Back button + builder.row( + InlineKeyboardButton( + text=_("admin_back_to_management", default="🔙 К управлению"), + callback_data="admin_action:promo_management" + ) + ) + + try: + await callback.message.edit_text( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + await callback.answer() + + except ValueError: + await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + except Exception as e: + logging.error(f"Error in promo detail: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +@router.callback_query(F.data.startswith("promo_toggle:")) +async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Toggle promo code active status""" + 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 service error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_id = int(callback.data.split(":")[1]) + promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) + + if not promo: + await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + return + + new_status = not promo.is_active + update_data = {"is_active": new_status} + + updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) + if updated: + await session.commit() + status_text = _("admin_promo_activated", default="активирован") if new_status else _("admin_promo_deactivated", default="деактивирован") + await callback.answer( + _("admin_promo_status_changed", default="✅ Промокод {status}", status=status_text) + ) + # Refresh the detail view + callback.data = f"promo_detail:{promo_id}" + await promo_detail_handler(callback, i18n_data, settings, session) + else: + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + except ValueError: + await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + except Exception as e: + logging.error(f"Error toggling promo: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +@router.callback_query(F.data.startswith("promo_activations:")) +async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Show promo code activations""" + 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 processing request.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_id = int(callback.data.split(":")[1]) + promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) + + if not promo: + await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + return + + activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id) + + text_lines = [ + _("admin_promo_activations_title", + default="📋 Активации промокода {code}\n", + code=promo.code) + ] + + if not activations: + text_lines.append(_("admin_no_activations", default="❌ Нет активаций")) + else: + for activation in activations[:10]: # Show last 10 + activated_at = activation.activated_at.strftime("%d.%m.%Y %H:%M") if activation.activated_at else "N/A" + text_lines.append( + f"👤 {activation.user_id} | 🕐 {activated_at}" + ) + + if len(activations) > 10: + text_lines.append(f"\n... и еще {len(activations) - 10}") + + text = "\n".join(text_lines) + + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text=_("admin_back_to_promo", default="🔙 К промокоду"), + callback_data=f"promo_detail:{promo_id}" + ) + ) + + try: + await callback.message.edit_text( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + await callback.answer() + + except ValueError: + await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + except Exception as e: + logging.error(f"Error viewing activations: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +@router.callback_query(F.data.startswith("promo_delete:")) +async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Delete promo code""" + 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 service error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_id = int(callback.data.split(":")[1]) + promo = await promo_code_dal.delete_promo_code(session, promo_id) + + if promo: + await session.commit() + await callback.answer( + _("admin_promo_deleted", default="✅ Промокод удален", code=promo.code) + ) + # Go back to management + callback.data = "admin_action:promo_management" + await promo_management_handler(callback, i18n_data, settings, session) + else: + await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + + except ValueError: + await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + except Exception as e: + logging.error(f"Error deleting promo: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +# Legacy handlers that redirect to new system +async def manage_promo_codes_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession): + """Redirect to new unified handler""" + await promo_management_handler(callback, i18n_data, settings, session) \ No newline at end of file diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py deleted file mode 100644 index af4fecd..0000000 --- a/bot/handlers/admin/promo_codes.py +++ /dev/null @@ -1,367 +0,0 @@ -import logging -from aiogram import Router, F, types, Bot -from aiogram.filters import StateFilter -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.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, get_admin_panel_keyboard -from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton -from bot.middlewares.i18n import JsonI18n - -router = Router(name="admin_promo_codes_router") - - -async def create_promo_prompt_handler(callback: types.CallbackQuery, - state: FSMContext, 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 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") - - 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) - - -@router.message(AdminStates.waiting_for_promo_details, F.text) -async def process_promo_code_details_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings, - 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.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_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(_("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(_("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 if message.from_user else 0 - - 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 - } - - 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, - 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 displaying promo codes.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - promo_models = await promo_code_dal.get_all_active_promo_codes(session, - limit=20, - offset=0) - - if not promo_models: - await callback.message.edit_text( - _("admin_no_active_promos"), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - await callback.answer() - return - - response_text_parts = [f"{_('admin_active_promos_list_header')}\n"] - for promo in promo_models: - valid_until_display_text = _("admin_promo_valid_indefinitely") - if 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, - valid_until=valid_until_display_text)) - - final_text = "\n".join(response_text_parts) - try: - await callback.message.edit_text( - final_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML") - except Exception as e: - logging.warning( - f"Failed to edit message for promo list: {e}. Sending new.") - if callback.message: - await callback.message.answer( - final_text, - reply_markup=get_back_to_admin_panel_keyboard( - current_lang, i18n), - parse_mode="HTML") - await callback.answer() - - -@router.callback_query(F.data == "admin_action:manage_promos") -async def manage_promo_codes_handler(callback: 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 or not callback.message: - await callback.answer("Error displaying promo codes.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - promo_models = await promo_code_dal.get_all_active_promo_codes(session, - limit=20, - offset=0) - if not promo_models: - await callback.message.edit_text( - _("admin_no_active_promos"), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - await callback.answer() - return - - kb = InlineKeyboardBuilder() - for promo in promo_models: - kb.row( - InlineKeyboardButton( - text=promo.code, - callback_data=f"promo_edit:{promo.promo_code_id}"), - InlineKeyboardButton( - text=_("admin_promo_delete_button"), - callback_data=f"promo_delete:{promo.promo_code_id}"), - ) - kb.row( - InlineKeyboardButton(text=_("back_to_admin_panel_button"), - callback_data="admin_action:main")) - - await callback.message.edit_text( - _("admin_manage_promos_title"), - reply_markup=kb.as_markup()) - await callback.answer() - - -@router.callback_query(F.data.startswith("promo_edit:")) -async def promo_edit_select_handler(callback: types.CallbackQuery, state: FSMContext, - i18n_data: dict, settings: Settings, - session: AsyncSession): - promo_id = int(callback.data.split(":")[1]) - promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not promo or not callback.message: - await callback.answer("Error", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - await state.update_data(edit_promo_id=promo_id) - await state.set_state(AdminStates.waiting_for_promo_edit_details) - await callback.message.edit_text( - _("admin_promo_edit_prompt", code=promo.code), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML") - await callback.answer() - - -@router.message(AdminStates.waiting_for_promo_edit_details, F.text) -async def process_promo_edit_details(message: types.Message, state: FSMContext, - i18n_data: dict, settings: Settings, - session: AsyncSession): - data = await state.get_data() - promo_id = data.get("edit_promo_id") - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not promo_id: - await message.answer("Error") - await state.clear() - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - parts = message.text.strip().split() - if not (3 <= len(parts) <= 4): - await message.answer(_("admin_promo_invalid_format")) - return - try: - code_str = parts[0].upper() - bonus = int(parts[1]) - max_act = int(parts[2]) - valid_until = None - if len(parts) == 4: - days = int(parts[3]) - valid_until = datetime.now(timezone.utc) + timedelta(days=days) - except Exception: - await message.answer(_("admin_promo_invalid_format_general")) - return - - update_data = { - "code": code_str, - "bonus_days": bonus, - "max_activations": max_act, - "valid_until": valid_until, - } - updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) - if updated: - await session.commit() - await message.answer( - _("admin_promo_updated_success", code=code_str), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML", - ) - else: - await session.rollback() - await message.answer(_("admin_promo_not_found")) - await state.clear() - - -@router.callback_query(F.data.startswith("promo_delete:")) -async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings, session: AsyncSession): - promo_id = int(callback.data.split(":")[1]) - promo = await promo_code_dal.delete_promo_code(session, promo_id) - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - if promo: - await session.commit() - await callback.message.edit_text( - _("admin_promo_deleted_success", code=promo.code), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML") - else: - await session.rollback() - await callback.message.edit_text( - _("admin_promo_not_found"), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - await callback.answer() - - -@router.callback_query( - F.data == "admin_action:main", - StateFilter( - AdminStates.waiting_for_promo_details, - AdminStates.waiting_for_promo_edit_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 616ea09..4cdcc73 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -8,6 +8,7 @@ from config.settings import Settings from db.dal import user_dal, payment_dal, panel_sync_dal from db.models import Payment, PanelSyncStatus +from bot.services.panel_api_service import PanelApiService from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard from bot.middlewares.i18n import JsonI18n @@ -29,13 +30,130 @@ async def show_statistics_handler(callback: types.CallbackQuery, stats_text_parts = [f"{_('admin_stats_header')}"] - user_stats_dict = await user_dal.get_user_count_stats_dal(session) + # Enhanced user statistics + user_stats = await user_dal.get_enhanced_user_statistics(session) + stats_text_parts.append( - _("admin_stats_users", - 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))) + f"\n👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}" + ) + stats_text_parts.append( + f"📊 {_('admin_user_stats_total_label', default='Всего')}: {user_stats['total_users']}" + ) + # Removed: Active today moved to panel stats + stats_text_parts.append( + f"💳 {_('admin_user_stats_paid_subs_label', default='С платной подпиской')}: {user_stats['paid_subscriptions']}" + ) + stats_text_parts.append( + f"🆓 {_('admin_user_stats_trial_label', default='На пробном периоде')}: {user_stats['trial_users']}" + ) + stats_text_parts.append( + f"😴 {_('admin_user_stats_inactive_label', default='Неактивных')}: {user_stats['inactive_users']}" + ) + stats_text_parts.append( + f"🚫 {_('admin_user_stats_banned_label', default='Заблокированных')}: {user_stats['banned_users']}" + ) + stats_text_parts.append( + f"🎁 {_('admin_user_stats_referral_label', default='Привлечено по реферальной программе')}: {user_stats['referral_users']}" + ) + + # Panel Statistics - moved above financial + stats_text_parts.append(f"\n🖥 {_('admin_panel_stats_header', default='Статистика панели')}") + + try: + async with PanelApiService(settings) as panel_service: + # Get system stats + system_stats = await panel_service.get_system_stats() + bandwidth_stats = await panel_service.get_bandwidth_stats() + nodes_stats = await panel_service.get_nodes_statistics() + + logging.info(f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}") + + if system_stats: + users = system_stats.get('users', {}) + status_counts = users.get('statusCounts', {}) + online_stats = system_stats.get('onlineStats', {}) + + active_users = status_counts.get('ACTIVE', 0) + disabled_users = status_counts.get('DISABLED', 0) + expired_users = status_counts.get('EXPIRED', 0) + limited_users = status_counts.get('LIMITED', 0) + total_users = users.get('totalUsers', 0) + online_now = online_stats.get('onlineNow', 0) + + stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: {online_now}") + stats_text_parts.append(f"📊 {_('admin_panel_active_label', default='Активных')}: {active_users}") + stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label', default='Отключенных')}: {disabled_users}") + stats_text_parts.append(f"⏰ {_('admin_panel_expired_label', default='Истекшие')}: {expired_users}") + stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: {limited_users}") + stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: {total_users}") + + # System resources + memory = system_stats.get('memory', {}) + if memory: + memory_total = memory.get('total', 1) + memory_used = memory.get('used', 0) + memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0 + stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: {memory_usage:.1f}%") + else: + stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}") + + # Bandwidth stats + if bandwidth_stats: + week_traffic = bandwidth_stats.get('bandwidthLastSevenDays', {}) + month_traffic = bandwidth_stats.get('bandwidthLast30Days', {}) + # Fallback to the actual key name from API if the above doesn't exist + if not month_traffic: + month_traffic = bandwidth_stats.get('bandwidthLastThirtyDays', {}) + + if week_traffic: + week_total = week_traffic.get('current', '0 B') + stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: {week_total}") + + if month_traffic: + month_total = month_traffic.get('current', '0 B') + stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: {month_total}") + else: + stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}") + + # Nodes stats + if nodes_stats and 'lastSevenDays' in nodes_stats: + last_seven_days = nodes_stats.get('lastSevenDays', []) + # Get unique node names from the data + unique_nodes = set() + for node_data in last_seven_days: + unique_nodes.add(node_data.get('nodeName', '')) + total_nodes_count = len(unique_nodes) + # Assume all nodes are active since we don't have status info + stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: {total_nodes_count}/{total_nodes_count}") + else: + # Use nodes total from system stats as fallback + nodes_info = system_stats.get('nodes', {}) if system_stats else {} + total_online = nodes_info.get('totalOnline', 0) + stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: {total_online}") + + except Exception as e: + logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True) + stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error', default='Ошибка получения данных с панели')}") + stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details', default='Детали')}: {str(e)}") + + # Financial statistics + financial_stats = await payment_dal.get_financial_statistics(session) + + stats_text_parts.append( + f"\n💰 {_('admin_financial_stats_header', default='Финансовая статистика')}" + ) + stats_text_parts.append( + f"📅 {_('admin_financial_today_label', default='За сегодня')}: {financial_stats['today_revenue']:.2f} RUB ({financial_stats['today_payments_count']} {_('admin_financial_payments_label', default='платежей')})" + ) + stats_text_parts.append( + f"📅 {_('admin_financial_week_label', default='За неделю')}: {financial_stats['week_revenue']:.2f} RUB" + ) + stats_text_parts.append( + f"📅 {_('admin_financial_month_label', default='За месяц')}: {financial_stats['month_revenue']:.2f} RUB" + ) + stats_text_parts.append( + f"🏆 {_('admin_financial_all_time_label', default='За все время')}: {financial_stats['all_time_revenue']:.2f} RUB" + ) last_payments_models: List[ Payment] = await payment_dal.get_recent_payment_logs_with_user(session, diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index d98a3f2..b83b711 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -1,170 +1,773 @@ 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 - +from aiogram.utils.markdown import hcode, hbold +from typing import Optional, Dict, Any from sqlalchemy.ext.asyncio import AsyncSession -from datetime import datetime +from datetime import datetime, timezone from config.settings import Settings - -from db.dal import user_dal, subscription_dal -from db.models import User, Subscription - -from bot.services.panel_api_service import PanelApiService - +from db.dal import user_dal, subscription_dal, message_log_dal +from db.models import User from bot.states.admin_states import AdminStates -from bot.keyboards.inline.admin_keyboards import ( - get_back_to_admin_panel_keyboard, get_user_card_keyboard, - get_banned_users_keyboard, get_confirmation_keyboard, - get_admin_panel_keyboard) +from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard +from bot.services.subscription_service import SubscriptionService +from bot.services.panel_api_service import PanelApiService from bot.middlewares.i18n import JsonI18n +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton 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]: - if input_text.isdigit(): - try: - return await user_dal.get_user(session, user_id=int(input_text)) - except ValueError: - return None - if input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]): - return await user_dal.get_user(session, username=input_text[1:]) - if USERNAME_REGEX.match(input_text): - return await user_dal.get_user(session, username=input_text) - return None - - -async def ban_user_prompt_handler(callback: types.CallbackQuery, - state: FSMContext, i18n_data: dict, - settings: Settings, session: AsyncSession): +async def user_management_menu_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Display user management menu""" 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 processing ban prompt.", show_alert=True) + await callback.answer("Error preparing user management.", show_alert=True) return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - prompt_text = _("admin_ban_user_prompt") + prompt_text = _( + "admin_user_management_prompt", + default="👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:" + ) + try: await callback.message.edit_text( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + 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.") + logging.warning(f"Could not edit message for user management: {e}. Sending new.") await callback.message.answer( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + + await callback.answer() + await state.set_state(AdminStates.waiting_for_user_search) + +def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyboardBuilder: + """Generate keyboard for user management actions""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + # Row 1: Trial and Subscription actions + builder.button( + text=_(key="admin_user_reset_trial_button", default="🔄 Сбросить триал"), + callback_data=f"user_action:reset_trial:{user_id}" + ) + builder.button( + text=_(key="admin_user_add_subscription_button", default="➕ Добавить дни"), + callback_data=f"user_action:add_subscription:{user_id}" + ) + + # Row 2: Block/Unblock and Message + builder.button( + text=_(key="admin_user_toggle_ban_button", default="🚫 Заблокировать/Разблокировать"), + callback_data=f"user_action:toggle_ban:{user_id}" + ) + builder.button( + text=_(key="admin_user_send_message_button", default="✉️ Отправить сообщение"), + callback_data=f"user_action:send_message:{user_id}" + ) + + # Row 3: View actions + builder.button( + text=_(key="admin_user_view_logs_button", default="📜 Действия пользователя"), + callback_data=f"user_action:view_logs:{user_id}" + ) + builder.button( + text=_(key="admin_user_refresh_button", default="🔄 Обновить"), + callback_data=f"user_action:refresh:{user_id}" + ) + + # Row 4: Back button + builder.button( + text=_(key="admin_user_search_new_button", default="🔍 Найти другого"), + callback_data="admin_action:users_management" + ) + builder.button( + text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main" + ) + + builder.adjust(2, 2, 2, 2) + return builder + + +async def format_user_card(user: User, session: AsyncSession, + subscription_service: SubscriptionService, + i18n_instance, lang: str) -> str: + """Format user information as a detailed card""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + # Basic user info + card_parts = [] + card_parts.append(f"👤 {_('admin_user_card_title', default='Карточка пользователя')}\n") + + # User details + na_value = _("admin_user_na_value", default="N/A") + user_name = user.first_name or na_value + username_display = f"@{user.username}" if user.username else na_value + registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value + + card_parts.append(f"{_('admin_user_id_label', default='🆔 ID:')} {hcode(str(user.user_id))}") + card_parts.append(f"{_('admin_user_name_label', default='👤 Имя:')} {hcode(user_name)}") + card_parts.append(f"{_('admin_user_username_label', default='📱 Username:')} {hcode(username_display)}") + card_parts.append(f"{_('admin_user_language_label', default='🌍 Язык:')} {hcode(user.language_code or na_value)}") + card_parts.append(f"{_('admin_user_registration_label', default='📅 Регистрация:')} {hcode(registration_date)}") + + # Ban status + ban_status = _("admin_user_status_banned", default="🚫 Заблокирован") if user.is_banned else _("admin_user_status_active", default="✅ Активен") + card_parts.append(f"{_('admin_user_status_label', default='🛡 Статус:')} {ban_status}") + + # Referral info + if user.referred_by_id: + card_parts.append(f"{_('admin_user_referral_label', default='🎁 Привлечен по реферальной программе от:')} {hcode(str(user.referred_by_id))}") + + # Panel info + if user.panel_user_uuid: + card_parts.append(f"{_('admin_user_panel_uuid_label', default='🔗 Panel UUID:')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}") + + card_parts.append("") # Empty line + + # Subscription info + try: + subscription_details = await subscription_service.get_active_subscription_details(session, user.user_id) + if subscription_details: + card_parts.append(f"💳 {_('admin_user_subscription_info', default='Информация о подписке:')}") + + end_date = subscription_details.get('end_date') + if end_date: + end_date_str = end_date.strftime('%Y-%m-%d %H:%M') if isinstance(end_date, datetime) else str(end_date) + card_parts.append(f"{_('admin_user_subscription_active_until', default='⏰ Действует до:')} {hcode(end_date_str)}") + + status = subscription_details.get('status_from_panel', 'UNKNOWN') + card_parts.append(f"{_('admin_user_panel_status_label', default='📊 Статус на панели:')} {hcode(status)}") + + traffic_limit = subscription_details.get('traffic_limit_bytes') + traffic_used = subscription_details.get('traffic_used_bytes') + if traffic_limit and traffic_used is not None: + traffic_limit_gb = traffic_limit / (1024**3) + traffic_used_gb = traffic_used / (1024**3) + card_parts.append(f"{_('admin_user_traffic_label', default='📊 Трафик:')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}") + else: + card_parts.append(f"{_('admin_user_subscription_label', default='💼 Подписка:')} {hcode(_('admin_user_subscription_none', default='Нет активной подписки'))}") + except Exception as e: + logging.error(f"Error getting subscription details for user {user.user_id}: {e}") + card_parts.append(f"{_('admin_user_subscription_label', default='💼 Подписка:')} {hcode(_('admin_user_subscription_error', default='Ошибка загрузки'))}") + + # Statistics + try: + # Count user logs + logs_count = await message_log_dal.count_user_message_logs(session, user.user_id) + card_parts.append(f"{_('admin_user_actions_count_label', default='📜 Всего действий:')} {hcode(str(logs_count))}") + + # Check if user had any subscriptions + had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id) + trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал") + card_parts.append(f"{_('admin_user_trial_label', default='🏡 Триал:')} {hcode(trial_status)}") + + except Exception as e: + logging.error(f"Error getting user statistics for {user.user_id}: {e}") + + return "\n".join(card_parts) + + +@router.message(AdminStates.waiting_for_user_search, F.text) +async def process_user_search_handler(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + subscription_service: SubscriptionService, + session: AsyncSession): + """Process user search input and display user card""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + input_text = message.text.strip() if message.text else "" + user_model: Optional[User] = None + + # Try to find user by ID or username + 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) + + if not user_model: + await message.answer(_( + "admin_user_not_found", + default="❌ Пользователь не найден: {input}", + input=hcode(input_text) + )) + return + + # Store user ID in state for further operations + await state.update_data(target_user_id=user_model.user_id) + await state.clear() + + # Format and send user card + try: + user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang) + keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang) + + await message.answer( + user_card_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + except Exception as e: + logging.error(f"Error displaying user card for {user_model.user_id}: {e}") + await message.answer(_( + "admin_user_card_error", + default="❌ Ошибка отображения карточки пользователя" + )) + + +@router.callback_query(F.data.startswith("user_action:")) +async def user_action_handler(callback: types.CallbackQuery, state: FSMContext, + settings: Settings, i18n_data: dict, bot: Bot, + subscription_service: SubscriptionService, + panel_service: PanelApiService, + session: AsyncSession): + """Handle user management actions""" + try: + parts = callback.data.split(":") + action = parts[1] + user_id = int(parts[2]) + except (IndexError, ValueError): + await callback.answer("Invalid action format.", show_alert=True) + return + + 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 service error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + # Get user from database + user = await user_dal.get_user_by_id(session, user_id) + if not user: + await callback.answer(_( + "admin_user_not_found_action", + default="Пользователь не найден" + ), show_alert=True) + return + + if action == "reset_trial": + await handle_reset_trial(callback, user, subscription_service, session, i18n, current_lang) + elif action == "add_subscription": + await handle_add_subscription_prompt(callback, state, user, i18n, current_lang) + elif action == "toggle_ban": + await handle_toggle_ban(callback, user, panel_service, session, i18n, current_lang) + elif action == "send_message": + await handle_send_message_prompt(callback, state, user, i18n, current_lang) + elif action == "view_logs": + await handle_view_user_logs(callback, user, session, settings, i18n, current_lang) + elif action == "refresh": + await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang) + else: + await callback.answer(_("admin_unknown_action"), show_alert=True) + + +async def handle_reset_trial(callback: types.CallbackQuery, user: User, + subscription_service: SubscriptionService, + session: AsyncSession, i18n_instance, lang: str): + """Reset user's trial eligibility""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + # Delete all user subscriptions to reset trial eligibility + await subscription_dal.delete_all_user_subscriptions(session, user.user_id) + await session.commit() + + await callback.answer(_( + "admin_user_trial_reset_success", + default="✅ Триал сброшен! Пользователь может активировать триал заново." + ), show_alert=True) + + # Refresh user card + await handle_refresh_user_card(callback, user, subscription_service, session, i18n_instance, lang) + + except Exception as e: + logging.error(f"Error resetting trial for user {user.user_id}: {e}") + await session.rollback() + await callback.answer(_( + "admin_user_trial_reset_error", + default="❌ Ошибка сброса триала" + ), show_alert=True) + + +async def handle_add_subscription_prompt(callback: types.CallbackQuery, state: FSMContext, + user: User, i18n_instance, lang: str): + """Prompt admin to enter subscription days to add""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + await state.update_data(target_user_id=user.user_id) + await state.set_state(AdminStates.waiting_for_subscription_days_to_add) + + prompt_text = _( + "admin_user_add_subscription_prompt", + default="➕ Добавление дней подписки для пользователя {user_id}\n\nВведите количество дней для добавления:", + user_id=user.user_id + ) + + try: + await callback.message.edit_text(prompt_text) + except Exception: + await callback.message.answer(prompt_text) + + await callback.answer() + + +async def handle_toggle_ban(callback: types.CallbackQuery, user: User, + panel_service: PanelApiService, session: AsyncSession, + i18n_instance, lang: str): + """Toggle user ban status""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + new_ban_status = not user.is_banned + + # Update in database + await user_dal.update_user(session, user.user_id, {"is_banned": new_ban_status}) + + # Update on panel if user has panel UUID + if user.panel_user_uuid: + panel_status = "DISABLED" if new_ban_status else "ACTIVE" + await panel_service.update_user_status_on_panel(user.panel_user_uuid, not new_ban_status) + + await session.commit() + + status_text = _("admin_user_ban_action_banned", default="заблокирован") if new_ban_status else _("admin_user_ban_action_unbanned", default="разблокирован") + await callback.answer(_( + "admin_user_ban_toggle_success", + default="✅ Пользователь {status}", + status=status_text + ), show_alert=True) + + # Refresh user card with updated ban status + user.is_banned = new_ban_status # Update local object + from config.settings import Settings + from bot.services.panel_api_service import PanelApiService + settings = Settings() + async with PanelApiService(settings) as panel_service: + subscription_service = SubscriptionService(settings, panel_service) + await handle_refresh_user_card(callback, user, subscription_service, session, i18n_instance, lang) + + except Exception as e: + logging.error(f"Error toggling ban for user {user.user_id}: {e}") + await session.rollback() + await callback.answer(_( + "admin_user_ban_toggle_error", + default="❌ Ошибка изменения статуса блокировки" + ), show_alert=True) + + +async def handle_send_message_prompt(callback: types.CallbackQuery, state: FSMContext, + user: User, i18n_instance, lang: str): + """Prompt admin to enter message to send to user""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + await state.update_data(target_user_id=user.user_id) + await state.set_state(AdminStates.waiting_for_direct_message_to_user) + + prompt_text = _( + "admin_user_send_message_prompt", + default="✉️ Отправка сообщения пользователю {user_id}\n\nВведите текст сообщения:", + user_id=user.user_id + ) + + try: + await callback.message.edit_text(prompt_text) + except Exception: + await callback.message.answer(prompt_text) + + await callback.answer() + + +async def handle_view_user_logs(callback: types.CallbackQuery, user: User, + session: AsyncSession, settings: Settings, + i18n_instance, lang: str): + """Show recent user logs""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + # Get recent logs for user + logs = await message_log_dal.get_user_message_logs(session, user.user_id, limit=10, offset=0) + + if not logs: + await callback.answer(_( + "admin_user_no_logs", + default="📜 У пользователя нет действий" + ), show_alert=True) + return + + logs_text_parts = [f"📜 Последние действия пользователя {user.user_id}:\n"] + + for log in logs: + timestamp = log.timestamp.strftime('%Y-%m-%d %H:%M') if log.timestamp else 'N/A' + event_type = log.event_type or 'N/A' + content_preview = (log.content or '')[:50] + ('...' if len(log.content or '') > 50 else '') + + logs_text_parts.append( + f"🕐 {hcode(timestamp)} - {hcode(event_type)}\n" + f" {content_preview}" + ) + + logs_text = "\n\n".join(logs_text_parts) + + # Create inline keyboard for full logs + builder = InlineKeyboardBuilder() + builder.button( + text=_(key="admin_user_view_all_logs_button", default="📋 Все действия"), + callback_data=f"admin_logs:view_user:{user.user_id}:0" + ) + builder.button( + text=_(key="admin_user_back_to_card_button", default="🔙 К карточке"), + callback_data=f"user_action:refresh:{user.user_id}" + ) + builder.adjust(1) + + try: + await callback.message.edit_text( + logs_text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + logs_text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + + await callback.answer() + + except Exception as e: + logging.error(f"Error viewing logs for user {user.user_id}: {e}") + await callback.answer(_( + "admin_user_logs_error", + default="❌ Ошибка загрузки действий пользователя" + ), show_alert=True) + + +async def handle_refresh_user_card(callback: types.CallbackQuery, user: User, + subscription_service: SubscriptionService, + session: AsyncSession, i18n_instance, lang: str): + """Refresh user card with latest information""" + try: + # Reload user from database + fresh_user = await user_dal.get_user_by_id(session, user.user_id) + if not fresh_user: + await callback.answer("User not found", show_alert=True) + return + + user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang) + keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang) + + try: + await callback.message.edit_text( + user_card_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + user_card_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + + await callback.answer() + + except Exception as e: + logging.error(f"Error refreshing user card for {user.user_id}: {e}") + await callback.answer("Error refreshing user card", show_alert=True) + + +# Message handlers for state-based inputs + +@router.message(AdminStates.waiting_for_subscription_days_to_add, F.text) +async def process_subscription_days_handler(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + subscription_service: SubscriptionService, + session: AsyncSession): + """Process subscription days input""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + target_user_id = data.get("target_user_id") + if not target_user_id: + await message.answer("Error: target user not found in state") + await state.clear() + return + + try: + days_to_add = int(message.text.strip()) + if days_to_add <= 0 or days_to_add > 3650: # Max 10 years + raise ValueError("Invalid days count") + except ValueError: + await message.answer(_( + "admin_user_invalid_days", + default="❌ Неверное количество дней. Введите число от 1 до 3650." + )) + return + + try: + # Extend subscription + result = await subscription_service.extend_active_subscription_days( + session, target_user_id, days_to_add, "admin_manual_extension" + ) + + if result: + await session.commit() + await message.answer(_( + "admin_user_subscription_added_success", + default="✅ Успешно добавлено {days} дней подписки пользователю {user_id}", + days=days_to_add, + user_id=target_user_id + )) + + # Show updated user card + user = await user_dal.get_user_by_id(session, target_user_id) + if user: + user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang) + keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang) + + await message.answer( + user_card_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + else: + await session.rollback() + await message.answer(_( + "admin_user_subscription_added_error", + default="❌ Ошибка добавления дней подписки" + )) + + except Exception as e: + logging.error(f"Error adding subscription days for user {target_user_id}: {e}") + await session.rollback() + await message.answer(_( + "admin_user_subscription_added_error", + default="❌ Ошибка добавления дней подписки" + )) + + await state.clear() + + +@router.message(AdminStates.waiting_for_direct_message_to_user, F.text) +async def process_direct_message_handler(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + bot: Bot, session: AsyncSession): + """Process direct message to user""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + target_user_id = data.get("target_user_id") + if not target_user_id: + await message.answer("Error: target user not found in state") + await state.clear() + return + + message_text = message.text.strip() + if len(message_text) > 4000: + await message.answer(_( + "admin_user_message_too_long", + default="❌ Сообщение слишком длинное (максимум 4000 символов)" + )) + return + + try: + # Get target user + target_user = await user_dal.get_user_by_id(session, target_user_id) + if not target_user: + await message.answer("Target user not found") + await state.clear() + return + + # Prepare message with admin signature + admin_signature = _( + "admin_direct_message_signature", + default="\n\n---\n💬 Сообщение от администратора" + ) + full_message = message_text + admin_signature + + # Send message to user + await bot.send_message(target_user_id, full_message) + + # Confirm to admin + await message.answer(_( + "admin_user_message_sent_success", + default="✅ Сообщение отправлено пользователю {user_id}", + user_id=target_user_id + )) + + # Show user card again + from bot.services.panel_api_service import PanelApiService + async with PanelApiService(settings) as panel_service: + subscription_service = SubscriptionService(settings, panel_service) + user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang) + keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang) + + await message.answer( + user_card_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + + except Exception as e: + logging.error(f"Error sending direct message to user {target_user_id}: {e}") + await message.answer(_( + "admin_user_message_sent_error", + default="❌ Ошибка отправки сообщения" + )) + + await state.clear() + + +async def ban_user_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Prompt admin to enter user ID or username to ban""" + 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 preparing ban prompt.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + prompt_text = _( + "admin_ban_user_prompt", + default="🚫 Блокировка пользователя\n\nВведите ID пользователя или @username для блокировки:" + ) + + 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 ban 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_user_id_to_ban) -@router.message(AdminStates.waiting_for_user_id_to_ban, F.text) -async def process_user_input_to_ban_handler(message: types.Message, - state: FSMContext, i18n_data: dict, - settings: Settings, - panel_service: PanelApiService, - 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.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - 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_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_model.is_banned: - await message.answer( - _("admin_user_already_banned", - user_id_or_username=user_display_for_msg)) - await state.clear() - return - - 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 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, session: AsyncSession): + state: FSMContext, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Prompt admin to enter user ID or username to unban""" 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 processing unban prompt.", - show_alert=True) + await callback.answer("Error preparing unban prompt.", show_alert=True) return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - prompt_text = _("admin_unban_user_prompt") + prompt_text = _( + "admin_unban_user_prompt", + default="✅ Разблокировка пользователя\n\nВведите ID пользователя или @username для разблокировки:" + ) + try: await callback.message.edit_text( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + 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.") + logging.warning(f"Could not edit message for unban prompt: {e}. Sending new.") await callback.message.answer( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + await callback.answer() await state.set_state(AdminStates.waiting_for_user_id_to_unban) -@router.message(AdminStates.waiting_for_user_id_to_unban, F.text) -async def process_user_input_to_unban_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings, - panel_service: PanelApiService, - session: AsyncSession): +async def view_banned_users_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Display list of banned users""" + 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 preparing banned users list.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + # Get banned users + banned_users = await user_dal.get_banned_users(session) + + if not banned_users: + message_text = _( + "admin_banned_users_empty", + default="📋 Заблокированные пользователи\n\nСписок пуст" + ) + else: + user_list = [] + for user in banned_users: + display_name = user.first_name or "Unknown" + if user.username: + display_name = f"@{user.username}" + user_list.append(f"• {display_name} (ID: {user.user_id})") + + message_text = _( + "admin_banned_users_list", + default="📋 Заблокированные пользователи ({count}):\n\n{users}", + count=len(banned_users), + users="\n".join(user_list) + ) + + await callback.message.edit_text( + message_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + + except Exception as e: + logging.error(f"Error displaying banned users: {e}") + await callback.answer("Error loading banned users", show_alert=True) + + +@router.message(AdminStates.waiting_for_user_id_to_ban, F.text) +async def process_ban_user_handler(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + panel_service: PanelApiService, + session: AsyncSession): + """Process user ban input""" current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: @@ -173,373 +776,129 @@ async def process_user_input_to_unban_handler(message: types.Message, _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) input_text = message.text.strip() if message.text else "" - user_to_unban_model = await _get_user_model_by_input(session, input_text) + user_model: Optional[User] = None - 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_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_display_for_msg)) - await state.clear() - return - - 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 session.rollback() - await message.answer(_("admin_user_unban_failed_local_db_error")) - - await state.clear() - - -async def view_banned_users_handler(callback: types.CallbackQuery, - state: FSMContext, i18n_data: dict, - settings: Settings, session: AsyncSession): - await state.clear() - current_page_idx = 0 - if ":" in callback.data and callback.data.count(":") == 2: + # Try to find user by ID or username + if input_text.isdigit(): try: - current_page_idx = int(callback.data.split(":")[-1]) + user_model = await user_dal.get_user_by_id(session, int(input_text)) except ValueError: - current_page_idx = 0 + 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) - 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 displaying banned users.", - show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - page_size = settings.LOGS_PAGE_SIZE - offset = current_page_idx * page_size - - 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_count / - page_size) if page_size > 0 else 1 - await callback.message.edit_text( - text=_("admin_banned_list_title", - current_page=current_page_idx + 1, - total_pages=max(1, total_pages)), - 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)) + await message.answer(_( + "admin_user_not_found", + default="❌ Пользователь не найден: {input}", + input=hcode(input_text) + )) 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, - 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 - else: - try: - parts = callback.data.split(":") - user_id_to_show = int(parts[1]) - banned_list_page_to_return = int(parts[2]) if len(parts) > 2 else 0 - except (IndexError, ValueError): - await callback.answer("Invalid user card data.", show_alert=True) - return - - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error displaying user card.", show_alert=True) - return - - 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() - - -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 (ValueError, IndexError): - await callback.answer("Invalid confirmation data.", show_alert=True) - return - - 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 processing confirmation.", - show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - 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, 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_target = int(user_id_str) - banned_list_page = int(page_str) - except (ValueError, IndexError): - await callback.answer("Invalid action data.", show_alert=True) - return - - 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 processing action.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - 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) - return - - 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: + # Check if user is already banned 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) + await message.answer(_( + "admin_user_already_banned", + default="⚠️ Пользователь уже заблокирован" + )) + await state.clear() return - action_success_local = await user_dal.set_user_ban_status( - session, user_id_target, is_banning) - - if action_success_local: - panel_action_message = "" + # Ban the user + await user_dal.update_user(session, user_model.user_id, {"is_banned": True}) + + # Update on panel if user has panel UUID 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 panel_service.update_user_status_on_panel(user_model.panel_user_uuid, False) + 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 message.answer(_( + "admin_user_ban_success", + default="✅ Пользователь {input} заблокирован", + input=hcode(input_text) + )) + + except Exception as e: + logging.error(f"Error banning user {user_model.user_id}: {e}") 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) + await message.answer(_( + "admin_user_ban_error", + default="❌ Ошибка блокировки пользователя" + )) + + await state.clear() -@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, - session: AsyncSession, state: FSMContext): - await _do_ban_unban_action_handler(callback, i18n_data, settings, - panel_service, session, state, "ban") - - -@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, -): +@router.message(AdminStates.waiting_for_user_id_to_unban, F.text) +async def process_unban_user_handler(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + panel_service: PanelApiService, + session: AsyncSession): + """Process user unban input""" 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) + if not i18n: + await message.reply("Language service error.") return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + input_text = message.text.strip() if message.text else "" + user_model: Optional[User] = None + + # Try to find user by ID or username + 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) + + if not user_model: + await message.answer(_( + "admin_user_not_found", + default="❌ Пользователь не найден: {input}", + input=hcode(input_text) + )) + return + 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() + # Check if user is not banned + if not user_model.is_banned: + await message.answer(_( + "admin_user_not_banned", + default="⚠️ Пользователь не заблокирован" + )) + await state.clear() + return + + # Unban the user + await user_dal.update_user(session, user_model.user_id, {"is_banned": False}) + + # Update on panel if user has panel UUID + if user_model.panel_user_uuid: + await panel_service.update_user_status_on_panel(user_model.panel_user_uuid, True) + + await session.commit() + + await message.answer(_( + "admin_user_unban_success", + default="✅ Пользователь {input} разблокирован", + input=hcode(input_text) + )) + + except Exception as e: + logging.error(f"Error unbanning user {user_model.user_id}: {e}") + await session.rollback() + await message.answer(_( + "admin_user_unban_error", + default="❌ Ошибка разблокировки пользователя" + )) + + await state.clear() \ No newline at end of file diff --git a/bot/handlers/inline_mode.py b/bot/handlers/inline_mode.py new file mode 100644 index 0000000..83dd4fb --- /dev/null +++ b/bot/handlers/inline_mode.py @@ -0,0 +1,360 @@ +import logging +from aiogram import Router, types, Bot +from aiogram.types import InlineQuery, InlineQueryResultArticle, InputTextMessageContent +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from db.dal import user_dal, payment_dal +from bot.services.referral_service import ReferralService +from bot.middlewares.i18n import JsonI18n + +router = Router(name="inline_mode_router") + + +@router.inline_query() +async def inline_query_handler(inline_query: InlineQuery, + settings: Settings, + i18n_data: dict, + referral_service: ReferralService, + bot: Bot, + session: AsyncSession): + """Handle inline queries for referral links and admin statistics""" + 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_id = inline_query.from_user.id + query = inline_query.query.lower().strip() + + results: List[InlineQueryResultArticle] = [] + + # Check if user is admin + is_admin = user_id in settings.ADMIN_IDS if settings.ADMIN_IDS else False + + try: + # For all users: referral functionality + if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query: + referral_result = await create_referral_result( + inline_query, bot, referral_service, i18n, current_lang, settings + ) + if referral_result: + results.append(referral_result) + + # For admins: statistics + if is_admin and (not query or "стат" in query or "stat" in query or "админ" in query or "admin" in query): + stats_results = await create_admin_stats_results( + session, i18n, current_lang, settings + ) + results.extend(stats_results) + + + + # Limit results to 50 (Telegram limit) + results = results[:50] + + await inline_query.answer( + results=results, + cache_time=30, # Cache for 30 seconds + is_personal=True # Results are personalized + ) + + except Exception as e: + logging.error(f"Error handling inline query from user {user_id}: {e}") + # Send empty results in case of error + await inline_query.answer(results=[], cache_time=10) + + +async def create_referral_result(inline_query: InlineQuery, bot: Bot, + referral_service: ReferralService, + i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]: + """Create referral link result for inline query""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + bot_info = await bot.get_me() + bot_username = bot_info.username + if not bot_username: + return None + + user_id = inline_query.from_user.id + referral_link = referral_service.generate_referral_link(bot_username, user_id) + + # Create message content (use same text as friend message) + message_text = _( + "referral_friend_message", + default="🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n" + "🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", + referral_link=referral_link + ) + + return InlineQueryResultArticle( + id="referral_link", + title=_( + "inline_referral_title", + default="🎁 Пригласить друга" + ), + description=_( + "inline_referral_description", + default="Поделиться реферальной ссылкой для получения бонусов" + ), + input_message_content=InputTextMessageContent( + message_text=message_text, + disable_web_page_preview=True + ), + thumbnail_url=settings.INLINE_REFERRAL_THUMBNAIL_URL + ) + + except Exception as e: + logging.error(f"Error creating referral result: {e}") + return None + + +async def create_admin_stats_results(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> List[InlineQueryResultArticle]: + """Create admin statistics results for inline query""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + results = [] + + try: + # Quick user stats + user_stats_result = await create_user_stats_result(session, i18n_instance, lang, settings) + if user_stats_result: + results.append(user_stats_result) + + # Quick financial stats + financial_stats_result = await create_financial_stats_result(session, i18n_instance, lang, settings) + if financial_stats_result: + results.append(financial_stats_result) + + # Quick system stats + system_stats_result = await create_system_stats_result(session, i18n_instance, lang, settings) + if system_stats_result: + results.append(system_stats_result) + + except Exception as e: + logging.error(f"Error creating admin stats results: {e}") + + return results + + +async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]: + """Create user statistics result""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + from db.dal.user_dal import get_enhanced_user_statistics + user_stats = await get_enhanced_user_statistics(session) + + stats_text = _( + "inline_user_stats_message", + default="📊 Статистика Бота\n👥 Пользователи\n\n" + "📊 Всего: {total}\n" + "💳 С платной подпиской: {paid}\n" + "🆓 На пробном периоде: {trial}\n" + "😴 Неактивных: {inactive}\n" + "🚫 Заблокированных: {banned}\n" + "🎁 Привлечено по реферальной программе: {referral}", + total=user_stats['total_users'], + paid=user_stats['paid_subscriptions'], + trial=user_stats['trial_users'], + inactive=user_stats['inactive_users'], + banned=user_stats['banned_users'], + referral=user_stats['referral_users'] + ) + + return InlineQueryResultArticle( + id="admin_user_stats", + title=_( + "inline_admin_user_stats_title", + default="📊 Статистика пользователей" + ), + description=_( + "inline_user_stats_description", + default="Всего: {total}, Платных: {active}", + total=user_stats['total_users'], + active=user_stats['paid_subscriptions'] + ), + input_message_content=InputTextMessageContent( + message_text=stats_text, + parse_mode="HTML" + ), + thumbnail_url=settings.INLINE_USER_STATS_THUMBNAIL_URL + ) + + except Exception as e: + logging.error(f"Error creating user stats result: {e}") + return None + + +async def create_financial_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]: + """Create financial statistics result""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + from db.dal.payment_dal import get_financial_statistics + financial_stats = await get_financial_statistics(session) + + stats_text = _( + "inline_financial_stats_message", + default="💰 Финансовая статистика\n\n" + "📅 За сегодня: {today:.2f} RUB\n" + " ({today_count} платежей)\n" + "📅 За неделю: {week:.2f} RUB\n" + "📅 За месяц: {month:.2f} RUB\n" + "🏆 За все время: {all_time:.2f} RUB", + today=financial_stats['today_revenue'], + today_count=financial_stats['today_payments_count'], + week=financial_stats['week_revenue'], + month=financial_stats['month_revenue'], + all_time=financial_stats['all_time_revenue'] + ) + + return InlineQueryResultArticle( + id="admin_financial_stats", + title=_( + "inline_admin_financial_stats_title", + default="💰 Финансовая статистика" + ), + description=_( + "inline_financial_description", + default="Сегодня: {today} RUB", + today=f"{financial_stats['today_revenue']:.2f}" + ), + input_message_content=InputTextMessageContent( + message_text=stats_text, + parse_mode="HTML" + ), + thumbnail_url=settings.INLINE_FINANCIAL_STATS_THUMBNAIL_URL + ) + + except Exception as e: + logging.error(f"Error creating financial stats result: {e}") + return None + + +async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]: + """Create panel statistics result with system/nodes/bandwidth info""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + from bot.services.panel_api_service import PanelApiService + + # Get panel stats similar to main statistics + async with PanelApiService(settings) as panel_service: + system_stats = await panel_service.get_system_stats() + bandwidth_stats = await panel_service.get_bandwidth_stats() + nodes_stats = await panel_service.get_nodes_statistics() + + if system_stats: + users = system_stats.get('users', {}) + status_counts = users.get('statusCounts', {}) + online_stats = system_stats.get('onlineStats', {}) + + active_users = status_counts.get('ACTIVE', 0) + disabled_users = status_counts.get('DISABLED', 0) + expired_users = status_counts.get('EXPIRED', 0) + limited_users = status_counts.get('LIMITED', 0) + total_users = users.get('totalUsers', 0) + online_now = online_stats.get('onlineNow', 0) + + # Memory usage + memory = system_stats.get('memory', {}) + memory_usage = 0 + if memory: + memory_total = memory.get('total', 1) + memory_used = memory.get('used', 0) + memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0 + + # Bandwidth + week_traffic = "N/A" + month_traffic = "N/A" + if bandwidth_stats: + week_data = bandwidth_stats.get('bandwidthLastSevenDays', {}) + month_data = bandwidth_stats.get('bandwidthLast30Days', {}) or bandwidth_stats.get('bandwidthLastThirtyDays', {}) + + week_traffic = week_data.get('current', 'N/A') if week_data else 'N/A' + month_traffic = month_data.get('current', 'N/A') if month_data else 'N/A' + + # Nodes + active_nodes = 0 + total_nodes = 0 + if nodes_stats and 'lastSevenDays' in nodes_stats: + unique_nodes = set() + for node_data in nodes_stats.get('lastSevenDays', []): + unique_nodes.add(node_data.get('nodeName', '')) + total_nodes = len(unique_nodes) + active_nodes = total_nodes # Assume all are active + elif system_stats and 'nodes' in system_stats: + active_nodes = system_stats.get('nodes', {}).get('totalOnline', 0) + total_nodes = active_nodes + + stats_text = _( + "inline_system_stats_message", + default="🖥 Статистика панели\n\n" + "🟢 Онлайн: {online}\n" + "📊 Активных: {active}\n" + "🔴 Отключенных: {disabled}\n" + "⏰ Истекшие: {expired}\n" + "⚠️ Ограниченные: {limited}\n" + "👥 Всего пользователей: {total}\n" + "💾 Использование RAM: {memory:.1f}%\n" + "📊 Трафик за неделю: {week_traffic}\n" + "📊 Трафик за месяц: {month_traffic}\n" + "🔗 Активных нод: {active_nodes}/{total_nodes}", + online=online_now, + active=active_users, + disabled=disabled_users, + expired=expired_users, + limited=limited_users, + total=total_users, + memory=memory_usage, + week_traffic=week_traffic, + month_traffic=month_traffic, + active_nodes=active_nodes, + total_nodes=total_nodes + ) + else: + stats_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели") + + return InlineQueryResultArticle( + id="admin_system_stats", + title=_( + "inline_admin_system_stats_title", + default="🖥 Системная статистика" + ), + description=_( + "inline_system_description", + default="🟢 Онлайн: {online}, 📊 Активных: {active}", + online=online_now, + active=active_users + ), + input_message_content=InputTextMessageContent( + message_text=stats_text, + parse_mode="HTML" + ), + thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL + ) + + except Exception as e: + logging.error(f"Error creating system stats result: {e}") + # Fallback error message + error_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели") + + return InlineQueryResultArticle( + id="admin_system_stats", + title=_( + "inline_admin_system_stats_title", + default="🖥 Системная статистика" + ), + description=_("inline_system_error", default="Ошибка получения данных"), + input_message_content=InputTextMessageContent( + message_text=error_text, + parse_mode="HTML" + ), + thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL + ) + return None + + diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 76e7c2a..a8c2463 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -20,7 +20,7 @@ from bot.services.panel_api_service import PanelApiService from bot.services.yookassa_service import YooKassaService from bot.middlewares.i18n import JsonI18n from config.settings import Settings -from bot.services.notification_service import notify_admin_new_payment +from bot.services.notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard payment_processing_lock = asyncio.Lock() @@ -195,14 +195,20 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, f"Failed to send payment details message to user {user_id}: {e_notify}" ) - await notify_admin_new_payment( - bot, - settings, - i18n, - user_id, - subscription_months, - payment_value, - ) + # Send notification about payment + try: + notification_service = NotificationService(bot, settings, i18n) + user = await user_dal.get_user_by_id(session, user_id) + await notification_service.notify_payment_received( + user_id=user_id, + amount=payment_value, + currency=settings.DEFAULT_CURRENCY_SYMBOL, + months=subscription_months, + payment_provider="yookassa", # This is specifically for YooKassa webhook + username=user.username if user else None + ) + except Exception as e: + logging.error(f"Failed to send payment notification: {e}") except Exception as e_process: logging.error( diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index f720324..18fa120 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -105,30 +105,23 @@ async def process_promo_code_input(message: types.Message, state: FSMContext, response_to_user_text = "" if is_suspicious: - - 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}" - ) + # Send notification through NotificationService if enabled + if settings.LOG_SUSPICIOUS_ACTIVITY: + try: + from bot.services.notification_service import NotificationService + notification_service = NotificationService(bot, settings, i18n) + await notification_service.notify_suspicious_promo_attempt( + user_id=user.id, + username=user.username, + first_name=user.first_name, + suspicious_input=code_input + ) + except Exception as e: + logging.error(f"Failed to send suspicious promo notification: {e}") response_to_user_text = _("promo_code_not_found", code=hcode(code_input.upper())) + reply_markup = get_back_to_main_menu_markup(current_lang, i18n) else: success, result = await promo_code_service.apply_promo_code( diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py index ea06161..7536370 100644 --- a/bot/handlers/user/referral.py +++ b/bot/handlers/user/referral.py @@ -83,11 +83,17 @@ async def referral_command_handler(event: Union[types.Message, bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _( "referral_no_bonuses_configured") + # Get referral statistics + referral_stats = await referral_service.get_referral_stats(session, inviter_user_id) + text = _("referral_program_info_new", referral_link=referral_link, - bonus_details=bonus_details_str) + bonus_details=bonus_details_str, + invited_count=referral_stats["invited_count"], + purchased_count=referral_stats["purchased_count"]) - reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n) + from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard + reply_markup_val = get_referral_link_keyboard(current_lang, i18n) if isinstance(event, types.Message): await event.answer(text, @@ -106,3 +112,37 @@ async def referral_command_handler(event: Union[types.Message, reply_markup=reply_markup_val, disable_web_page_preview=True) await event.answer() + + +@router.callback_query(F.data.startswith("referral_action:")) +async def referral_action_handler(callback: types.CallbackQuery, settings: Settings, + i18n_data: dict, referral_service: ReferralService, + bot: Bot, session: AsyncSession): + action = callback.data.split(":")[1] + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + if action == "share_message": + try: + bot_info = await bot.get_me() + bot_username = bot_info.username + if not bot_username: + await callback.answer("Ошибка получения имени бота", show_alert=True) + return + + inviter_user_id = callback.from_user.id + referral_link = referral_service.generate_referral_link(bot_username, inviter_user_id) + + friend_message = _("referral_friend_message", referral_link=referral_link) + + await callback.message.answer( + friend_message, + disable_web_page_preview=True + ) + + except Exception as e: + logging.error(f"Error in referral share message: {e}") + await callback.answer("Произошла ошибка", show_alert=True) + + await callback.answer() diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index f1db7ba..ab9c19e 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -129,6 +129,8 @@ async def start_command_handler(message: types.Message, user_id = user.id referred_by_user_id: Optional[int] = None + promo_code_to_apply: Optional[str] = None + if command and command.args: arg_payload = command.args if arg_payload.startswith("ref_"): @@ -142,6 +144,14 @@ async def start_command_handler(message: types.Message, logging.warning( f"Could not parse referral from /start args '{arg_payload}': {e}" ) + elif arg_payload.startswith("promo_"): + try: + promo_code_to_apply = arg_payload.split("_")[1] + logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") + except (IndexError, ValueError) as e: + logging.warning( + f"Could not parse promo code from /start args '{arg_payload}': {e}" + ) db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: @@ -160,6 +170,19 @@ async def start_command_handler(message: types.Message, logging.info( f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." ) + + # Send notification about new user registration + try: + from bot.services.notification_service import NotificationService + notification_service = NotificationService(message.bot, settings, i18n) + await notification_service.notify_new_user_registration( + user_id=user_id, + username=user.username, + first_name=user.first_name, + referred_by_id=referred_by_user_id + ) + except Exception as e: + logging.error(f"Failed to send new user notification: {e}") except Exception as e_create: logging.error( @@ -194,6 +217,52 @@ async def start_command_handler(message: types.Message, exc_info=True) await message.answer(_(key="welcome", user_name=hd.quote(user.full_name))) + + # Auto-apply promo code if provided via start parameter + if promo_code_to_apply: + try: + from bot.services.promo_code_service import PromoCodeService + promo_code_service = PromoCodeService() + + success, result = await promo_code_service.apply_promo_code( + session, user_id, promo_code_to_apply, current_lang + ) + + if success: + await session.commit() + logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}") + + # Get updated subscription details + active = await subscription_service.get_active_subscription_details(session, user_id) + config_link = active.get("config_link") if active else None + config_link = config_link or _("config_link_not_available") + + new_end_date = result if isinstance(result, datetime) else None + + promo_success_text = _( + "promo_code_applied_success_full", + end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"), + config_link=config_link, + ) + + from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard + await message.answer( + promo_success_text, + reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link), + parse_mode="HTML" + ) + + # Don't show main menu if promo was successfully applied + return + else: + await session.rollback() + logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}") + # Continue to show main menu if promo failed + + except Exception as e: + logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}") + await session.rollback() + await send_main_menu(message, settings, i18n_data, diff --git a/bot/handlers/user/trial_handler.py b/bot/handlers/user/trial_handler.py index ad38106..d390086 100644 --- a/bot/handlers/user/trial_handler.py +++ b/bot/handlers/user/trial_handler.py @@ -59,21 +59,94 @@ async def request_trial_confirmation_handler( 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") + # Directly activate trial without confirmation + activation_result = await subscription_service.activate_trial_subscription( + session, user_id ) - await callback.message.edit_text( - text=_( - "trial_confirm_prompt", - days=settings.TRIAL_DURATION_DAYS, - traffic_gb=traffic_gb_display, - ), - reply_markup=get_trial_confirmation_keyboard(current_lang, i18n), - ) - await callback.answer() + 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_obj = activation_result.get("end_date") + config_link_for_trial = activation_result.get("subscription_url") or _( + "config_link_not_available" + ) + + traffic_gb_val = activation_result.get( + "traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB + ) + traffic_display = ( + f"{traffic_gb_val} GB" + if traffic_gb_val and traffic_gb_val > 0 + else _("traffic_unlimited") + ) + + final_message_text_in_chat = _( + "trial_activated_details_message", + days=activation_result.get("days", settings.TRIAL_DURATION_DAYS), + end_date=( + end_date_obj.strftime("%Y-%m-%d") + if isinstance(end_date_obj, datetime) + else "N/A" + ), + config_link=config_link_for_trial, + traffic_gb=traffic_display, + ) + + # Send notification to admin about new trial + await notify_admin_new_trial( + callback.bot, + settings, + i18n, + user_id, + end_date_obj, + ) + else: + 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_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 + + 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( + current_lang, i18n, settings, show_trial_button_after_action + ), + disable_web_page_preview=True, + ) @router.callback_query(F.data == "trial_action:confirm_activate") diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 6dae708..99eb072 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -12,27 +12,103 @@ def get_admin_panel_keyboard(i18n_instance, lang: str, settings: Settings) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() + + # Статистика и мониторинг + builder.button(text=_(key="admin_stats_and_monitoring_section"), + callback_data="admin_section:stats_monitoring") + + # Управление пользователями + builder.button(text=_(key="admin_user_management_section"), + callback_data="admin_section:user_management") + + # Промокоды и маркетинг + builder.button(text=_(key="admin_promo_marketing_section"), + callback_data="admin_section:promo_marketing") + + # Системные функции + builder.button(text=_(key="admin_system_functions_section"), + callback_data="admin_section:system_functions") + + builder.adjust(1) + return builder.as_markup() + + +def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="admin_stats_button"), callback_data="admin_action:stats") - builder.button(text=_(key="admin_broadcast_button"), - callback_data="admin_action:broadcast") - builder.button(text=_(key="admin_create_promo_button"), - callback_data="admin_action:create_promo") - builder.button(text=_(key="admin_manage_promos_button"), - callback_data="admin_action:manage_promos") - builder.button(text=_(key="admin_view_promos_button"), - callback_data="admin_action:view_promos") + builder.button(text=_(key="admin_view_logs_menu_button"), + callback_data="admin_action:view_logs_menu") + + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(2, 1) + return builder.as_markup() + + +def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + builder.button(text=_(key="admin_users_management_button"), + callback_data="admin_action:users_management") + builder.button(text=_(key="admin_ban_management_section"), + callback_data="admin_section:ban_management") + + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(2, 1) + return builder.as_markup() + + +def get_ban_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="admin_ban_user_button"), callback_data="admin_action:ban_user_prompt") builder.button(text=_(key="admin_unban_user_button"), callback_data="admin_action:unban_user_prompt") builder.button(text=_(key="admin_view_banned_users_button"), callback_data="admin_action:view_banned:0") - builder.button(text=_(key="admin_view_logs_menu_button"), - callback_data="admin_action:view_logs_menu") + + builder.button(text=_(key="back_to_user_management_button"), + callback_data="admin_section:user_management") + builder.adjust(2, 1, 1) + return builder.as_markup() + + +def get_promo_marketing_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + builder.button(text=_(key="admin_create_promo_button"), + callback_data="admin_action:create_promo") + builder.button(text=_(key="admin_create_bulk_promo_button"), + callback_data="admin_action:create_bulk_promo") + builder.button(text=_(key="admin_promo_management_button"), + callback_data="admin_action:promo_management") + + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(2, 1, 1) + return builder.as_markup() + + +def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + builder.button(text=_(key="admin_broadcast_button"), + callback_data="admin_action:broadcast") builder.button(text=_(key="admin_sync_panel_button"), callback_data="admin_action:sync_panel") - builder.adjust(2, 2, 2, 2, 1) + + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(2, 1) return builder.as_markup() @@ -43,9 +119,12 @@ def get_logs_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: callback_data="admin_logs:view_all:0") builder.button(text=_(key="admin_view_user_logs_prompt_button"), callback_data="admin_logs:prompt_user") + builder.button(text=_(key="admin_export_logs_csv_button"), + callback_data="admin_logs:export_csv") builder.row( InlineKeyboardButton(text=_(key="back_to_admin_panel_button"), callback_data="admin_action:main")) + builder.adjust(2, 1, 1) return builder.as_markup() diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 49337cd..6a0898f 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -158,8 +158,11 @@ def get_referral_link_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() + builder.button(text=_(key="referral_share_message_button"), + callback_data="referral_action:share_message") builder.button(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main") + builder.adjust(1) return builder.as_markup() diff --git a/bot/main_bot.py b/bot/main_bot.py index 251cf93..1408c22 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -29,6 +29,7 @@ from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware from bot.handlers.user import user_router_aggregate from bot.handlers.admin import admin_router_aggregate +from bot.handlers import inline_mode from bot.filters.admin_filter import AdminFilter from bot.services.yookassa_service import YooKassaService @@ -78,6 +79,9 @@ class DBSessionMiddleware(BaseMiddleware): async def register_all_routers(dp: Dispatcher, settings: Settings): dp.include_router(user_router_aggregate) + + # Add inline mode router (available for all users) + dp.include_router(inline_mode.router) admin_main_router = Router(name="admin_main_filtered_router") admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS) diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index b2a40a9..8c67195 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -14,7 +14,7 @@ from bot.middlewares.i18n import JsonI18n from bot.services.subscription_service import SubscriptionService from bot.services.referral_service import ReferralService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard -from bot.services.notification_service import notify_admin_new_payment +from bot.services.notification_service import NotificationService from db.dal import payment_dal, user_dal @@ -194,15 +194,20 @@ class CryptoPayService: except Exception as e: logging.error(f"Failed to send CryptoPay success message: {e}") - await notify_admin_new_payment( - bot, - settings, - i18n, - user_id, - months, - float(invoice.amount), - currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL, - ) + # Send notification about payment + try: + notification_service = NotificationService(bot, settings, i18n) + user = await user_dal.get_user_by_id(session, user_id) + await notification_service.notify_payment_received( + user_id=user_id, + amount=float(invoice.amount), + currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL, + months=months, + payment_provider="crypto_pay", + username=user.username if user else None + ) + except Exception as e: + logging.error(f"Failed to send crypto_pay payment notification: {e}") async def webhook_route(self, request: web.Request) -> web.Response: if not self.configured or not self.client: diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 37cc534..15af8e3 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -3,13 +3,232 @@ import asyncio from aiogram import Bot from aiogram.utils.text_decorations import html_decoration as hd from datetime import datetime, timezone +from typing import Optional, Union, Dict, Any from config.settings import Settings - from sqlalchemy.orm import sessionmaker from bot.middlewares.i18n import JsonI18n +class NotificationService: + """Enhanced notification service for sending messages to admins and log channels""" + + def __init__(self, bot: Bot, settings: Settings, i18n: Optional[JsonI18n] = None): + self.bot = bot + self.settings = settings + self.i18n = i18n + + async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None): + """Send message to configured log channel/group""" + if not self.settings.LOG_CHAT_ID: + return + + try: + # Use thread_id if provided, otherwise use from settings + final_thread_id = thread_id or self.settings.LOG_THREAD_ID + + kwargs = { + "chat_id": self.settings.LOG_CHAT_ID, + "text": message, + "parse_mode": "HTML", + "disable_web_page_preview": True + } + + # Add thread ID for supergroups if specified + if final_thread_id: + kwargs["message_thread_id"] = final_thread_id + + await self.bot.send_message(**kwargs) + + except Exception as e: + logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}") + + async def _send_to_admins(self, message: str): + """Send message to all admin users""" + if not self.settings.ADMIN_IDS: + return + + for admin_id in self.settings.ADMIN_IDS: + try: + await self.bot.send_message( + chat_id=admin_id, + text=message, + parse_mode="HTML", + disable_web_page_preview=True + ) + except Exception as e: + logging.error(f"Failed to send notification to admin {admin_id}: {e}") + + async def notify_new_user_registration(self, user_id: int, username: Optional[str] = None, + first_name: Optional[str] = None, + referred_by_id: Optional[int] = None): + """Send notification about new user registration""" + if not self.settings.LOG_NEW_USERS: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k + + user_display = first_name or f"ID {user_id}" + if username: + user_display += f" (@{username})" + + referral_text = "" + if referred_by_id: + referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id) + + message = _( + "log_new_user_registration", + default="👤 Новый пользователь\n\n" + "🆔 ID: {user_id}\n" + "👤 Имя: {user_display}{referral_text}\n" + "📅 Время: {timestamp}", + user_id=user_id, + user_display=user_display, + referral_text=referral_text, + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + + # Send to log channel + await self._send_to_log_channel(message) + + async def notify_payment_received(self, user_id: int, amount: float, currency: str, + months: int, payment_provider: str, + username: Optional[str] = None): + """Send notification about successful payment""" + if not self.settings.LOG_PAYMENTS: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k + + user_display = f"ID {user_id}" + if username: + user_display += f" (@{username})" + + provider_emoji = { + "yookassa": "💳", + "cryptopay": "₿", + "stars": "⭐", + "tribute": "💎" + }.get(payment_provider.lower(), "💰") + + message = _( + "log_payment_received", + default="{provider_emoji} Получен платеж\n\n" + "👤 Пользователь: {user_display}\n" + "💰 Сумма: {amount} {currency}\n" + "📅 Период: {months} мес.\n" + "🏦 Провайдер: {payment_provider}\n" + "🕐 Время: {timestamp}", + provider_emoji=provider_emoji, + user_display=user_display, + amount=amount, + currency=currency, + months=months, + payment_provider=payment_provider, + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + + # Send to log channel + await self._send_to_log_channel(message) + + async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int, + username: Optional[str] = None): + """Send notification about promo code activation""" + if not self.settings.LOG_PROMO_ACTIVATIONS: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k + + user_display = f"ID {user_id}" + if username: + user_display += f" (@{username})" + + message = _( + "log_promo_activation", + default="🎁 Активирован промокод\n\n" + "👤 Пользователь: {user_display}\n" + "🏷 Код: {promo_code}\n" + "🎯 Бонус: +{bonus_days} дн.\n" + "🕐 Время: {timestamp}", + user_display=user_display, + promo_code=promo_code, + bonus_days=bonus_days, + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + + # Send to log channel + await self._send_to_log_channel(message) + + async def notify_trial_activation(self, user_id: int, end_date: datetime, + username: Optional[str] = None): + """Send notification about trial activation""" + if not self.settings.LOG_TRIAL_ACTIVATIONS: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k + + user_display = f"ID {user_id}" + if username: + user_display += f" (@{username})" + + message = _( + "log_trial_activation", + default="🆓 Активирован триал\n\n" + "👤 Пользователь: {user_display}\n" + "⏰ Действует до: {end_date}\n" + "🕐 Время: {timestamp}", + user_display=user_display, + end_date=end_date.strftime("%Y-%m-%d %H:%M"), + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + + # Send to log channel + await self._send_to_log_channel(message) + + async def notify_suspicious_promo_attempt( + self, user_id: int, suspicious_input: str, + username: Optional[str] = None, first_name: Optional[str] = None): + """Send notification about a suspicious promo code attempt.""" + if not self.settings.LOG_SUSPICIOUS_ACTIVITY: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext( + admin_lang, k, **kw) if self.i18n else k + + user_display = first_name or f"ID {user_id}" + if username: + user_display += f" (@{username})" + + message = _( + "log_suspicious_promo", + default="⚠️ Подозрительная попытка ввода промокода\n\n" + "👤 Пользователь: {user_display}\n" + "🆔 ID: {user_id}\n" + "📝 Ввод:
{suspicious_input}
\n" + "🕐 Время: {timestamp}", + user_display=hd.quote(user_display), + user_id=user_id, + suspicious_input=hd.quote(suspicious_input), + timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z")) + + # Send to log channel + await self._send_to_log_channel(message) + + async def send_custom_notification(self, message: str, to_admins: bool = False, + to_log_channel: bool = True, thread_id: Optional[int] = None): + """Send custom notification message""" + if to_log_channel: + await self._send_to_log_channel(message, thread_id) + if to_admins: + await self._send_to_admins(message) + + +# Legacy functions for backward compatibility async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n, message_key: str, parse_mode: str | None = None, **kwargs) -> None: @@ -26,31 +245,12 @@ async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n, async def notify_admin_new_trial(bot: Bot, settings: Settings, i18n: JsonI18n, user_id: int, end_date: datetime) -> None: - end_date_str = end_date.strftime('%Y-%m-%d') if isinstance(end_date, datetime) else str(end_date) - await notify_admins( - bot, - settings, - i18n, - "admin_new_trial_notification", - user_id=user_id, - end_date=end_date_str, - ) + """Send notification to admins about new trial activation (legacy)""" + notification_service = NotificationService(bot, settings, i18n) + await notification_service.notify_trial_activation(user_id, end_date) + -async def notify_admin_new_payment(bot: Bot, settings: Settings, i18n: JsonI18n, - user_id: int, months: int, amount: float, - currency: str | None = None) -> None: - currency_symbol = currency or settings.DEFAULT_CURRENCY_SYMBOL - await notify_admins( - bot, - settings, - i18n, - "admin_new_payment_notification", - user_id=user_id, - months=months, - amount=f"{amount:.2f}", - currency=currency_symbol, - ) async def notify_admin_promo_activation(bot: Bot, settings: Settings, @@ -65,4 +265,4 @@ async def notify_admin_promo_activation(bot: Bot, settings: Settings, user_id=user_id, code=code, bonus_days=bonus_days, - ) + ) \ No newline at end of file diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index 9ffb6d7..2f036b1 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -21,6 +21,14 @@ class PanelApiService: self.api_key = settings.PANEL_API_KEY self._session: Optional[aiohttp.ClientSession] = None self.default_client_ip = "127.0.0.1" + + async def __aenter__(self): + """Context manager entry""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - automatically close session""" + await self.close_session() async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: @@ -32,7 +40,7 @@ class PanelApiService: if self._session and not self._session.closed: await self._session.close() self._session = None - logging.info("Panel API service HTTP session closed.") + logging.debug("Panel API service HTTP session closed.") async def close(self): """Alias for close_session for API consistency.""" @@ -460,3 +468,25 @@ class PanelApiService: async def get_bot_db_last_sync_status( self, session: AsyncSession) -> Optional[PanelSyncStatus]: return await panel_sync_dal.get_panel_sync_status(session) + + + async def get_system_stats(self) -> Optional[Dict[str, Any]]: + """Get system statistics (CPU, memory, users counts)""" + response_data = await self._request("GET", "/system/stats", log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response") + return None + + async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]: + """Get bandwidth statistics""" + response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response") + return None + + async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]: + """Get nodes statistics""" + response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response") + return None \ No newline at end of file diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index ca5a426..818c04e 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -41,6 +41,70 @@ class PanelWebhookService: except Exception as e: logging.error(f"Failed to send notification to {user_id}: {e}") + async def _handle_expired_subscription(self, session, user_id: int, user_payload: dict, + lang: str, markup, first_name: str): + """Handle expired subscription - auto-renew tribute users if no cancellation was received""" + from db.dal import subscription_dal, payment_dal + from datetime import datetime, timezone, timedelta + + try: + # Check if user has tribute subscriptions that weren't cancelled + user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id) + + for sub in user_subs: + # Check if this subscription was marked as cancelled (from tribute cancellation webhook) + if sub.status_from_panel == 'CANCELLED': + logging.info(f"Subscription {sub.subscription_id} for user {user_id} was cancelled, skipping auto-renewal") + continue + + # Check if this user has tribute payments + last_tribute_duration = await payment_dal.get_last_tribute_payment_duration(session, user_id) + + if last_tribute_duration is not None: + # This user has tribute payments, auto-renew for the same duration + logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months") + + # Extend subscription by the last payment duration + new_end_date = datetime.now(timezone.utc) + timedelta(days=last_tribute_duration * 30) + + await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + 'end_date': new_end_date, + 'status_from_panel': 'ACTIVE', + 'is_active': True + } + ) + + # Send auto-renewal notification + _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k + auto_renewal_msg = _( + "tribute_auto_renewal", + default="🔄 Подписка автоматически продлена\n\n" + "Ваша подписка Tribute была автоматически продлена на {months} мес.\n" + "Новая дата окончания: {end_date}", + user_name=first_name, + months=last_tribute_duration, + end_date=new_end_date.strftime('%Y-%m-%d') + ) + + try: + await self.bot.send_message( + user_id, + auto_renewal_msg, + reply_markup=markup, + parse_mode="HTML" + ) + except Exception as e: + logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}") + + await session.commit() + + except Exception as e: + logging.error(f"Error handling expired subscription for user {user_id}: {e}") + await session.rollback() + async def handle_event(self, event_name: str, user_payload: dict): telegram_id = user_payload.get("telegramId") if not telegram_id: @@ -70,6 +134,9 @@ class PanelWebhookService: end_date=user_payload.get("expireAt", "")[:10], ) elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: + # Check if this is a tribute user that should be auto-renewed + await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name) + await self._send_message( user_id, lang, diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py index a9bee97..6aaaca8 100644 --- a/bot/services/promo_code_service.py +++ b/bot/services/promo_code_service.py @@ -11,7 +11,7 @@ from db.models import PromoCode, User from .subscription_service import SubscriptionService from bot.middlewares.i18n import JsonI18n -from .notification_service import notify_admin_promo_activation +from .notification_service import NotificationService class PromoCodeService: @@ -62,14 +62,19 @@ class PromoCodeService: session, promo_data.promo_code_id) if activation_recorded and promo_incremented: - await notify_admin_promo_activation( - self.bot, - self.settings, - self.i18n, - user_id, - code_input_upper, - bonus_days, - ) + # Send notification about promo activation + try: + notification_service = NotificationService(self.bot, self.settings, self.i18n) + user = await user_dal.get_user_by_id(session, user_id) + await notification_service.notify_promo_activation( + user_id=user_id, + promo_code=code_input_upper, + bonus_days=bonus_days, + username=user.username if user else None + ) + except Exception as e: + logging.error(f"Failed to send promo activation notification: {e}") + return True, new_end_date else: diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py index 30681d8..1be6063 100644 --- a/bot/services/referral_service.py +++ b/bot/services/referral_service.py @@ -1,5 +1,6 @@ import logging from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text from typing import Optional, Dict, Any from aiogram import Bot from datetime import datetime, timezone, timedelta @@ -219,3 +220,39 @@ class ReferralService: def generate_referral_link(self, bot_username: str, inviter_user_id: int) -> str: return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}" + + async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict: + """Get referral statistics for a user""" + from db.dal import user_dal, payment_dal + + try: + # Count total invited users (referrals) + invited_count_result = await session.execute( + text("SELECT COUNT(*) FROM users WHERE referred_by_id = :user_id"), + {"user_id": user_id} + ) + invited_count = invited_count_result.scalar() or 0 + + # Count users who made successful payments (purchased subscription) + purchased_count_result = await session.execute( + text(""" + SELECT COUNT(DISTINCT u.user_id) + FROM users u + JOIN payments p ON u.user_id = p.user_id + WHERE u.referred_by_id = :user_id + AND p.status = 'succeeded' + """), + {"user_id": user_id} + ) + purchased_count = purchased_count_result.scalar() or 0 + + return { + "invited_count": invited_count, + "purchased_count": purchased_count + } + except Exception as e: + logging.error(f"Error getting referral stats for user {user_id}: {e}") + return { + "invited_count": 0, + "purchased_count": 0 + } diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index 524547b..548babe 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -10,7 +10,7 @@ from db.dal import payment_dal, user_dal from .subscription_service import SubscriptionService from .referral_service import ReferralService from bot.middlewares.i18n import JsonI18n -from .notification_service import notify_admin_new_payment +from .notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard @@ -153,13 +153,18 @@ class StarsService: logging.error( f"Failed to send stars payment success message: {e_send}") - await notify_admin_new_payment( - self.bot, - self.settings, - self.i18n, - message.from_user.id, - months, - float(stars_amount), - currency="XTR", - ) + # Send notification about payment + try: + notification_service = NotificationService(self.bot, self.settings, self.i18n) + user = await user_dal.get_user_by_id(session, message.from_user.id) + await notification_service.notify_payment_received( + user_id=message.from_user.id, + amount=float(stars_amount), + currency="XTR", + months=months, + payment_provider="stars", + username=user.username if user else None + ) + except Exception as e: + logging.error(f"Failed to send stars payment notification: {e}") diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index ffcb678..0be251b 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -482,7 +482,7 @@ class SubscriptionService: "status_from_panel": "ACTIVE", "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, "provider": provider, - "skip_notifications": provider == "tribute", + "skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS, } try: new_or_updated_sub = await subscription_dal.upsert_subscription( diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py index c9d6882..6e2029a 100644 --- a/bot/services/tribute_service.py +++ b/bot/services/tribute_service.py @@ -13,7 +13,7 @@ from bot.middlewares.i18n import JsonI18n from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService from bot.services.referral_service import ReferralService -from .notification_service import notify_admin_new_payment +from .notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from db.dal import payment_dal, user_dal, subscription_dal @@ -187,19 +187,89 @@ class TributeService: logging.error( f"Failed to send Tribute payment success message to user {user_id}: {e}") - await notify_admin_new_payment( - bot, - settings, - i18n, - user_id, - months, - float(price_rub), - currency="RUB", - ) + # Send notification about payment + try: + notification_service = NotificationService(bot, settings, i18n) + user = await user_dal.get_user_by_id(session, user_id) + await notification_service.notify_payment_received( + user_id=user_id, + amount=float(price_rub), + currency="RUB", + months=months, + payment_provider="tribute", + username=user.username if user else None + ) + except Exception as e: + logging.error(f"Failed to send tribute payment notification: {e}") + + elif event_name == 'subscription_cancelled': + # Handle tribute subscription cancellation + await self._handle_tribute_cancellation(session, user_id, bot, i18n) + else: await session.commit() return web.Response(status=200, text="ok") + async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n): + """Handle tribute subscription cancellation - set subscription to 1 day grace period""" + from datetime import datetime, timezone, timedelta + from db.dal import subscription_dal, user_dal + from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup + + try: + # Set all user's subscriptions to expire in 1 day (grace period) + grace_end_date = datetime.now(timezone.utc) + timedelta(days=1) + + # Get all active subscriptions for the user + user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id) + + for sub in user_subs: + await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + 'end_date': grace_end_date, + 'status_from_panel': 'CANCELLED', + 'skip_notifications': True # Skip future notifications for cancelled subs + } + ) + + await session.commit() + + # Send notification about cancellation if enabled + if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: + db_user = await user_dal.get_user_by_id(session, user_id) + lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE + first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}" + + _ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k + markup = get_subscribe_only_markup(lang, i18n) + + cancellation_msg = _( + "tribute_subscription_cancelled", + default="🚨 Подписка отменена\n\n" + "Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, " + "после чего подписка будет заблокирована.\n\n" + "Для продления подписки нажмите кнопку ниже.", + user_name=first_name + ) + + try: + await bot.send_message( + user_id, + cancellation_msg, + reply_markup=markup, + parse_mode="HTML" + ) + except Exception as e: + logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}") + + logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day") + + except Exception as e: + logging.error(f"Error handling tribute cancellation for user {user_id}: {e}") + await session.rollback() + async def tribute_webhook_route(request: web.Request): """AIOHTTP route handler for Tribute webhook calls.""" diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py index 854bc6c..330e53e 100644 --- a/bot/states/admin_states.py +++ b/bot/states/admin_states.py @@ -6,8 +6,25 @@ class AdminStates(StatesGroup): waiting_for_broadcast_message = State() confirming_broadcast = State() waiting_for_promo_details = State() + waiting_for_promo_code = State() + waiting_for_promo_bonus_days = State() + waiting_for_promo_max_activations = State() + waiting_for_promo_validity_days = State() waiting_for_promo_edit_details = State() + waiting_for_promo_edit_code = State() + waiting_for_promo_edit_bonus_days = State() + waiting_for_promo_edit_max_activations = State() + waiting_for_promo_edit_validity_days = State() + waiting_for_bulk_promo_quantity = State() + waiting_for_bulk_promo_bonus_days = State() + waiting_for_bulk_promo_max_activations = State() + waiting_for_bulk_promo_validity_days = State() waiting_for_user_id_to_ban = State() waiting_for_user_id_to_unban = State() waiting_for_user_id_for_logs = State() + + # User management states + waiting_for_user_search = State() + waiting_for_subscription_days_to_add = State() + waiting_for_direct_message_to_user = State() diff --git a/config/settings.py b/config/settings.py index e410eb5..aec0979 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, ValidationError, computed_field +from pydantic import Field, ValidationError, computed_field, field_validator from typing import Optional, List, Dict, Any @@ -66,6 +66,8 @@ class Settings(BaseSettings): TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None) TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None) TRIBUTE_API_KEY: Optional[str] = Field(default=None) + TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments") + TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments") PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None) SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True) @@ -111,6 +113,12 @@ class Settings(BaseSettings): SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None) START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None) + + # Inline mode thumbnail URLs + INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png") + INLINE_USER_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/681/681494.png") + INLINE_FINANCIAL_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/2769/2769339.png") + INLINE_SYSTEM_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/2920/2920277.png") @computed_field @property @@ -288,6 +296,25 @@ class Settings(BaseSettings): if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None: bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS return bonuses + + # Logging Configuration + LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications") + LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)") + + @field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before') + @classmethod + def validate_optional_int_fields(cls, v): + """Convert empty strings to None for optional integer fields""" + if isinstance(v, str) and v.strip() == '': + return None + return v + + # Notification types + LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations") + LOG_PAYMENTS: bool = Field(default=True, description="Send notifications for successful payments") + LOG_PROMO_ACTIVATIONS: bool = Field(default=True, description="Send notifications for promo code activations") + LOG_TRIAL_ACTIVATIONS: bool = Field(default=True, description="Send notifications for trial activations") + LOG_SUSPICIOUS_ACTIVITY: bool = Field(default=True, description="Send notifications for suspicious promo attempts") model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index b530212..394e43a 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -38,14 +38,6 @@ async def create_payment_record(session: AsyncSession, 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_provider_payment_id( session: AsyncSession, provider_payment_id: str) -> Optional[Payment]: """Fetch a payment by provider-specific identifier.""" @@ -64,15 +56,6 @@ async def get_payment_by_db_id(session: AsyncSession, 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, @@ -96,38 +79,6 @@ async def update_payment_status_by_db_id( return payment -async def user_has_successful_payment_for_provider( - session: AsyncSession, user_id: int, provider: str) -> bool: - """Check if a user has at least one successful payment for the provider.""" - - stmt = (select(Payment.payment_id) - .where(Payment.user_id == user_id, - Payment.provider == provider, - Payment.status == 'succeeded') - .limit(1)) - result = await session.execute(stmt) - return result.scalar_one_or_none() is not None - - -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]: @@ -155,3 +106,81 @@ async def update_provider_payment_and_status( f"Payment record with DB ID {payment_db_id} not found for provider update." ) return payment + + +async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]: + """Get comprehensive financial statistics.""" + from datetime import datetime, timedelta + from sqlalchemy import and_, text + + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = today_start - timedelta(days=7) + month_start = today_start - timedelta(days=30) + + # Today's revenue + stmt_today = select(func.sum(Payment.amount)).where( + and_( + Payment.status == 'succeeded', + Payment.created_at >= today_start + ) + ) + today_revenue = await session.execute(stmt_today) + today_amount = today_revenue.scalar() or 0 + + # Week revenue + stmt_week = select(func.sum(Payment.amount)).where( + and_( + Payment.status == 'succeeded', + Payment.created_at >= week_start + ) + ) + week_revenue = await session.execute(stmt_week) + week_amount = week_revenue.scalar() or 0 + + # Month revenue + stmt_month = select(func.sum(Payment.amount)).where( + and_( + Payment.status == 'succeeded', + Payment.created_at >= month_start + ) + ) + month_revenue = await session.execute(stmt_month) + month_amount = month_revenue.scalar() or 0 + + # All time revenue + stmt_all = select(func.sum(Payment.amount)).where(Payment.status == 'succeeded') + all_revenue = await session.execute(stmt_all) + all_amount = all_revenue.scalar() or 0 + + # Count of successful payments today + stmt_count_today = select(func.count(Payment.payment_id)).where( + and_( + Payment.status == 'succeeded', + Payment.created_at >= today_start + ) + ) + today_count = await session.execute(stmt_count_today) + today_payments_count = today_count.scalar() or 0 + + return { + "today_revenue": float(today_amount), + "week_revenue": float(week_amount), + "month_revenue": float(month_amount), + "all_time_revenue": float(all_amount), + "today_payments_count": today_payments_count + } + + +async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]: + """Get duration in months from the last successful tribute payment for a user.""" + stmt = select(Payment.subscription_duration_months).where( + and_( + Payment.user_id == user_id, + Payment.provider == 'tribute', + Payment.status == 'succeeded' + ) + ).order_by(Payment.created_at.desc()).limit(1) + + result = await session.execute(stmt) + return result.scalar_one_or_none() diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py index df3ea56..5d1f017 100644 --- a/db/dal/promo_code_dal.py +++ b/db/dal/promo_code_dal.py @@ -26,6 +26,13 @@ async def get_promo_code_by_id(session: AsyncSession, return await session.get(PromoCode, promo_code_id) +async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]: + """Get promo code by code string (regardless of active status)""" + stmt = select(PromoCode).where(PromoCode.code == code_str.upper()) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + async def get_active_promo_code_by_code_str( session: AsyncSession, code_str: str) -> Optional[PromoCode]: stmt = select(PromoCode).where( @@ -49,6 +56,33 @@ async def get_all_active_promo_codes(session: AsyncSession, return result.scalars().all() +async def get_all_promo_codes_with_details(session: AsyncSession, limit: int = 50, + offset: int = 0) -> List[PromoCode]: + """Get all promo codes (active and inactive) with pagination for management""" + stmt = (select(PromoCode).order_by( + PromoCode.created_at.desc()).limit(limit).offset(offset)) + result = await session.execute(stmt) + return result.scalars().all() + + +async def get_promo_code_by_id(session: AsyncSession, promo_id: int) -> Optional[PromoCode]: + """Get promo code by ID""" + stmt = select(PromoCode).where(PromoCode.promo_code_id == promo_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def get_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int) -> List: + """Get activation history for a specific promo code""" + from db.models import PromoCodeActivation + stmt = (select(PromoCodeActivation) + .where(PromoCodeActivation.promo_code_id == promo_code_id) + .order_by(PromoCodeActivation.activated_at.desc()) + .limit(50)) # Limit to last 50 activations + result = await session.execute(stmt) + return result.scalars().all() + + async def update_promo_code(session: AsyncSession, promo_id: int, update_data: Dict[str, Any]) -> Optional[PromoCode]: promo = await get_promo_code_by_id(session, promo_id) diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py index 105303c..91ede4a 100644 --- a/db/dal/subscription_dal.py +++ b/db/dal/subscription_dal.py @@ -31,27 +31,14 @@ async def get_subscription_by_panel_subscription_uuid( 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 get_active_subscriptions_for_user(session: AsyncSession, user_id: int) -> List[Subscription]: + """Get all active subscriptions for a user.""" + stmt = select(Subscription).where( + Subscription.user_id == user_id, + Subscription.is_active == True + ).order_by(Subscription.end_date.desc()) + result = await session.execute(stmt) + return result.scalars().all() async def update_subscription( @@ -144,6 +131,18 @@ async def deactivate_all_user_subscriptions( return result.rowcount +async def delete_all_user_subscriptions( + session: AsyncSession, user_id: int) -> int: + """Completely delete all user subscriptions (for trial reset)""" + stmt = delete(Subscription).where(Subscription.user_id == user_id) + result = await session.execute(stmt) + if result.rowcount > 0: + logging.info( + f"Deleted {result.rowcount} subscription records for user {user_id} for trial reset." + ) + return result.rowcount + + async def update_subscription_end_date( session: AsyncSession, subscription_id: int, new_end_date: datetime) -> Optional[Subscription]: @@ -193,17 +192,6 @@ async def update_subscription_notification_time( {"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]: @@ -222,34 +210,4 @@ async def find_subscription_for_notification_update( return result.scalar_one_or_none() -async def set_skip_notifications_for_provider( - session: AsyncSession, user_id: int, provider: str, - skip: bool) -> int: - stmt = (update(Subscription).where( - Subscription.user_id == user_id, - Subscription.is_active == True, - Subscription.provider == provider).values(skip_notifications=skip)) - result = await session.execute(stmt) - return result.rowcount - -async def get_active_subscriptions_for_autorenew( - session: AsyncSession, provider: str, - days_threshold: int = 1, - require_skip_flag: bool = True) -> List[Subscription]: - """Fetch active subscriptions nearing expiration for auto-renew logic.""" - - now_utc = datetime.now(timezone.utc) - threshold_date = now_utc + timedelta(days=days_threshold) - - conditions = [ - Subscription.provider == provider, - Subscription.is_active == True, - Subscription.end_date <= threshold_date, - ] - if require_skip_flag: - conditions.append(Subscription.skip_notifications == True) - - stmt = select(Subscription).where(*conditions) - result = await session.execute(stmt) - return result.scalars().all() diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py index 70d3c57..af8f58c 100644 --- a/db/dal/user_dal.py +++ b/db/dal/user_dal.py @@ -81,36 +81,15 @@ async def update_user_language( 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 = ( +async def get_banned_users(session: AsyncSession) -> List[User]: + """Get all banned users""" + stmt = ( 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 + result = await session.execute(stmt) + return result.scalars().all() async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]: @@ -119,33 +98,74 @@ async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[i 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, - } - - async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]: stmt = select(User).where(User.panel_user_uuid.is_not(None)) result = await session.execute(stmt) return result.scalars().all() + + +async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: + """Get comprehensive user statistics including active users, trial users, etc.""" + from datetime import datetime, timedelta + + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + # Total users + total_users_stmt = select(func.count(User.user_id)) + total_users = (await session.execute(total_users_stmt)).scalar() or 0 + + # Banned users + banned_users_stmt = select(func.count(User.user_id)).where(User.is_banned == True) + banned_users = (await session.execute(banned_users_stmt)).scalar() or 0 + + # Active users today (users with login activity - for now using registration as proxy) + active_today_stmt = select(func.count(User.user_id)).where( + User.registration_date >= today_start + ) + active_today = (await session.execute(active_today_stmt)).scalar() or 0 + + # Users with active paid subscriptions + paid_subs_stmt = ( + select(func.count(func.distinct(Subscription.user_id))) + .join(User, Subscription.user_id == User.user_id) + .where( + and_( + Subscription.is_active == True, + Subscription.end_date > now, + Subscription.provider.is_not(None) # Not trial + ) + ) + ) + paid_subs_users = (await session.execute(paid_subs_stmt)).scalar() or 0 + + # Users on trial period + trial_subs_stmt = ( + select(func.count(func.distinct(Subscription.user_id))) + .join(User, Subscription.user_id == User.user_id) + .where( + and_( + Subscription.is_active == True, + Subscription.end_date > now, + Subscription.provider.is_(None) # Trial subscriptions + ) + ) + ) + trial_users = (await session.execute(trial_subs_stmt)).scalar() or 0 + + # Inactive users (no active subscription) + inactive_users = total_users - paid_subs_users - trial_users - banned_users + + # Users attracted via referral + referral_users_stmt = select(func.count(User.user_id)).where(User.referred_by_id.is_not(None)) + referral_users = (await session.execute(referral_users_stmt)).scalar() or 0 + + return { + "total_users": total_users, + "banned_users": banned_users, + "active_today": active_today, + "paid_subscriptions": paid_subs_users, + "trial_users": trial_users, + "inactive_users": max(0, inactive_users), + "referral_users": referral_users + } diff --git a/locales/en.json b/locales/en.json index aac840b..6b8405c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -77,11 +77,13 @@ "no_button": "No", "trial_cancelled_short": "Activation cancelled.", - "referral_program_info_new": "Invite friends and earn bonuses!\n\nYour referral link:\n{referral_link}\n\nBonuses:{bonus_details}", + "referral_program_info_new": "🎁 Referral Program\n\n📊 Your stats:\n👥 Friends invited: {invited_count}\n💳 Purchased subscription: {purchased_count}\n\n🔗 Your link:\n{referral_link}\n\n💰 Invitation bonuses:\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!", "referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: {inviter_bonus_days} days\n ➢ Friend: {referee_bonus_days} days", "no_bonus_days": "0", "referral_no_bonuses_configured": "\nNo referral bonuses configured.", "referral_link_for_copying_reminder": "The link is above. Press and hold to copy.", + "referral_share_message_button": "📩 Message for friend", + "referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}", "friend_placeholder": "friend", "referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.", @@ -92,19 +94,30 @@ "admin_stats_button": "📊 Statistics", "admin_broadcast_button": "📢 Broadcast", "admin_create_promo_button": "🎁 Create Promo", - "admin_manage_promos_button": "🛠 Manage Promos", - "admin_view_promos_button": "👀 Promo List", - "admin_ban_user_button": "🚫 Ban User", - "admin_unban_user_button": "✅ Unban User", + "admin_create_bulk_promo_button": "📦 Bulk Create", + "admin_manage_promos_button": "🛠 Manage", + "admin_view_promos_button": "👀 List", + "admin_ban_user_button": "🚫 Ban", + "admin_unban_user_button": "✅ Unban", "admin_view_banned_users_button": "📜 Ban List", "admin_view_logs_menu_button": "📄 Logs", "admin_sync_panel_button": "🔄 Sync", "admin_unknown_action": "Unknown admin action.", + + "admin_stats_and_monitoring_section": "📊 Statistics", + "admin_user_management_section": "👥 Users", + "admin_promo_marketing_section": "🎁 Promos", + "admin_system_functions_section": "⚙️ System", + "admin_ban_management_section": "🚫 Bans", + "admin_users_management_button": "👤 Management", + "back_to_user_management_button": "⬅️ To Users", "admin_action_cancelled_default": "Action cancelled. Returning to menu.", "admin_action_cancelled_default_alert": "Action cancelled", - "back_to_admin_panel_button": "⬅️ Back to Admin", + "back_to_admin_panel_button": "⬅️ To Admin", "admin_stats_header": "📊 Bot Statistics", + "admin_enhanced_users_stats_header": "Users", + "admin_financial_stats_header": "Financial Statistics", "admin_stats_users": "👥 Users: Total - {total_users}, Banned - {banned_users}, Active Subs - {active_subs}", "admin_stats_recent_payments_header": "Recent Payments:", "admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]", @@ -193,6 +206,7 @@ "admin_logs_menu_title": "Logs Menu:", "admin_view_all_logs_button": "📜 All Message Logs", "admin_view_user_logs_prompt_button": "👤 User Logs", + "admin_export_logs_csv_button": "📄 Export to CSV", "admin_all_logs_title": "All Logs (page {current_page}/{total_pages}):", "admin_no_logs_found": "No logs found.", "admin_log_entry_format": "{timestamp_str} - {user_display} (ID: {user_id})\n {event_type}: {content_preview}", @@ -218,10 +232,248 @@ "subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.", + "tribute_subscription_cancelled": "🚨 Subscription Cancelled\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.", + "tribute_auto_renewal": "🔄 Subscription Auto-Renewed\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}", "admin_new_trial_notification": "\ud83c\udf21 User {user_id} activated a free trial until {end_date}.", - "admin_new_payment_notification": "\ud83d\udcb3 Payment received from user {user_id}: {months} mo. for {amount} {currency}.", + "admin_promo_activation_notification": "\ud83c\udf81 Promo code {code} activated by user {user_id} (+{bonus_days}d).", - "error_unknown": "An unknown error occurred." + "error_unknown": "An unknown error occurred.", + + "admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:", + "admin_user_card_title": "User Card", + "admin_user_subscription_info": "Subscription Information:", + "admin_user_reset_trial_button": "🔄 Reset Trial", + "admin_user_add_subscription_button": "➕ Add Days", + "admin_user_toggle_ban_button": "🚫 Block/Unblock", + "admin_user_send_message_button": "✉️ Send Message", + "admin_user_view_logs_button": "📜 User Actions", + "admin_user_refresh_button": "🔄 Refresh", + "admin_user_search_new_button": "🔍 Find Another", + "admin_user_view_all_logs_button": "📋 All Actions", + "admin_user_back_to_card_button": "🔙 Back to Card", + + "admin_user_not_found": "❌ User not found: {input}", + "admin_user_not_found_action": "User not found", + "admin_user_card_error": "❌ Error displaying user card", + "admin_user_trial_reset_success": "✅ Trial reset! User can activate trial again.", + "admin_user_trial_reset_error": "❌ Error resetting trial", + "admin_user_add_subscription_prompt": "➕ Adding subscription days for user {user_id}\n\nEnter number of days to add:", + "admin_user_invalid_days": "❌ Invalid number of days. Enter number from 1 to 3650.", + "admin_user_subscription_added_success": "✅ Successfully added {days} days to user {user_id}", + "admin_user_subscription_added_error": "❌ Error adding subscription days", + "admin_user_ban_toggle_success": "✅ User {status}", + "admin_user_ban_toggle_error": "❌ Error changing ban status", + "admin_user_ban_success": "✅ User {input} has been banned", + "admin_user_unban_success": "✅ User {input} has been unbanned", + "admin_user_ban_error": "❌ Error banning user", + "admin_user_unban_error": "❌ Error unbanning user", + "admin_user_not_banned": "⚠️ User is not banned", + "admin_user_send_message_prompt": "✉️ Sending message to user {user_id}\n\nEnter message text:", + "admin_user_message_too_long": "❌ Message too long (maximum 4000 characters)", + "admin_user_message_sent_success": "✅ Message sent to user {user_id}", + "admin_user_message_sent_error": "❌ Error sending message", + "admin_user_no_logs": "📜 User has no actions", + "admin_user_logs_error": "❌ Error loading user actions", + "admin_direct_message_signature": "\n\n---\n💬 Message from administrator", + + "inline_user_stats_message": "👥 User Statistics\n\n📊 Total: {total}\n📈 Active today: {active_today}\n💳 With paid subscription: {paid}\n🆓 On trial period: {trial}\n😴 Inactive: {inactive}\n🚫 Banned: {banned}\n🎁 Via referral program: {referral}", + "inline_admin_user_stats_title": "👥 User Statistics", + "inline_admin_user_stats_desc": "Total: {total}, Active: {paid}", + + "inline_financial_stats_message": "💰 Financial Statistics\n\n📅 Today: {today:.2f} RUB\n ({today_count} payments)\n📅 Week: {week:.2f} RUB\n📅 Month: {month:.2f} RUB\n🏆 All time: {all_time:.2f} RUB", + "inline_admin_financial_stats_title": "💰 Financial Statistics", + "inline_admin_financial_stats_desc": "Today: {today:.2f} RUB", + + "inline_system_stats_message": "🖥 Panel Statistics\n\n🟢 Online: {online}\n📊 Active: {active}\n🔴 Disabled: {disabled}\n⏰ Expired: {expired}\n⚠️ Limited: {limited}\n👥 Total users: {total}\n💾 RAM Usage: {memory:.1f}%\n📊 Week traffic: {week_traffic}\n📊 Month traffic: {month_traffic}\n🔗 Active nodes: {active_nodes}/{total_nodes}", + "inline_admin_system_stats_title": "🖥 System Statistics", + "inline_admin_system_stats_desc": "Online: {online}, Offline: {offline}", + + "inline_admin_help_message": "🤖 Bot Inline Mode\n\n📱 Available commands:\n\n🎁 ref - share referral link\n👥 stat - user statistics\n💰 financial - financial statistics\n🖥 system - system statistics\n\n💡 Just type @{bot_username} and start typing a command in any chat!", + "inline_admin_help_title": "🤖 Inline Help (Admin)", + "inline_admin_help_desc": "Available commands: ref, stat, financial, system", + + "inline_user_help_message": "🤖 Bot Inline Mode\n\n📱 Available commands:\n\n🎁 ref - share referral link\n\n💡 Just type @{bot_username} and start typing 'ref' in any chat!", + "inline_user_help_title": "🤖 Inline Help", + "inline_user_help_desc": "Available command: ref (referral link)", + + "log_referral_suffix": " (referral from {referrer_id})", + "log_new_user_registration": "👤 New User\n\n🆔 ID: {user_id}\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}", + "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", + "log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}", + "log_trial_activation": "🆓 Trial Activated\n\n👤 User: {user_display}\n⏰ Valid until: {end_date}\n🕐 Time: {timestamp}", + "log_suspicious_promo": "⚠️ Suspicious Promo Code Attempt\n\n👤 User: {user_display}\n🆔 ID: {user_id}\n📝 Input:
{suspicious_input}
\n🕐 Time: {timestamp}", + + "admin_general_cancel_operation": "Operation cancelled ❌", + "admin_promo_codes_menu_bulk_create": "📦 Bulk Creation", + "admin_bulk_promo_create_prompt": "📦 Bulk Promo Code Creation\n\nEnter data in format:\nquantity|days|description\n\nExample:\n10|7|Weekly promo", + "admin_bulk_promo_invalid_format": "❌ Invalid format. Use: quantity|days|description", + "admin_bulk_promo_created_success": "✅ Created {count} promo codes for {days} days!", + "admin_bulk_promo_codes_file": "📄 All created promo codes in file ({count} pcs.)", + + "admin_promo_edit_step1_code": "✏️ Editing Promo Code\n\nStep 1 of 4: Promo Code\n\nCurrent code: {current_code}\n\nEnter new promo code (3-30 characters, letters and numbers only) or send current one to keep:", + "admin_promo_edit_step2_bonus_days": "✏️ Editing Promo Code\n\nStep 2 of 4: Bonus Days\n\nCode: {code}\nCurrent days: {current_days}\n\nEnter number of bonus days (1-365):", + "admin_promo_edit_step3_max_activations": "✏️ Editing Promo Code\n\nStep 3 of 4: Max Activations\n\nCode: {code}\nDays: {days}\nCurrent activations: {current_max}\n\nEnter maximum number of activations (1-10000):", + "admin_promo_edit_step4_validity": "✏️ Editing Promo Code\n\nStep 4 of 4: Validity Period\n\nCode: {code}\nDays: {days}\nActivations: {max_act}\nCurrent validity: {current_validity}\n\nChoose the validity period for the promo code:", + "admin_promo_edit_unlimited_validity": "♾️ Unlimited", + "admin_promo_edit_set_validity": "⏰ Set Period", + "admin_promo_edit_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):", + "admin_promo_updated_success": "✅ Promo code updated!\n\n🎟 Code: {code}\n🎁 Bonus days: {days}\n🔢 Max activations: {max_act}\n⏰ Valid until: {validity}", + "admin_promo_update_failed": "❌ Error updating promo code", + + "admin_logs_export_csv": "📄 Export to CSV", + "admin_logs_csv_export_started": "📄 Starting log export to CSV...", + "admin_logs_csv_export_success": "✅ Logs exported! File attached above.", + + "admin_csv_header_log_id": "Log ID", + "admin_csv_header_timestamp": "Timestamp", + "admin_csv_header_user_id": "User ID", + "admin_csv_header_telegram_username": "Username", + "admin_csv_header_telegram_first_name": "First Name", + "admin_csv_header_event_type": "Event Type", + "admin_csv_header_content": "Content", + "admin_csv_header_is_admin_event": "Admin Event", + "admin_csv_header_target_user_id": "Target User ID", + "admin_csv_header_raw_update_preview": "Update Preview", + + "admin_banned_users_empty": "📋 Banned Users\n\nList is empty", + "admin_banned_users_list": "📋 Banned Users ({count}):\n\n{users}", + + "admin_panel_stats_header": "Panel Statistics", + + "admin_bulk_promo_step1_quantity": "📦 Bulk Promo Code Creation\n\nStep 1 of 4: Quantity\n\nEnter the number of promo codes to create (1-1000):", + "admin_bulk_promo_step2_bonus_days": "📦 Bulk Promo Code Creation\n\nStep 2 of 4: Bonus Days\n\nQuantity: {quantity}\n\nEnter the number of bonus days (1-365):", + "admin_bulk_promo_step3_max_activations": "📦 Bulk Promo Code Creation\n\nStep 3 of 4: Max Activations\n\nQuantity: {quantity}\nBonus days: {bonus_days}\n\nEnter the maximum number of activations for each promo code (1-10000):", + "admin_bulk_promo_step4_validity": "📦 Bulk Promo Code Creation\n\nStep 4 of 4: Validity Period\n\nQuantity: {quantity}\nBonus days: {bonus_days}\nMax activations: {max_activations}\n\nChoose the validity period for promo codes:", + + "admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000", + "admin_bulk_promo_invalid_bonus_days": "❌ Bonus days must be between 1 and 365", + "admin_bulk_promo_invalid_max_activations": "❌ Max activations must be between 1 and 10000", + + "admin_bulk_promo_unlimited_validity": "♾️ Unlimited", + "admin_bulk_promo_set_validity": "⏰ Set Period", + "admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):", + + "admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...", + + "admin_promo_step1_code": "🎟 Create Promo Code\n\nStep 1 of 4: Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):", + "admin_promo_step2_bonus_days": "🎟 Create Promo Code\n\nStep 2 of 4: Bonus Days\n\nCode: {code}\n\nEnter the number of bonus days (1-365):", + "admin_promo_step3_max_activations": "🎟 Create Promo Code\n\nStep 3 of 4: Max Activations\n\nCode: {code}\nBonus days: {bonus_days}\n\nEnter the maximum number of activations (1-10000):", + "admin_promo_step4_validity": "🎟 Create Promo Code\n\nStep 4 of 4: Validity Period\n\nCode: {code}\nBonus days: {bonus_days}\nMax activations: {max_activations}\n\nChoose the validity period for the promo code:", + + "admin_promo_code_already_exists": "❌ A promo code with this code already exists", + "admin_promo_processing_error": "❌ Error processing promo code", + "admin_promo_unlimited_validity": "♾️ Unlimited", + "admin_promo_set_validity": "⏰ Set Period", + "admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):", + "admin_promo_skip_description": "⏭️ Skip", + "admin_promo_description_too_long": "❌ Description must not exceed 200 characters", + "admin_promo_creating": "⏳ Creating promo code...", + + "admin_panel_back_button": "⬅️ Back", + "admin_bulk_promo_creation_failed": "❌ Error creating promo codes: {error}", + + "admin_user_id_label": "🆔 ID:", + "admin_user_name_label": "👤 Name:", + "admin_user_username_label": "📱 Username:", + "admin_user_language_label": "🌍 Language:", + "admin_user_registration_label": "📅 Registration:", + "admin_user_status_label": "🛡 Status:", + "admin_user_referral_label": "🎁 Referred by:", + "admin_user_panel_uuid_label": "🔗 Panel UUID:", + "admin_user_panel_status_label": "📊 Panel Status:", + "admin_user_traffic_label": "📊 Traffic:", + "admin_user_subscription_label": "💼 Subscription:", + "admin_user_trial_label": "🏡 Trial:", + + "admin_user_status_banned": "🚫 Banned", + "admin_user_status_active": "✅ Active", + "admin_user_trial_used": "Used", + "admin_user_trial_not_used": "Not used", + "admin_user_ban_action_banned": "banned", + "admin_user_ban_action_unbanned": "unbanned", + "admin_user_na_value": "N/A", + + "admin_user_subscription_active": "Active until {end_date}", + "admin_user_subscription_expired": "Expired {end_date}", + "admin_user_subscription_none": "No active subscription", + + "admin_user_logs_count": "Total entries: {count}", + "admin_user_logs_entry": "📅 {timestamp}\n📝 {event_type}: {content}", + + "admin_user_actions_count_label": "📜 Total actions:", + "admin_user_subscription_active_until": "⏰ Active until:", + "admin_user_subscription_error": "Loading error", + + "admin_bulk_promo_unique_generation_failed": "Failed to create unique promo code", + + "admin_promo_management_button": "🎟 Promo Management", + "admin_promo_management_title": "🎟 Promo Code Management\n\nSelect a promo code for detailed view:", + "admin_promo_management_empty": "📭 No promo codes available", + "admin_promo_card_title": "🎟 Promo Code: {code}", + "admin_promo_card_bonus_days": "🎁 Bonus days: {days}", + "admin_promo_card_activations": "🔢 Activations: {current}/{max}", + "admin_promo_card_validity": "⏰ Valid until: {validity}", + "admin_promo_card_status": "📊 Status: {status}", + "admin_promo_card_created": "📅 Created: {created}", + "admin_promo_card_created_by": "👤 Created by: {creator}", + "admin_promo_status_active": "✅ Active", + "admin_promo_status_inactive": "🚫 Inactive", + "admin_promo_status_activated": "activated", + "admin_promo_status_deactivated": "deactivated", + "admin_promo_status_expired": "⏰ Expired", + "admin_promo_status_used_up": "🔄 Used up", + "admin_promo_toggle_status_button": "🔄 On/Off", + "admin_promo_view_activations_button": "📋 Activations", + "admin_promo_back_to_list_button": "⬅️ Back to list", + "admin_promo_toggle_success": "✅ Promo code {code} {status}", + "admin_promo_toggle_failed": "❌ Error changing promo code status", + "admin_promo_no_activations": "📋 Activations of promo code: {code}\n\n❌ No activations found", + "admin_promo_activations_header": "📋 Activations of promo code: {code}\n\n", + "admin_promo_activation_item": "👤 User ID: {user_id}\n📅 Date: {date}\n", + "admin_promo_back_to_detail_button": "⬅️ Back to promo", + + "admin_panel_online_label": "Online", + "admin_panel_active_label": "Active", + "admin_panel_disabled_label": "Disabled", + "admin_panel_offline_label": "Offline", + "admin_panel_expired_label": "Expired", + "admin_panel_limited_label": "Limited", + "admin_panel_total_users_label": "Total users", + "admin_panel_cpu_usage_label": "CPU Usage", + "admin_panel_memory_usage_label": "RAM Usage", + "admin_panel_traffic_today_label": "Traffic today", + "admin_panel_traffic_week_label": "Traffic this week", + "admin_panel_traffic_month_label": "Traffic this month", + "admin_panel_nodes_label": "Active nodes", + "admin_panel_system_stats_error": "Error getting system statistics", + "admin_panel_bandwidth_stats_error": "Error getting bandwidth statistics", + "admin_panel_nodes_stats_error": "Error getting nodes statistics", + "admin_panel_stats_fetch_error": "Error fetching panel data", + "admin_panel_stats_error_details": "Details", + + "inline_referral_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}", + "inline_referral_title": "Referral link", + "inline_referral_description": "Share referral link to get bonuses", + "inline_stats_title": "Bot statistics", + "inline_stats_description": "Total: {total}, Active: {active}", + "inline_financial_title": "Financial statistics", + "inline_financial_description": "Today: {today} RUB", + "inline_system_title": "System statistics", + "inline_system_description": "🟢 Online: {online}, 📊 Active: {active}", + "inline_admin_help_title": "Commands help", + "inline_admin_help_description": "Available commands: ref, stat, financial, system", + + "admin_user_stats_total_label": "Total", + "admin_user_stats_paid_subs_label": "With paid subscription", + "admin_user_stats_trial_label": "On trial period", + "admin_user_stats_inactive_label": "Inactive", + "admin_user_stats_banned_label": "Banned", + "admin_user_stats_referral_label": "Attracted via referral program", + "admin_financial_today_label": "Today", + "admin_financial_week_label": "This week", + "admin_financial_month_label": "This month", + "admin_financial_all_time_label": "All time", + "admin_financial_payments_label": "payments" } diff --git a/locales/ru.json b/locales/ru.json index 1c9c209..9f50ed9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -77,11 +77,13 @@ "no_button": "Нет", "trial_cancelled_short": "Активация отменена.", - "referral_program_info_new": "Приглашайте друзей и получайте бонусы!\n\nВаша реф. ссылка:\n{referral_link}\n\nБонусы:{bonus_details}", + "referral_program_info_new": "🎁 Реферальная программа\n\n📊 Твоя статистика:\n👥 Приглашено друзей: {invited_count}\n💳 Купили подписку: {purchased_count}\n\n🔗 Твоя ссылка:\n{referral_link}\n\n💰 Бонусы за приглашения:\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!", "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_share_message_button": "📩 Сообщение для друга", + "referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", "friend_placeholder": "друг", "referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.", @@ -92,19 +94,30 @@ "admin_stats_button": "📊 Статистика", "admin_broadcast_button": "📢 Рассылка", "admin_create_promo_button": "🎁 Создать промо", - "admin_manage_promos_button": "🛠 Управление промо", - "admin_view_promos_button": "👀 Список промо", + "admin_create_bulk_promo_button": "📦 Массовое создание", + "admin_manage_promos_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_stats_and_monitoring_section": "📊 Статистика", + "admin_user_management_section": "👥 Пользователи", + "admin_promo_marketing_section": "🎁 Промокоды", + "admin_system_functions_section": "⚙️ Система", + "admin_ban_management_section": "🚫 Блокировки", + "admin_users_management_button": "👤 Управление", + "back_to_user_management_button": "⬅️ К пользователям", "admin_action_cancelled_default": "Действие отменено. Возврат в меню.", "admin_action_cancelled_default_alert": "Действие отменено", "back_to_admin_panel_button": "⬅️ В админку", "admin_stats_header": "📊 Статистика Бота", + "admin_enhanced_users_stats_header": "Пользователи", + "admin_financial_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}]", @@ -193,6 +206,7 @@ "admin_logs_menu_title": "Меню логов:", "admin_view_all_logs_button": "📜 Все логи сообщений", "admin_view_user_logs_prompt_button": "👤 Логи пользователя", + "admin_export_logs_csv_button": "📄 Экспорт в CSV", "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}", @@ -218,10 +232,247 @@ "subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.", "subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.", + "tribute_subscription_cancelled": "🚨 Подписка отменена\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.", + "tribute_auto_renewal": "🔄 Подписка автоматически продлена\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}", "admin_new_trial_notification": "\ud83c\udf21 Пользователь {user_id} активировал пробный период до {end_date}.", - "admin_new_payment_notification": "\ud83d\udcb3 Получен платеж от пользователя {user_id}: {months} мес. за {amount} {currency}.", + "admin_promo_activation_notification": "\ud83c\udf81 Пользователь {user_id} активировал промокод {code} (+{bonus_days} дн.)", - "error_unknown": "Произошла неизвестная ошибка." + "error_unknown": "Произошла неизвестная ошибка.", + + "admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:", + "admin_user_card_title": "Карточка пользователя", + "admin_user_subscription_info": "Информация о подписке:", + "admin_user_reset_trial_button": "🔄 Сбросить триал", + "admin_user_add_subscription_button": "➕ Добавить дни", + "admin_user_toggle_ban_button": "🚫 Бан/Разбан", + "admin_user_send_message_button": "✉️ Сообщение", + "admin_user_view_logs_button": "📜 Логи", + "admin_user_refresh_button": "🔄 Обновить", + "admin_user_search_new_button": "🔍 Новый поиск", + "admin_user_view_all_logs_button": "📋 Все логи", + "admin_user_back_to_card_button": "🔙 К карточке", + + "admin_user_not_found": "❌ Пользователь не найден: {input}", + "admin_user_not_found_action": "Пользователь не найден", + "admin_user_card_error": "❌ Ошибка отображения карточки пользователя", + "admin_user_trial_reset_success": "✅ Триал сброшен! Пользователь может активировать триал заново.", + "admin_user_trial_reset_error": "❌ Ошибка сброса триала", + "admin_user_add_subscription_prompt": "➕ Добавление дней подписки для пользователя {user_id}\n\nВведите количество дней для добавления:", + "admin_user_invalid_days": "❌ Неверное количество дней. Введите число от 1 до 3650.", + "admin_user_subscription_added_success": "✅ Успешно добавлено {days} дней подписки пользователю {user_id}", + "admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки", + "admin_user_ban_toggle_success": "✅ Пользователь {status}", + "admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки", + "admin_user_ban_success": "✅ Пользователь {input} заблокирован", + "admin_user_unban_success": "✅ Пользователь {input} разблокирован", + "admin_user_ban_error": "❌ Ошибка блокировки пользователя", + "admin_user_unban_error": "❌ Ошибка разблокировки пользователя", + "admin_user_not_banned": "⚠️ Пользователь не заблокирован", + "admin_user_send_message_prompt": "✉️ Отправка сообщения пользователю {user_id}\n\nВведите текст сообщения:", + "admin_user_message_too_long": "❌ Сообщение слишком длинное (максимум 4000 символов)", + "admin_user_message_sent_success": "✅ Сообщение отправлено пользователю {user_id}", + "admin_user_message_sent_error": "❌ Ошибка отправки сообщения", + "admin_user_no_logs": "📜 У пользователя нет действий", + "admin_user_logs_error": "❌ Ошибка загрузки действий пользователя", + "admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора", + + "inline_user_stats_message": "📊 Статистика Бота\n👥 Пользователи\n\n📊 Всего: {total}\n💳 С платной подпиской: {paid}\n🆓 На пробном периоде: {trial}\n😴 Неактивных: {inactive}\n🚫 Заблокированных: {banned}\n🎁 Привлечено по реферальной программе: {referral}", + "inline_user_stats_description": "Всего: {total}, Платных: {active}", + "inline_admin_user_stats_title": "👥 Статистика пользователей", + "inline_admin_user_stats_desc": "Всего: {total}, Активных: {paid}", + + "inline_financial_stats_message": "💰 Финансовая статистика\n\n📅 За сегодня: {today:.2f} RUB\n ({today_count} платежей)\n📅 За неделю: {week:.2f} RUB\n📅 За месяц: {month:.2f} RUB\n🏆 За все время: {all_time:.2f} RUB", + "inline_admin_financial_stats_title": "💰 Финансовая статистика", + "inline_admin_financial_stats_desc": "Сегодня: {today:.2f} RUB", + + "inline_system_stats_message": "🖥 Статистика панели\n\n🟢 Онлайн: {online}\n📊 Активных: {active}\n🔴 Отключенных: {disabled}\n⏰ Истекшие: {expired}\n⚠️ Ограниченные: {limited}\n👥 Всего пользователей: {total}\n💾 Использование RAM: {memory:.1f}%\n📊 Трафик за неделю: {week_traffic}\n📊 Трафик за месяц: {month_traffic}\n🔗 Активных нод: {active_nodes}/{total_nodes}", + "inline_admin_system_stats_title": "🖥 Системная статистика", + "inline_admin_system_stats_desc": "Онлайн: {online}, Офлайн: {offline}", + + "inline_admin_help_message": "🤖 Inline режим бота\n\n📱 Доступные команды:\n\n🎁 реф/ref - поделиться реферальной ссылкой\n👥 стат/stat - статистика пользователей\n💰 финансы - финансовая статистика\n🖥 система - системная статистика\n\n💡 Просто напишите @{bot_username} и начните вводить команду в любом чате!", + + "inline_user_help_message": "🤖 Inline режим бота\n\n📱 Доступные команды:\n\n🎁 реф/ref - поделиться реферальной ссылкой\n\n💡 Просто напишите @{bot_username} и начните вводить 'реф' в любом чате!", + "inline_user_help_title": "🤖 Inline помощь", + "inline_user_help_desc": "Доступна команда: реф (реферальная ссылка)", + + "log_referral_suffix": " (реферал от {referrer_id})", + "log_new_user_registration": "👤 Новый пользователь\n\n🆔 ID: {user_id}\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}", + "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", + "log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}", + "log_trial_activation": "🆓 Активирован триал\n\n👤 Пользователь: {user_display}\n⏰ Действует до: {end_date}\n🕐 Время: {timestamp}", + "log_suspicious_promo": "⚠️ Подозрительная попытка ввода промокода\n\n👤 Пользователь: {user_display}\n🆔 ID: {user_id}\n📝 Ввод:
{suspicious_input}
\n🕐 Время: {timestamp}", + + "admin_general_cancel_operation": "Операция отменена ❌", + "admin_promo_codes_menu_bulk_create": "📦 Массовое создание", + "admin_bulk_promo_create_prompt": "📦 Массовое создание промокодов\n\nВведите данные в формате:\nколичество|дни|описание\n\nПример:\n10|7|Промо на неделю", + "admin_bulk_promo_invalid_format": "❌ Неверный формат. Используйте: количество|дни|описание", + "admin_bulk_promo_created_success": "✅ Создано {count} промокодов на {days} дней!", + "admin_bulk_promo_codes_file": "📄 Все созданные промокоды в файле ({count} шт.)", + + "admin_promo_edit_step1_code": "✏️ Редактирование промокода\n\nШаг 1 из 4: Код промокода\n\nТекущий код: {current_code}\n\nВведите новый код промокода (3-30 символов, только буквы и цифры) или отправьте текущий для сохранения:", + "admin_promo_edit_step2_bonus_days": "✏️ Редактирование промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\nТекущие дни: {current_days}\n\nВведите количество бонусных дней (1-365):", + "admin_promo_edit_step3_max_activations": "✏️ Редактирование промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nДни: {days}\nТекущие активации: {current_max}\n\nВведите максимальное количество активаций (1-10000):", + "admin_promo_edit_step4_validity": "✏️ Редактирование промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nДни: {days}\nАктивации: {max_act}\nТекущий срок: {current_validity}\n\nВыберите срок действия промокода:", + "admin_promo_edit_unlimited_validity": "♾️ Неограниченно", + "admin_promo_edit_set_validity": "⏰ Установить срок", + "admin_promo_edit_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):", + "admin_promo_updated_success": "✅ Промокод обновлен!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", + "admin_promo_update_failed": "❌ Ошибка обновления промокода", + + "admin_logs_export_csv": "📄 Экспорт в CSV", + "admin_logs_csv_export_started": "📄 Начинаю экспорт логов в CSV...", + "admin_logs_csv_export_success": "✅ Логи экспортированы! Файл прикреплен выше.", + + "admin_csv_header_log_id": "ID Лога", + "admin_csv_header_timestamp": "Время", + "admin_csv_header_user_id": "ID Пользователя", + "admin_csv_header_telegram_username": "Username", + "admin_csv_header_telegram_first_name": "Имя", + "admin_csv_header_event_type": "Тип события", + "admin_csv_header_content": "Содержание", + "admin_csv_header_is_admin_event": "Админ событие", + "admin_csv_header_target_user_id": "ID Целевого юзера", + "admin_csv_header_raw_update_preview": "Превью обновления", + + "admin_banned_users_empty": "📋 Заблокированные пользователи\n\nСписок пуст", + "admin_banned_users_list": "📋 Заблокированные пользователи ({count}):\n\n{users}", + + "admin_panel_stats_header": "Статистика панели", + + "admin_bulk_promo_step1_quantity": "📦 Массовое создание промокодов\n\nШаг 1 из 4: Количество промокодов\n\nВведите количество промокодов для создания (1-1000):", + "admin_bulk_promo_step2_bonus_days": "📦 Массовое создание промокодов\n\nШаг 2 из 4: Бонусные дни\n\nКоличество: {quantity}\n\nВведите количество бонусных дней (1-365):", + "admin_bulk_promo_step3_max_activations": "📦 Массовое создание промокодов\n\nШаг 3 из 4: Максимальные активации\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций для каждого промокода (1-10000):", + "admin_bulk_promo_step4_validity": "📦 Массовое создание промокодов\n\nШаг 4 из 4: Срок действия\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокодов:", + + "admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000", + "admin_bulk_promo_invalid_bonus_days": "❌ Бонусные дни должны быть от 1 до 365", + "admin_bulk_promo_invalid_max_activations": "❌ Максимальные активации должны быть от 1 до 10000", + + "admin_bulk_promo_unlimited_validity": "♾️ Неограниченно", + "admin_bulk_promo_set_validity": "⏰ Установить срок", + "admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):", + + "admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...", + + "admin_promo_step1_code": "🎟 Создание промокода\n\nШаг 1 из 4: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):", + "admin_promo_step2_bonus_days": "🎟 Создание промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", + "admin_promo_step3_max_activations": "🎟 Создание промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", + "admin_promo_step4_validity": "🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", + + "admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует", + "admin_promo_processing_error": "❌ Ошибка обработки промокода", + "admin_promo_unlimited_validity": "♾️ Неограниченно", + "admin_promo_set_validity": "⏰ Установить срок", + "admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):", + "admin_promo_skip_description": "⏭️ Пропустить", + "admin_promo_description_too_long": "❌ Описание не должно превышать 200 символов", + "admin_promo_creating": "⏳ Создаю промокод...", + + "admin_panel_back_button": "⬅️ Назад", + "admin_bulk_promo_creation_failed": "❌ Ошибка создания промокодов: {error}", + + "admin_user_id_label": "🆔 ID:", + "admin_user_name_label": "👤 Имя:", + "admin_user_username_label": "📱 Username:", + "admin_user_language_label": "🌍 Язык:", + "admin_user_registration_label": "📅 Регистрация:", + "admin_user_status_label": "🛡 Статус:", + "admin_user_referral_label": "🎁 Привлечен по реферальной программе от:", + "admin_user_panel_uuid_label": "🔗 Panel UUID:", + "admin_user_panel_status_label": "📊 Статус на панели:", + "admin_user_traffic_label": "📊 Трафик:", + "admin_user_subscription_label": "💼 Подписка:", + "admin_user_trial_label": "🏡 Триал:", + + "admin_user_status_banned": "🚫 Заблокирован", + "admin_user_status_active": "✅ Активен", + "admin_user_trial_used": "Использовал", + "admin_user_trial_not_used": "Не использовал", + "admin_user_ban_action_banned": "заблокирован", + "admin_user_ban_action_unbanned": "разблокирован", + "admin_user_na_value": "N/A", + + "admin_user_subscription_active": "Активна до {end_date}", + "admin_user_subscription_expired": "Истекла {end_date}", + "admin_user_subscription_none": "Нет активной подписки", + + "admin_user_logs_count": "Всего записей: {count}", + "admin_user_logs_entry": "📅 {timestamp}\n📝 {event_type}: {content}", + + "admin_user_actions_count_label": "📜 Всего действий:", + "admin_user_subscription_active_until": "⏰ Действует до:", + "admin_user_subscription_error": "Ошибка загрузки", + + "admin_bulk_promo_unique_generation_failed": "Не удалось создать уникальный промокод", + + "admin_promo_management_button": "🎟 Управление промокодами", + "admin_promo_management_title": "🎟 Управление промокодами\n\nВыберите промокод для детального просмотра:", + "admin_promo_management_empty": "📭 Промокоды отсутствуют", + "admin_promo_card_title": "🎟 Промокод: {code}", + "admin_promo_card_bonus_days": "🎁 Бонусные дни: {days}", + "admin_promo_card_activations": "🔢 Активации: {current}/{max}", + "admin_promo_card_validity": "⏰ Действует до: {validity}", + "admin_promo_card_status": "📊 Статус: {status}", + "admin_promo_card_created": "📅 Создан: {created}", + "admin_promo_card_created_by": "👤 Создал: {creator}", + "admin_promo_status_active": "✅ Активен", + "admin_promo_status_inactive": "🚫 Неактивен", + "admin_promo_status_activated": "активирован", + "admin_promo_status_deactivated": "деактивирован", + "admin_promo_status_expired": "⏰ Истек", + "admin_promo_status_used_up": "🔄 Исчерпан", + "admin_promo_toggle_status_button": "🔄 Вкл/Выкл", + "admin_promo_view_activations_button": "📋 Активации", + "admin_promo_back_to_list_button": "⬅️ К списку", + "admin_promo_toggle_success": "✅ Промокод {code} {status}", + "admin_promo_toggle_failed": "❌ Ошибка изменения статуса промокода", + "admin_promo_no_activations": "📋 Активации промокода: {code}\n\n❌ Активаций не найдено", + "admin_promo_activations_header": "📋 Активации промокода: {code}\n\n", + "admin_promo_activation_item": "👤 User ID: {user_id}\n📅 Дата: {date}\n", + "admin_promo_back_to_detail_button": "⬅️ К промокоду", + + "admin_panel_online_label": "Онлайн", + "admin_panel_active_label": "Активных", + "admin_panel_disabled_label": "Отключенных", + "admin_panel_offline_label": "Офлайн", + "admin_panel_expired_label": "Истекшие", + "admin_panel_limited_label": "Ограниченные", + "admin_panel_total_users_label": "Всего пользователей", + "admin_panel_cpu_usage_label": "Загрузка CPU", + "admin_panel_memory_usage_label": "Использование RAM", + "admin_panel_traffic_today_label": "Трафик сегодня", + "admin_panel_traffic_week_label": "Трафик за неделю", + "admin_panel_traffic_month_label": "Трафик за месяц", + "admin_panel_nodes_label": "Активных нод", + "admin_panel_system_stats_error": "Ошибка получения системной статистики", + "admin_panel_bandwidth_stats_error": "Ошибка получения статистики трафика", + "admin_panel_nodes_stats_error": "Ошибка получения статистики нод", + "admin_panel_stats_fetch_error": "Ошибка получения данных с панели", + "admin_panel_stats_error_details": "Детали", + + "inline_referral_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", + "inline_referral_title": "Реферальная ссылка", + "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов", + "inline_stats_title": "Статистика бота", + "inline_stats_description": "Всего: {total}, Активных: {active}", + "inline_financial_title": "Финансовая статистика", + "inline_financial_description": "Сегодня: {today} RUB", + "inline_system_title": "Системная статистика", + "inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}", + "inline_admin_help_title": "Помощь по командам", + "inline_admin_help_description": "Доступны команды: реф, стат, финансы, система", + + "admin_user_stats_total_label": "Всего", + "admin_user_stats_paid_subs_label": "С платной подпиской", + "admin_user_stats_trial_label": "На пробном периоде", + "admin_user_stats_inactive_label": "Неактивных", + "admin_user_stats_banned_label": "Заблокированных", + "admin_user_stats_referral_label": "Привлечено по реферальной программе", + "admin_financial_today_label": "За сегодня", + "admin_financial_week_label": "За неделю", + "admin_financial_month_label": "За месяц", + "admin_financial_all_time_label": "За все время", + "admin_financial_payments_label": "платежей" }