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