diff --git a/.github/workflows/dev-docker-build.yml b/.github/workflows/dev-docker-build.yml
new file mode 100644
index 0000000..eb0706f
--- /dev/null
+++ b/.github/workflows/dev-docker-build.yml
@@ -0,0 +1,56 @@
+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: 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/common.py b/bot/handlers/admin/common.py
index e23df9d..0592fdb 100644
--- a/bot/handlers/admin/common.py
+++ b/bot/handlers/admin/common.py
@@ -6,7 +6,11 @@ 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
@@ -75,6 +79,9 @@ async def admin_panel_actions_callback_handler(
elif action == "create_promo":
await admin_promo_handlers.create_promo_prompt_handler(
callback, state, i18n_data, settings, session)
+ elif action == "create_bulk_promo":
+ await admin_promo_handlers.create_bulk_promo_prompt_handler(
+ callback, state, i18n_data, settings, session)
elif action == "manage_promos":
await admin_promo_handlers.manage_promo_codes_handler(
callback, i18n_data, settings, session)
@@ -87,6 +94,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(
@@ -121,3 +132,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_codes.py b/bot/handlers/admin/promo_codes.py
index af4fecd..227d239 100644
--- a/bot/handlers/admin/promo_codes.py
+++ b/bot/handlers/admin/promo_codes.py
@@ -1,4 +1,6 @@
import logging
+import random
+import string
from aiogram import Router, F, types, Bot
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
@@ -340,6 +342,7 @@ async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict,
StateFilter(
AdminStates.waiting_for_promo_details,
AdminStates.waiting_for_promo_edit_details,
+ AdminStates.waiting_for_bulk_promo_details,
),
)
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
@@ -365,3 +368,219 @@ async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
await callback.answer(_("admin_action_cancelled_default_alert"))
await state.clear()
+
+
+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)
+
+ prompt_text = _(
+ "admin_bulk_promo_create_prompt",
+ default="📦 Массовое создание промокодов\n\nВведите данные в формате:\n<количество> <бонусные_дни> <максимальное_использование> [дни_действия]\n\nПример: 50 7 100 30\n(создаст 50 промокодов на 7 дней с лимитом 100 активаций каждый, действующих 30 дней)",
+ example_format="50 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 bulk 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_bulk_promo_details)
+
+
+def generate_unique_promo_code(length: int = 8) -> str:
+ """Generate a unique promotional code"""
+ characters = string.ascii_uppercase + string.digits
+ # Exclude confusing characters
+ characters = characters.replace('0', '').replace('O', '').replace('1', '').replace('I', '').replace('L', '')
+ return ''.join(random.choice(characters) for _ in range(length))
+
+
+@router.message(AdminStates.waiting_for_bulk_promo_details, F.text)
+async def process_bulk_promo_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_bulk_promo_invalid_format",
+ default="❌ Неверный формат. Используйте: <количество> <бонусные_дни> <максимальное_использование> [дни_действия]"
+ ))
+ return
+
+ try:
+ count = int(parts[0])
+ bonus_days = int(parts[1])
+ max_activations = int(parts[2])
+
+ if count <= 0 or count > 1000:
+ raise ValueError(_("admin_bulk_promo_invalid_count", default="Количество должно быть от 1 до 1000"))
+
+ if bonus_days <= 0 or max_activations <= 0:
+ raise ValueError(_("admin_promo_invalid_bonus_or_activations"))
+
+ 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'))
+
+ except ValueError as e:
+ await message.answer(_(
+ "admin_bulk_promo_invalid_values",
+ default="❌ Неверные значения: {error}",
+ error=str(e)
+ ))
+ return
+ except Exception as e_parse:
+ logging.error(f"Error parsing bulk 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
+
+ # Generate unique codes and create promo codes
+ created_codes = []
+ failed_codes = []
+
+ await message.answer(_(
+ "admin_bulk_promo_creating",
+ default="🔄 Создаю {count} промокодов...",
+ count=count
+ ))
+
+ for i in range(count):
+ try:
+ # Generate unique code
+ attempts = 0
+ while attempts < 10: # Max 10 attempts to generate unique code
+ code = generate_unique_promo_code()
+
+ # Check if code already exists
+ existing_promo = await promo_code_dal.get_promo_code_by_code(session, code)
+ if not existing_promo:
+ break
+ attempts += 1
+
+ if attempts >= 10:
+ failed_codes.append(f"Failed to generate unique code #{i+1}")
+ continue
+
+ promo_data_to_create = {
+ "code": code,
+ "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
+ }
+
+ created_promo = await promo_code_dal.create_promo_code(
+ session, promo_data_to_create)
+
+ if created_promo:
+ created_codes.append(created_promo.code)
+ else:
+ failed_codes.append(f"Failed to create code #{i+1}")
+
+ except Exception as e:
+ logging.error(f"Error creating bulk promo code #{i+1}: {e}")
+ failed_codes.append(f"Error creating code #{i+1}: {str(e)}")
+
+ try:
+ await session.commit()
+
+ # Prepare success message
+ success_text_parts = [
+ _(
+ "admin_bulk_promo_created_success",
+ default="✅ Массовое создание завершено!\n\n📦 Создано промокодов: {created_count}\n💎 Бонусных дней: {bonus_days}\n🔄 Макс. активаций каждого: {max_activations}\n⏰ Действительны до: {valid_until}",
+ created_count=len(created_codes),
+ bonus_days=bonus_days,
+ max_activations=max_activations,
+ valid_until=valid_until_str_display
+ )
+ ]
+
+ if failed_codes:
+ success_text_parts.append(f"\n❌ Ошибок: {len(failed_codes)}")
+
+ if created_codes:
+ # Show first few codes as examples
+ codes_to_show = created_codes[:10] # Show first 10
+ success_text_parts.append(f"\n📝 Примеры созданных кодов:")
+ success_text_parts.append("\n".join([f"• {code}" for code in codes_to_show]))
+
+ if len(created_codes) > 10:
+ success_text_parts.append(f"... и еще {len(created_codes) - 10} кодов")
+
+ # Send codes as a file if many were created
+ if len(created_codes) > 20:
+ try:
+ codes_text = "\n".join(created_codes)
+ codes_file = types.BufferedInputFile(
+ codes_text.encode('utf-8'),
+ filename=f"promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
+ )
+ await message.answer_document(
+ codes_file,
+ caption=_(
+ "admin_bulk_promo_codes_file",
+ default="📄 Файл со всеми созданными промокодами"
+ )
+ )
+ except Exception as e:
+ logging.error(f"Failed to send codes file: {e}")
+
+ final_text = "\n".join(success_text_parts)
+ await message.answer(
+ final_text,
+ reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
+
+ except Exception as e_db_commit:
+ await session.rollback()
+ logging.error(f"Failed to commit bulk promo codes creation: {e_db_commit}", exc_info=True)
+ await message.answer(_(
+ "admin_bulk_promo_creation_failed",
+ default="❌ Ошибка при сохранении промокодов в базу данных"
+ ))
+
+ await state.clear()
+
+
+
diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py
index 616ea09..93d7b32 100644
--- a/bot/handlers/admin/statistics.py
+++ b/bot/handlers/admin/statistics.py
@@ -29,13 +29,52 @@ 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"📊 Всего: {user_stats['total_users']}"
+ )
+ stats_text_parts.append(
+ f"📈 Активных сегодня: {user_stats['active_today']}"
+ )
+ stats_text_parts.append(
+ f"💳 С платной подпиской: {user_stats['paid_subscriptions']}"
+ )
+ stats_text_parts.append(
+ f"🆓 На пробном периоде: {user_stats['trial_users']}"
+ )
+ stats_text_parts.append(
+ f"😴 Неактивных: {user_stats['inactive_users']}"
+ )
+ stats_text_parts.append(
+ f"🚫 Заблокированных: {user_stats['banned_users']}"
+ )
+ stats_text_parts.append(
+ f"🎁 Привлечено по реферальной программе: {user_stats['referral_users']}"
+ )
+
+ # 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"📅 За сегодня: {financial_stats['today_revenue']:.2f} RUB ({financial_stats['today_payments_count']} платежей)"
+ )
+ stats_text_parts.append(
+ f"📅 За неделю: {financial_stats['week_revenue']:.2f} RUB"
+ )
+ stats_text_parts.append(
+ f"📅 За месяц: {financial_stats['month_revenue']:.2f} RUB"
+ )
+ stats_text_parts.append(
+ f"🏆 За все время: {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..16e7b2e 100644
--- a/bot/handlers/admin/user_management.py
+++ b/bot/handlers/admin/user_management.py
@@ -1,545 +1,644 @@
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]:
+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 preparing user management.", show_alert=True)
+ return
+ _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
+
+ 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)
+ )
+ except Exception as e:
+ 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)
+ )
+
+ 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
+ user_name = user.first_name or "N/A"
+ username_display = f"@{user.username}" if user.username else "N/A"
+ registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else "N/A"
+
+ card_parts.append(f"🆔 ID: {hcode(str(user.user_id))}")
+ card_parts.append(f"👤 Имя: {hcode(user_name)}")
+ card_parts.append(f"📱 Username: {hcode(username_display)}")
+ card_parts.append(f"🌍 Язык: {hcode(user.language_code or 'N/A')}")
+ card_parts.append(f"📅 Регистрация: {hcode(registration_date)}")
+
+ # Ban status
+ ban_status = "🚫 Заблокирован" if user.is_banned else "✅ Активен"
+ card_parts.append(f"🛡 Статус: {ban_status}")
+
+ # Referral info
+ if user.referred_by_id:
+ card_parts.append(f"🎁 Привлечен по реферальной программе от: {hcode(str(user.referred_by_id))}")
+
+ # Panel info
+ if user.panel_user_uuid:
+ card_parts.append(f"🔗 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"⏰ Действует до: {hcode(end_date_str)}")
+
+ status = subscription_details.get('status_from_panel', 'UNKNOWN')
+ card_parts.append(f"📊 Статус на панели: {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"📊 Трафик: {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}")
+ else:
+ card_parts.append(f"💳 Подписка: {hcode('Отсутствует')}")
+ except Exception as e:
+ logging.error(f"Error getting subscription details for user {user.user_id}: {e}")
+ card_parts.append(f"💳 Подписка: {hcode('Ошибка загрузки')}")
+
+ # Statistics
+ try:
+ # Count user logs
+ logs_count = await message_log_dal.count_user_message_logs(session, user.user_id)
+ card_parts.append(f"📜 Всего действий: {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 = "Использовал" if had_subscriptions else "Не использовал"
+ card_parts.append(f"🆓 Триал: {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:
- return await user_dal.get_user(session, user_id=int(input_text))
+ user_model = await user_dal.get_user_by_id(session, 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
+ 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)
-
-async def ban_user_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 processing ban prompt.", show_alert=True)
+ if not user_model:
+ await message.answer(_(
+ "admin_user_not_found",
+ default="❌ Пользователь не найден: {input}",
+ input=hcode(input_text)
+ ))
return
- _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- prompt_text = _("admin_ban_user_prompt")
+ # 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:
- await callback.message.edit_text(
- prompt_text,
- reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
+ 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.warning(
- f"Edit failed for ban_user_prompt: {e}. Sending new message.")
- await callback.message.answer(
- prompt_text,
- reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
-
- await callback.answer()
- await state.set_state(AdminStates.waiting_for_user_id_to_ban)
+ logging.error(f"Error displaying user card for {user_model.user_id}: {e}")
+ await message.answer(_(
+ "admin_user_card_error",
+ default="❌ Ошибка отображения карточки пользователя"
+ ))
-@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):
+@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 message.reply("Language service error.")
+ await callback.answer("Language service error.", show_alert=True)
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()
+ # 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
- 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))
+ 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 session.rollback()
- await message.answer(_("admin_user_ban_failed_local_db_error"))
-
- await state.clear()
+ await callback.answer(_("admin_unknown_action"), show_alert=True)
-async def unban_user_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 processing unban prompt.",
- show_alert=True)
- return
- _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
-
- prompt_text = _("admin_unban_user_prompt")
+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:
- await callback.message.edit_text(
- prompt_text,
- reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
+ # Deactivate all user subscriptions
+ await subscription_dal.deactivate_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.warning(
- f"Edit failed for unban_user_prompt: {e}. Sending new message.")
- await callback.message.answer(
- prompt_text,
- reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
-
- await callback.answer()
- await state.set_state(AdminStates.waiting_for_user_id_to_unban)
-
-
-@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):
- 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_unban_model = await _get_user_model_by_input(session, input_text)
-
- if not user_to_unban_model:
- await message.answer(
- _("admin_user_not_found_in_bot_db", user_id=input_text))
- await state.clear()
- return
-
- user_id_to_unban = user_to_unban_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:
+ logging.error(f"Error resetting trial for user {user.user_id}: {e}")
await session.rollback()
- await message.answer(_("admin_user_unban_failed_local_db_error"))
-
- await state.clear()
+ await callback.answer(_(
+ "admin_user_trial_reset_error",
+ default="❌ Ошибка сброса триала"
+ ), show_alert=True)
-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:
- current_page_idx = int(callback.data.split(":")[-1])
- except ValueError:
- current_page_idx = 0
-
- current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
- i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n 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))
- 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):
+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:
-
- _, 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:
- if user_model.is_banned:
- await callback.answer(_("admin_user_already_banned",
- user_id_or_username=user_display_name),
- show_alert=True)
- return
- if user_id_target == callback.from_user.id or user_id_target in settings.ADMIN_IDS:
- await callback.answer(_("admin_cannot_ban_self_or_admin"),
- show_alert=True)
- return
- else:
- if not user_model.is_banned:
- await callback.answer(_("admin_user_not_banned",
- user_id_or_username=user_display_name),
- show_alert=True)
- return
-
- action_success_local = await user_dal.set_user_ban_status(
- session, user_id_target, is_banning)
-
- if action_success_local:
- panel_action_message = ""
- if user_model.panel_user_uuid:
- panel_api_success = await panel_service.update_user_status_on_panel(
- user_model.panel_user_uuid, enable=not is_banning)
- if not panel_api_success:
- panel_action_message = _("admin_panel_status_update_fail_part")
- logging.warning(
- f"Panel status update failed for {action_type} of user {user_id_target} (panel: {user_model.panel_user_uuid})"
- )
-
- await session.commit()
- alert_message_key = f"admin_user_{action_type}ned_from_card_alert"
- await callback.answer(_(alert_message_key,
- user_display=user_display_name,
- user_id=user_id_target) + " " +
- panel_action_message,
- show_alert=False)
- else:
- await session.rollback()
- await callback.answer(_(f"admin_user_{action_type}_failed_db_error"),
- show_alert=True)
- return
-
- await show_user_card_handler(callback,
- state,
- i18n_data,
- settings,
- panel_service,
- session,
- force_user_id=user_id_target,
- force_page=banned_list_page)
-
-
-@router.callback_query(F.data.startswith("admin_ban_do:"))
-async def do_ban_user_handler(callback: types.CallbackQuery, i18n_data: dict,
- settings: Settings,
- panel_service: PanelApiService,
- 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,
-):
- 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))
+ await callback.message.edit_text(prompt_text)
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 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 = "заблокирован" if new_ban_status else "разблокирован"
+ 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
+ await handle_refresh_user_card(callback, user, None, 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
+ user_card_text = await format_user_card(target_user, session, None, 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()
\ 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..693ea7a
--- /dev/null
+++ b/bot/handlers/inline_mode.py
@@ -0,0 +1,351 @@
+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
+ )
+ 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
+ )
+ results.extend(stats_results)
+
+ # Show help if no specific query
+ if not query:
+ help_result = await create_help_result(i18n, current_lang, is_admin)
+ results.append(help_result)
+
+ # 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) -> 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
+ message_text = _(
+ "inline_referral_message",
+ default="🚀 Привет! Попробуй этот крутой VPN сервис!\n\n"
+ "✨ Быстрый и надежный\n"
+ "🔒 Полная анонимность\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="https://cdn-icons-png.flaticon.com/512/1077/1077114.png"
+ )
+
+ 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) -> 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)
+ if user_stats_result:
+ results.append(user_stats_result)
+
+ # Quick financial stats
+ financial_stats_result = await create_financial_stats_result(session, i18n_instance, lang)
+ if financial_stats_result:
+ results.append(financial_stats_result)
+
+ # Quick system stats
+ system_stats_result = await create_system_stats_result(session, i18n_instance, lang)
+ 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) -> 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"
+ "📊 Всего: {total}\n"
+ "📈 Активных сегодня: {active_today}\n"
+ "💳 С платной подпиской: {paid}\n"
+ "🆓 На пробном периоде: {trial}\n"
+ "😴 Неактивных: {inactive}\n"
+ "🚫 Заблокированных: {banned}\n"
+ "🎁 По реферальной программе: {referral}",
+ total=user_stats['total_users'],
+ active_today=user_stats['active_today'],
+ 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_admin_user_stats_desc",
+ default=f"Всего: {user_stats['total_users']}, Активных: {user_stats['paid_subscriptions']}"
+ ),
+ input_message_content=InputTextMessageContent(
+ message_text=stats_text,
+ parse_mode="HTML"
+ ),
+ thumbnail_url="https://cdn-icons-png.flaticon.com/512/681/681494.png"
+ )
+
+ 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) -> 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_admin_financial_stats_desc",
+ default=f"Сегодня: {financial_stats['today_revenue']:.2f} RUB"
+ ),
+ input_message_content=InputTextMessageContent(
+ message_text=stats_text,
+ parse_mode="HTML"
+ ),
+ thumbnail_url="https://cdn-icons-png.flaticon.com/512/2769/2769339.png"
+ )
+
+ 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) -> Optional[InlineQueryResultArticle]:
+ """Create system statistics result with online/offline/expired/limited info"""
+ _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
+
+ try:
+ from datetime import datetime, timezone
+ from sqlalchemy import select, func, and_
+ from db.models import User, Subscription
+
+ now = datetime.now(timezone.utc)
+
+ # Count active subscriptions (online)
+ active_subs_stmt = select(func.count(Subscription.subscription_id)).where(
+ and_(
+ Subscription.is_active == True,
+ Subscription.end_date > now
+ )
+ )
+ active_subs = (await session.execute(active_subs_stmt)).scalar() or 0
+
+ # Count expired subscriptions
+ expired_subs_stmt = select(func.count(Subscription.subscription_id)).where(
+ and_(
+ Subscription.is_active == True,
+ Subscription.end_date <= now
+ )
+ )
+ expired_subs = (await session.execute(expired_subs_stmt)).scalar() or 0
+
+ # Count total users (approximation for "total")
+ total_users_stmt = select(func.count(User.user_id))
+ total_users = (await session.execute(total_users_stmt)).scalar() or 0
+
+ # Offline = users without active subscriptions
+ offline_users = total_users - active_subs
+
+ stats_text = _(
+ "inline_system_stats_message",
+ default="🖥 Системная статистика\n\n"
+ "🟢 Онлайн: {online}\n"
+ "🔴 Офлайн: {offline}\n"
+ "⏰ Истекшие: {expired}\n"
+ "👥 Всего пользователей: {total}",
+ online=active_subs,
+ offline=max(0, offline_users),
+ expired=expired_subs,
+ total=total_users
+ )
+
+ return InlineQueryResultArticle(
+ id="admin_system_stats",
+ title=_(
+ "inline_admin_system_stats_title",
+ default="🖥 Системная статистика"
+ ),
+ description=_(
+ "inline_admin_system_stats_desc",
+ default=f"Онлайн: {active_subs}, Офлайн: {max(0, offline_users)}"
+ ),
+ input_message_content=InputTextMessageContent(
+ message_text=stats_text,
+ parse_mode="HTML"
+ ),
+ thumbnail_url="https://cdn-icons-png.flaticon.com/512/2920/2920277.png"
+ )
+
+ except Exception as e:
+ logging.error(f"Error creating system stats result: {e}")
+ return None
+
+
+async def create_help_result(i18n_instance, lang: str, is_admin: bool) -> InlineQueryResultArticle:
+ """Create help result explaining inline mode features"""
+ _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
+
+ if is_admin:
+ help_text = _(
+ "inline_admin_help_message",
+ default="🤖 Inline режим бота\n\n"
+ "📱 Доступные команды:\n\n"
+ "🎁 реф/ref - поделиться реферальной ссылкой\n"
+ "👥 стат/stat - статистика пользователей\n"
+ "💰 финансы - финансовая статистика\n"
+ "🖥 система - системная статистика\n\n"
+ "💡 Просто напишите @{bot_username} и начните вводить команду в любом чате!"
+ )
+ title = _("inline_admin_help_title", default="🤖 Inline помощь (Админ)")
+ description = _("inline_admin_help_desc", default="Доступны команды: реф, стат, финансы, система")
+ else:
+ help_text = _(
+ "inline_user_help_message",
+ default="🤖 Inline режим бота\n\n"
+ "📱 Доступные команды:\n\n"
+ "🎁 реф/ref - поделиться реферальной ссылкой\n\n"
+ "💡 Просто напишите @{bot_username} и начните вводить 'реф' в любом чате!"
+ )
+ title = _("inline_user_help_title", default="🤖 Inline помощь")
+ description = _("inline_user_help_desc", default="Доступна команда: реф (реферальная ссылка)")
+
+ return InlineQueryResultArticle(
+ id="help",
+ title=title,
+ description=description,
+ input_message_content=InputTextMessageContent(
+ message_text=help_text,
+ parse_mode="HTML"
+ ),
+ thumbnail_url="https://cdn-icons-png.flaticon.com/512/906/906794.png"
+ )
\ No newline at end of file
diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py
index 76e7c2a..bc54a9c 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 notify_admin_new_payment, NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
payment_processing_lock = asyncio.Lock()
@@ -195,6 +195,22 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
f"Failed to send payment details message to user {user_id}: {e_notify}"
)
+ # 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}")
+
+ # Legacy notification for backwards compatibility
await notify_admin_new_payment(
bot,
settings,
diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py
index ea06161..be6b418 100644
--- a/bot/handlers/user/referral.py
+++ b/bot/handlers/user/referral.py
@@ -87,7 +87,8 @@ async def referral_command_handler(event: Union[types.Message,
referral_link=referral_link,
bonus_details=bonus_details_str)
- 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 +107,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..996e7de 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,53 @@ 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")
+
+ from datetime import datetime
+ 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..15e7c32 100644
--- a/bot/keyboards/inline/admin_keyboards.py
+++ b/bot/keyboards/inline/admin_keyboards.py
@@ -12,27 +12,105 @@ 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_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="back_to_admin_panel_button"),
+ callback_data="admin_action:main")
+ builder.adjust(2, 2, 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 +121,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/notification_service.py b/bot/services/notification_service.py
index 37cc534..a8166dc 100644
--- a/bot/services/notification_service.py
+++ b/bot/services/notification_service.py
@@ -3,13 +3,202 @@ 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 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,15 +215,9 @@ 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,
@@ -65,4 +248,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/promo_code_service.py b/bot/services/promo_code_service.py
index a9bee97..4338d81 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 notify_admin_promo_activation, NotificationService
class PromoCodeService:
@@ -62,6 +62,20 @@ class PromoCodeService:
session, promo_data.promo_code_id)
if activation_recorded and promo_incremented:
+ # 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}")
+
+ # Legacy notification for backwards compatibility
await notify_admin_promo_activation(
self.bot,
self.settings,
diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py
index 854bc6c..e7b4c63 100644
--- a/bot/states/admin_states.py
+++ b/bot/states/admin_states.py
@@ -7,7 +7,13 @@ class AdminStates(StatesGroup):
confirming_broadcast = State()
waiting_for_promo_details = State()
waiting_for_promo_edit_details = State()
+ waiting_for_bulk_promo_details = 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..ede723b 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -288,6 +288,16 @@ 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)")
+
+ # 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")
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..dce3514 100644
--- a/db/dal/payment_dal.py
+++ b/db/dal/payment_dal.py
@@ -155,3 +155,67 @@ 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
+ }
diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py
index df3ea56..591be40 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(
diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py
index 70d3c57..8278b53 100644
--- a/db/dal/user_dal.py
+++ b/db/dal/user_dal.py
@@ -149,3 +149,70 @@ 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..28728ca 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -82,6 +82,8 @@
"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,6 +94,7 @@
"admin_stats_button": "📊 Statistics",
"admin_broadcast_button": "📢 Broadcast",
"admin_create_promo_button": "🎁 Create Promo",
+ "admin_create_bulk_promo_button": "📦 Bulk Create",
"admin_manage_promos_button": "🛠 Manage Promos",
"admin_view_promos_button": "👀 Promo List",
"admin_ban_user_button": "🚫 Ban User",
@@ -100,11 +103,21 @@
"admin_view_logs_menu_button": "📄 Logs",
"admin_sync_panel_button": "🔄 Sync",
"admin_unknown_action": "Unknown admin action.",
+
+ "admin_stats_and_monitoring_section": "📊 Statistics & Monitoring",
+ "admin_user_management_section": "👥 User Management",
+ "admin_promo_marketing_section": "🎁 Promos & Marketing",
+ "admin_system_functions_section": "⚙️ System Functions",
+ "admin_ban_management_section": "🚫 Ban Management",
+ "admin_users_management_button": "👤 User Management",
+ "back_to_user_management_button": "⬅️ Back to User Management",
"admin_action_cancelled_default": "Action cancelled. Returning to menu.",
"admin_action_cancelled_default_alert": "Action cancelled",
"back_to_admin_panel_button": "⬅️ Back 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}",
@@ -223,5 +237,67 @@
"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_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_referral_message": "🚀 Hey! Try this awesome VPN service!\n\n✨ Fast and reliable\n🔒 Complete anonymity\n🌍 Servers worldwide\n💎 Free trial period\n\nCheck it out: {referral_link}",
+ "inline_referral_title": "🎁 Invite a Friend",
+ "inline_referral_description": "Share referral link to get bonuses",
+
+ "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": "🖥 System Statistics\n\n🟢 Online: {online}\n🔴 Offline: {offline}\n⏰ Expired: {expired}\n👥 Total users: {total}",
+ "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}"
}
diff --git a/locales/ru.json b/locales/ru.json
index 1c9c209..729d091 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -82,6 +82,8 @@
"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,6 +94,7 @@
"admin_stats_button": "📊 Статистика",
"admin_broadcast_button": "📢 Рассылка",
"admin_create_promo_button": "🎁 Создать промо",
+ "admin_create_bulk_promo_button": "📦 Массовое создание",
"admin_manage_promos_button": "🛠 Управление промо",
"admin_view_promos_button": "👀 Список промо",
"admin_ban_user_button": "🚫 Забанить",
@@ -100,11 +103,21 @@
"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}",
@@ -223,5 +237,67 @@
"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_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_referral_message": "🚀 Привет! Попробуй этот крутой VPN сервис!\n\n✨ Быстрый и надежный\n🔒 Полная анонимность\n🌍 Серверы по всему миру\n💎 Бесплатный пробный период\n\nПереходи по ссылке: {referral_link}",
+ "inline_referral_title": "🎁 Пригласить друга",
+ "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов",
+
+ "inline_user_stats_message": "👥 Статистика пользователей\n\n📊 Всего: {total}\n📈 Активных сегодня: {active_today}\n💳 С платной подпиской: {paid}\n🆓 На пробном периоде: {trial}\n😴 Неактивных: {inactive}\n🚫 Заблокированных: {banned}\n🎁 По реферальной программе: {referral}",
+ "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🔴 Офлайн: {offline}\n⏰ Истекшие: {expired}\n👥 Всего пользователей: {total}",
+ "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_admin_help_title": "🤖 Inline помощь (Админ)",
+ "inline_admin_help_desc": "Доступны команды: реф, стат, финансы, система",
+
+ "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}"
}