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": "платежей"
}