From 736e4c6caee431771a0a4b889dd52935357222a1 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 20 Aug 2025 23:57:04 +0300 Subject: [PATCH] NEW VERSION NEW VERSION --- app/bot.py | 88 ++ app/config.py | 171 +++ app/database/crud/promocode.py | 236 +++ app/database/crud/referral.py | 268 ++++ app/database/crud/rules.py | 82 ++ app/database/crud/server_squad.py | 355 +++++ app/database/crud/squad.py | 61 + app/database/crud/subscription.py | 485 +++++++ app/database/crud/transaction.py | 278 ++++ app/database/crud/user.py | 327 +++++ app/database/database.py | 51 + app/database/models.py | 421 ++++++ app/external/remnawave_api.py | 521 +++++++ app/external/telegram_stars.py | 100 ++ app/external/tribute.py | 108 ++ app/external/webhook_server.py | 98 ++ app/handlers/__init__.py | 0 app/handlers/admin/__init__.py | 1 + app/handlers/admin/main.py | 37 + app/handlers/admin/messages.py | 551 +++++++ app/handlers/admin/monitoring.py | 313 ++++ app/handlers/admin/promocodes.py | 562 ++++++++ app/handlers/admin/referrals.py | 72 + app/handlers/admin/remnawave.py | 1477 +++++++++++++++++++ app/handlers/admin/rules.py | 168 +++ app/handlers/admin/servers.py | 962 +++++++++++++ app/handlers/admin/statistics.py | 350 +++++ app/handlers/admin/subscriptions.py | 498 +++++++ app/handlers/admin/users.py | 856 +++++++++++ app/handlers/balance.py | 341 +++++ app/handlers/common.py | 85 ++ app/handlers/menu.py | 132 ++ app/handlers/promocode.py | 89 ++ app/handlers/referral.py | 144 ++ app/handlers/start.py | 507 +++++++ app/handlers/subscription.py | 1979 ++++++++++++++++++++++++++ app/handlers/support.py | 32 + app/handlers/webhooks.py | 139 ++ app/keyboards/admin.py | 529 +++++++ app/keyboards/inline.py | 594 ++++++++ app/keyboards/reply.py | 116 ++ app/localization/texts.py | 488 +++++++ app/middlewares/auth.py | 93 ++ app/middlewares/logging.py | 42 + app/middlewares/throttling.py | 53 + app/services/__init__.py | 3 + app/services/monitoring_service.py | 621 ++++++++ app/services/payment_service.py | 115 ++ app/services/promocode_service.py | 117 ++ app/services/referral_service.py | 174 +++ app/services/remnawave_service.py | 868 +++++++++++ app/services/subscription_service.py | 240 ++++ app/services/tribute_service.py | 284 ++++ app/services/user_service.py | 333 +++++ app/states.py | 82 ++ app/utils/__init__.py | 3 + app/utils/cache.py | 264 ++++ app/utils/decorators.py | 117 ++ app/utils/formatters.py | 207 +++ app/utils/pagination.py | 82 ++ app/utils/user_utils.py | 76 + app/utils/validators.py | 137 ++ main.py | 81 ++ migrations/alembic/alembic.ini | 41 + migrations/alembic/env.py | 67 + requirements.txt | 21 + 66 files changed, 18793 insertions(+) create mode 100644 app/bot.py create mode 100644 app/config.py create mode 100644 app/database/crud/promocode.py create mode 100644 app/database/crud/referral.py create mode 100644 app/database/crud/rules.py create mode 100644 app/database/crud/server_squad.py create mode 100644 app/database/crud/squad.py create mode 100644 app/database/crud/subscription.py create mode 100644 app/database/crud/transaction.py create mode 100644 app/database/crud/user.py create mode 100644 app/database/database.py create mode 100644 app/database/models.py create mode 100644 app/external/remnawave_api.py create mode 100644 app/external/telegram_stars.py create mode 100644 app/external/tribute.py create mode 100644 app/external/webhook_server.py create mode 100644 app/handlers/__init__.py create mode 100644 app/handlers/admin/__init__.py create mode 100644 app/handlers/admin/main.py create mode 100644 app/handlers/admin/messages.py create mode 100644 app/handlers/admin/monitoring.py create mode 100644 app/handlers/admin/promocodes.py create mode 100644 app/handlers/admin/referrals.py create mode 100644 app/handlers/admin/remnawave.py create mode 100644 app/handlers/admin/rules.py create mode 100644 app/handlers/admin/servers.py create mode 100644 app/handlers/admin/statistics.py create mode 100644 app/handlers/admin/subscriptions.py create mode 100644 app/handlers/admin/users.py create mode 100644 app/handlers/balance.py create mode 100644 app/handlers/common.py create mode 100644 app/handlers/menu.py create mode 100644 app/handlers/promocode.py create mode 100644 app/handlers/referral.py create mode 100644 app/handlers/start.py create mode 100644 app/handlers/subscription.py create mode 100644 app/handlers/support.py create mode 100644 app/handlers/webhooks.py create mode 100644 app/keyboards/admin.py create mode 100644 app/keyboards/inline.py create mode 100644 app/keyboards/reply.py create mode 100644 app/localization/texts.py create mode 100644 app/middlewares/auth.py create mode 100644 app/middlewares/logging.py create mode 100644 app/middlewares/throttling.py create mode 100644 app/services/__init__.py create mode 100644 app/services/monitoring_service.py create mode 100644 app/services/payment_service.py create mode 100644 app/services/promocode_service.py create mode 100644 app/services/referral_service.py create mode 100644 app/services/remnawave_service.py create mode 100644 app/services/subscription_service.py create mode 100644 app/services/tribute_service.py create mode 100644 app/services/user_service.py create mode 100644 app/states.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/cache.py create mode 100644 app/utils/decorators.py create mode 100644 app/utils/formatters.py create mode 100644 app/utils/pagination.py create mode 100644 app/utils/user_utils.py create mode 100644 app/utils/validators.py create mode 100644 main.py create mode 100644 migrations/alembic/alembic.ini create mode 100644 migrations/alembic/env.py create mode 100644 requirements.txt diff --git a/app/bot.py b/app/bot.py new file mode 100644 index 00000000..4ee30ec8 --- /dev/null +++ b/app/bot.py @@ -0,0 +1,88 @@ +import logging +from aiogram import Bot, Dispatcher, types +from aiogram.fsm.storage.redis import RedisStorage +from aiogram.fsm.storage.memory import MemoryStorage +import redis.asyncio as redis + +from app.config import settings +from app.middlewares.auth import AuthMiddleware +from app.middlewares.logging import LoggingMiddleware +from app.middlewares.throttling import ThrottlingMiddleware +from app.utils.cache import cache + +from app.handlers import ( + start, menu, subscription, balance, promocode, + referral, support, common +) +from app.handlers.admin import ( + main as admin_main, users as admin_users, subscriptions as admin_subscriptions, + promocodes as admin_promocodes, messages as admin_messages, + monitoring as admin_monitoring, referrals as admin_referrals, + rules as admin_rules, remnawave as admin_remnawave, + statistics as admin_statistics, servers as admin_servers +) + +logger = logging.getLogger(__name__) + + +async def debug_callback_handler(callback: types.CallbackQuery): + logger.info(f"🔍 DEBUG CALLBACK:") + logger.info(f" - Data: {callback.data}") + logger.info(f" - User: {callback.from_user.id}") + logger.info(f" - Username: {callback.from_user.username}") + + +async def setup_bot() -> tuple[Bot, Dispatcher]: + + try: + await cache.connect() + logger.info("✅ Кеш инициализирован") + except Exception as e: + logger.warning(f"⚠️ Кеш не инициализирован: {e}") + + bot = Bot(token=settings.BOT_TOKEN, parse_mode="HTML") + + try: + redis_client = redis.from_url(settings.REDIS_URL) + await redis_client.ping() + storage = RedisStorage(redis_client) + logger.info("✅ Подключено к Redis для FSM storage") + except Exception as e: + logger.warning(f"⚠️ Не удалось подключиться к Redis: {e}") + logger.info("🔄 Используется MemoryStorage для FSM") + storage = MemoryStorage() + + dp = Dispatcher(storage=storage) + + dp.message.middleware(LoggingMiddleware()) + dp.callback_query.middleware(LoggingMiddleware()) + dp.message.middleware(AuthMiddleware()) + dp.callback_query.middleware(AuthMiddleware()) + dp.message.middleware(ThrottlingMiddleware()) + dp.callback_query.middleware(ThrottlingMiddleware()) + + start.register_handlers(dp) + menu.register_handlers(dp) + subscription.register_handlers(dp) + balance.register_handlers(dp) + promocode.register_handlers(dp) + referral.register_handlers(dp) + support.register_handlers(dp) + + admin_main.register_handlers(dp) + admin_users.register_handlers(dp) + admin_subscriptions.register_handlers(dp) + admin_servers.register_handlers(dp) + admin_promocodes.register_handlers(dp) + admin_messages.register_handlers(dp) + admin_monitoring.register_handlers(dp) + admin_referrals.register_handlers(dp) + admin_rules.register_handlers(dp) + admin_remnawave.register_handlers(dp) + admin_statistics.register_handlers(dp) + + common.register_handlers(dp) + + logger.info("✅ Бот успешно настроен") + + return bot, dp \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 00000000..06463fd6 --- /dev/null +++ b/app/config.py @@ -0,0 +1,171 @@ +import os +from typing import List, Optional, Union +from pydantic_settings import BaseSettings +from pydantic import field_validator, Field +from pathlib import Path + + +class Settings(BaseSettings): + + BOT_TOKEN: str + ADMIN_IDS: str = "" + SUPPORT_USERNAME: str = "@support" + + DATABASE_URL: str + REDIS_URL: str = "redis://localhost:6379/0" + + REMNAWAVE_API_URL: str + REMNAWAVE_API_KEY: str + + TRIAL_DURATION_DAYS: int = 3 + TRIAL_TRAFFIC_LIMIT_GB: int = 10 + TRIAL_DEVICE_LIMIT: int = 2 + TRIAL_SQUAD_UUID: str + DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH" + + TRIAL_WARNING_HOURS: int = 2 + ENABLE_NOTIFICATIONS: bool = True + NOTIFICATION_RETRY_ATTEMPTS: int = 3 + + MONITORING_LOGS_RETENTION_DAYS: int = 30 + NOTIFICATION_CACHE_HOURS: int = 24 + + BASE_SUBSCRIPTION_PRICE: int = 50000 + + PRICE_14_DAYS: int = 50000 + PRICE_30_DAYS: int = 99000 + PRICE_60_DAYS: int = 189000 + PRICE_90_DAYS: int = 269000 + PRICE_180_DAYS: int = 499000 + PRICE_360_DAYS: int = 899000 + + PRICE_TRAFFIC_5GB: int = 10000 + PRICE_TRAFFIC_10GB: int = 19000 + PRICE_TRAFFIC_25GB: int = 45000 + PRICE_TRAFFIC_50GB: int = 85000 + PRICE_TRAFFIC_100GB: int = 159000 + PRICE_TRAFFIC_250GB: int = 369000 + PRICE_TRAFFIC_UNLIMITED: int = 0 + + PRICE_PER_DEVICE: int = 5000 + + REFERRAL_REGISTRATION_REWARD: int = 5000 + REFERRED_USER_REWARD: int = 2500 + REFERRAL_COMMISSION_PERCENT: int = 10 + + AUTOPAY_WARNING_DAYS: str = "3,1" + + DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3 + MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000 + + MONITORING_INTERVAL: int = 60 + INACTIVE_USER_DELETE_MONTHS: int = 3 + + TELEGRAM_STARS_ENABLED: bool = True + + TRIBUTE_ENABLED: bool = False + TRIBUTE_API_KEY: Optional[str] = None + TRIBUTE_WEBHOOK_SECRET: Optional[str] = None + TRIBUTE_DONATE_LINK: Optional[str] = None + TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook" + TRIBUTE_WEBHOOK_PORT: int = 8081 + + DEFAULT_LANGUAGE: str = "ru" + AVAILABLE_LANGUAGES: str = "ru,en" + + LOG_LEVEL: str = "INFO" + LOG_FILE: str = "logs/bot.log" + + DEBUG: bool = False + WEBHOOK_URL: Optional[str] = None + WEBHOOK_PATH: str = "/webhook" + + @field_validator('LOG_FILE', mode='before') + @classmethod + def ensure_log_dir(cls, v): + log_path = Path(v) + log_path.parent.mkdir(parents=True, exist_ok=True) + return str(log_path) + + def is_admin(self, user_id: int) -> bool: + return user_id in self.get_admin_ids() + + def get_admin_ids(self) -> List[int]: + try: + admin_ids = self.ADMIN_IDS + + if isinstance(admin_ids, str): + if not admin_ids.strip(): + return [] + return [int(x.strip()) for x in admin_ids.split(',') if x.strip()] + + return [] + + except (ValueError, AttributeError): + return [] + + def get_autopay_warning_days(self) -> List[int]: + try: + days = self.AUTOPAY_WARNING_DAYS + if isinstance(days, str): + if not days.strip(): + return [3, 1] + return [int(x.strip()) for x in days.split(',') if x.strip()] + return [3, 1] + except (ValueError, AttributeError): + return [3, 1] + + def get_available_languages(self) -> List[str]: + try: + langs = self.AVAILABLE_LANGUAGES + if isinstance(langs, str): + if not langs.strip(): + return ["ru", "en"] + return [x.strip() for x in langs.split(',') if x.strip()] + return ["ru", "en"] + except AttributeError: + return ["ru", "en"] + + def format_price(self, price_kopeks: int) -> str: + rubles = price_kopeks / 100 + return f"{rubles:.2f} ₽" + + def kopeks_to_rubles(self, kopeks: int) -> float: + return kopeks / 100 + + def rubles_to_kopeks(self, rubles: float) -> int: + return int(rubles * 100) + + def get_trial_warning_hours(self) -> int: + return self.TRIAL_WARNING_HOURS + + def is_notifications_enabled(self) -> bool: + return self.ENABLE_NOTIFICATIONS + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8" + } + + +settings = Settings() + + +PERIOD_PRICES = { + 14: settings.PRICE_14_DAYS, + 30: settings.PRICE_30_DAYS, + 60: settings.PRICE_60_DAYS, + 90: settings.PRICE_90_DAYS, + 180: settings.PRICE_180_DAYS, + 360: settings.PRICE_360_DAYS, +} + +TRAFFIC_PRICES = { + 5: settings.PRICE_TRAFFIC_5GB, + 10: settings.PRICE_TRAFFIC_10GB, + 25: settings.PRICE_TRAFFIC_25GB, + 50: settings.PRICE_TRAFFIC_50GB, + 100: settings.PRICE_TRAFFIC_100GB, + 250: settings.PRICE_TRAFFIC_250GB, + 0: settings.PRICE_TRAFFIC_UNLIMITED, +} \ No newline at end of file diff --git a/app/database/crud/promocode.py b/app/database/crud/promocode.py new file mode 100644 index 00000000..3d99ea8d --- /dev/null +++ b/app/database/crud/promocode.py @@ -0,0 +1,236 @@ +import logging +from datetime import datetime +from typing import Optional, List +from sqlalchemy import select, and_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import PromoCode, PromoCodeUse, PromoCodeType + +logger = logging.getLogger(__name__) + + +async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]: + result = await db.execute( + select(PromoCode) + .options(selectinload(PromoCode.uses)) + .where(PromoCode.code == code.upper()) + ) + return result.scalar_one_or_none() + + +async def create_promocode( + db: AsyncSession, + code: str, + type: PromoCodeType, + balance_bonus_kopeks: int = 0, + subscription_days: int = 0, + max_uses: int = 1, + valid_until: Optional[datetime] = None, + created_by: Optional[int] = None +) -> PromoCode: + + promocode = PromoCode( + code=code.upper(), + type=type.value, + balance_bonus_kopeks=balance_bonus_kopeks, + subscription_days=subscription_days, + max_uses=max_uses, + valid_until=valid_until, + created_by=created_by + ) + + db.add(promocode) + await db.commit() + await db.refresh(promocode) + + logger.info(f"✅ Создан промокод: {code}") + return promocode + + +async def use_promocode( + db: AsyncSession, + promocode_id: int, + user_id: int +) -> bool: + + try: + promocode = await db.get(PromoCode, promocode_id) + if not promocode: + return False + + usage = PromoCodeUse( + promocode_id=promocode_id, + user_id=user_id + ) + db.add(usage) + + promocode.current_uses += 1 + + await db.commit() + + logger.info(f"✅ Промокод {promocode.code} использован пользователем {user_id}") + return True + + except Exception as e: + logger.error(f"Ошибка использования промокода: {e}") + await db.rollback() + return False + + +async def check_user_promocode_usage( + db: AsyncSession, + user_id: int, + promocode_id: int +) -> bool: + + result = await db.execute( + select(PromoCodeUse).where( + and_( + PromoCodeUse.user_id == user_id, + PromoCodeUse.promocode_id == promocode_id + ) + ) + ) + return result.scalar_one_or_none() is not None + + + +async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse: + promocode_use = PromoCodeUse( + promocode_id=promocode_id, + user_id=user_id, + used_at=datetime.utcnow() + ) + + db.add(promocode_use) + await db.commit() + await db.refresh(promocode_use) + + logger.info(f"📝 Записано использование промокода {promocode_id} пользователем {user_id}") + return promocode_use + + +async def get_promocode_use_by_user_and_code( + db: AsyncSession, + user_id: int, + promocode_id: int +) -> Optional[PromoCodeUse]: + result = await db.execute( + select(PromoCodeUse).where( + and_( + PromoCodeUse.user_id == user_id, + PromoCodeUse.promocode_id == promocode_id + ) + ) + ) + return result.scalar_one_or_none() + + +async def get_user_promocodes(db: AsyncSession, user_id: int) -> List[PromoCodeUse]: + result = await db.execute( + select(PromoCodeUse) + .where(PromoCodeUse.user_id == user_id) + .order_by(PromoCodeUse.used_at.desc()) + ) + return result.scalars().all() + + + +async def get_promocodes_list( + db: AsyncSession, + offset: int = 0, + limit: int = 50, + is_active: Optional[bool] = None +) -> List[PromoCode]: + + query = select(PromoCode).options(selectinload(PromoCode.uses)) + + if is_active is not None: + query = query.where(PromoCode.is_active == is_active) + + query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit) + + result = await db.execute(query) + return result.scalars().all() + + +async def get_promocodes_count( + db: AsyncSession, + is_active: Optional[bool] = None +) -> int: + + query = select(func.count(PromoCode.id)) + + if is_active is not None: + query = query.where(PromoCode.is_active == is_active) + + result = await db.execute(query) + return result.scalar() + + +async def update_promocode( + db: AsyncSession, + promocode: PromoCode, + **kwargs +) -> PromoCode: + + for field, value in kwargs.items(): + if hasattr(promocode, field): + setattr(promocode, field, value) + + promocode.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(promocode) + + return promocode + + +async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool: + + try: + await db.delete(promocode) + await db.commit() + + logger.info(f"🗑️ Удален промокод: {promocode.code}") + return True + + except Exception as e: + logger.error(f"Ошибка удаления промокода: {e}") + await db.rollback() + return False + + +async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict: + + total_uses_result = await db.execute( + select(func.count(PromoCodeUse.id)) + .where(PromoCodeUse.promocode_id == promocode_id) + ) + total_uses = total_uses_result.scalar() + + today = datetime.utcnow().date() + today_uses_result = await db.execute( + select(func.count(PromoCodeUse.id)) + .where( + and_( + PromoCodeUse.promocode_id == promocode_id, + PromoCodeUse.used_at >= today + ) + ) + ) + today_uses = today_uses_result.scalar() + + recent_uses_result = await db.execute( + select(PromoCodeUse) + .where(PromoCodeUse.promocode_id == promocode_id) + .order_by(PromoCodeUse.used_at.desc()) + .limit(10) + ) + recent_uses = recent_uses_result.scalars().all() + + return { + "total_uses": total_uses, + "today_uses": today_uses, + "recent_uses": recent_uses + } \ No newline at end of file diff --git a/app/database/crud/referral.py b/app/database/crud/referral.py new file mode 100644 index 00000000..6c88e4eb --- /dev/null +++ b/app/database/crud/referral.py @@ -0,0 +1,268 @@ +import logging +from datetime import datetime, timedelta +from typing import List, Optional +from sqlalchemy import select, and_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ReferralEarning, User + +logger = logging.getLogger(__name__) + + +async def create_referral_earning( + db: AsyncSession, + user_id: int, + referral_id: int, + amount_kopeks: int, + reason: str, + referral_transaction_id: Optional[int] = None +) -> ReferralEarning: + + earning = ReferralEarning( + user_id=user_id, + referral_id=referral_id, + amount_kopeks=amount_kopeks, + reason=reason, + referral_transaction_id=referral_transaction_id + ) + + db.add(earning) + await db.commit() + await db.refresh(earning) + + logger.info(f"💰 Создан реферальный заработок: {amount_kopeks/100}₽ для пользователя {user_id}") + return earning + + +async def get_referral_earnings_by_user( + db: AsyncSession, + user_id: int, + limit: int = 50, + offset: int = 0 +) -> List[ReferralEarning]: + + result = await db.execute( + select(ReferralEarning) + .options( + selectinload(ReferralEarning.referral), + selectinload(ReferralEarning.referral_transaction) + ) + .where(ReferralEarning.user_id == user_id) + .order_by(ReferralEarning.created_at.desc()) + .offset(offset) + .limit(limit) + ) + return result.scalars().all() + + +async def get_referral_earnings_by_referral( + db: AsyncSession, + referral_id: int +) -> List[ReferralEarning]: + + result = await db.execute( + select(ReferralEarning) + .where(ReferralEarning.referral_id == referral_id) + .order_by(ReferralEarning.created_at.desc()) + ) + return result.scalars().all() + + +async def get_referral_earnings_sum( + db: AsyncSession, + user_id: int, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None +) -> int: + + query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + ReferralEarning.user_id == user_id + ) + + if start_date: + query = query.where(ReferralEarning.created_at >= start_date) + + if end_date: + query = query.where(ReferralEarning.created_at <= end_date) + + result = await db.execute(query) + return result.scalar() + + +async def get_referral_statistics(db: AsyncSession) -> dict: + users_with_referrals_result = await db.execute( + select(func.count(func.distinct(User.id))) + .where(User.referred_by_id.isnot(None)) + ) + users_with_referrals = users_with_referrals_result.scalar() + + active_referrers_result = await db.execute( + select(func.count(func.distinct(User.referred_by_id))) + .where(User.referred_by_id.isnot(None)) + ) + active_referrers = active_referrers_result.scalar() + + referral_paid_result = await db.execute( + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) + ) + referral_paid = referral_paid_result.scalar() + + from app.database.models import Transaction, TransactionType + transaction_paid_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where(Transaction.type == TransactionType.REFERRAL_REWARD.value) + ) + transaction_paid = transaction_paid_result.scalar() + + total_paid = referral_paid + transaction_paid + + top_referrers_result = await db.execute( + select( + User.referred_by_id.label('referrer_id'), + func.count(User.id).label('referrals_count'), + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('referral_earnings'), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('transaction_earnings') + ) + .outerjoin( + ReferralEarning, + ReferralEarning.user_id == User.referred_by_id + ) + .outerjoin( + Transaction, + and_( + Transaction.user_id == User.referred_by_id, + Transaction.type == TransactionType.REFERRAL_REWARD.value + ) + ) + .where( + and_( + User.referred_by_id.isnot(None), + User.referred_by_id != User.id + ) + ) + .group_by(User.referred_by_id) + .order_by(func.count(User.id).desc()) + .limit(5) + ) + top_referrers_raw = top_referrers_result.all() + + top_referrers = [] + for row in top_referrers_raw: + user_result = await db.execute( + select(User.id, User.username, User.first_name, User.last_name, User.telegram_id) + .where(User.id == row.referrer_id) + ) + user = user_result.first() + + if user: + display_name = "" + if user.first_name: + display_name = user.first_name + if user.last_name: + display_name += f" {user.last_name}" + elif user.username: + display_name = f"@{user.username}" + else: + display_name = f"ID{user.telegram_id}" + + total_earned = (row.referral_earnings or 0) + (row.transaction_earnings or 0) + + top_referrers.append({ + "user_id": row.referrer_id, + "display_name": display_name, + "username": user.username, + "total_earned_kopeks": total_earned, + "referrals_count": row.referrals_count + }) + + today = datetime.utcnow().date() + + today_referral_earnings = await db.execute( + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) + .where(ReferralEarning.created_at >= today) + ) + today_transaction_earnings = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.type == TransactionType.REFERRAL_REWARD.value, + Transaction.created_at >= today + ) + ) + ) + today_earnings = today_referral_earnings.scalar() + today_transaction_earnings.scalar() + + week_ago = datetime.utcnow() - timedelta(days=7) + week_referral_earnings = await db.execute( + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) + .where(ReferralEarning.created_at >= week_ago) + ) + week_transaction_earnings = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.type == TransactionType.REFERRAL_REWARD.value, + Transaction.created_at >= week_ago + ) + ) + ) + week_earnings = week_referral_earnings.scalar() + week_transaction_earnings.scalar() + + month_ago = datetime.utcnow() - timedelta(days=30) + month_referral_earnings = await db.execute( + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) + .where(ReferralEarning.created_at >= month_ago) + ) + month_transaction_earnings = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.type == TransactionType.REFERRAL_REWARD.value, + Transaction.created_at >= month_ago + ) + ) + ) + month_earnings = month_referral_earnings.scalar() + month_transaction_earnings.scalar() + + return { + "users_with_referrals": users_with_referrals, + "active_referrers": active_referrers, + "total_paid_kopeks": total_paid, + "today_earnings_kopeks": today_earnings, + "week_earnings_kopeks": week_earnings, + "month_earnings_kopeks": month_earnings, + "top_referrers": top_referrers + } + + +async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict: + + invited_count_result = await db.execute( + select(func.count(User.id)).where(User.referred_by_id == user_id) + ) + invited_count = invited_count_result.scalar() + + total_earned = await get_referral_earnings_sum(db, user_id) + + month_ago = datetime.utcnow() - timedelta(days=30) + month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago) + + active_referrals_result = await db.execute( + select(func.count(User.id)) + .join(User.subscription) + .where( + and_( + User.referred_by_id == user_id, + User.subscription.has() + ) + ) + ) + active_referrals = active_referrals_result.scalar() + + return { + "invited_count": invited_count, + "active_referrals": active_referrals, + "total_earned_kopeks": total_earned, + "month_earned_kopeks": month_earned + } \ No newline at end of file diff --git a/app/database/crud/rules.py b/app/database/crud/rules.py new file mode 100644 index 00000000..c66fa05f --- /dev/null +++ b/app/database/crud/rules.py @@ -0,0 +1,82 @@ +import logging +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime + +from app.database.models import ServiceRule + +logger = logging.getLogger(__name__) + + +async def get_rules_by_language(db: AsyncSession, language: str = "ru") -> Optional[ServiceRule]: + result = await db.execute( + select(ServiceRule) + .where( + ServiceRule.language == language, + ServiceRule.is_active == True + ) + .order_by(ServiceRule.order, ServiceRule.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def create_or_update_rules( + db: AsyncSession, + content: str, + language: str = "ru", + title: str = "Правила сервиса" +) -> ServiceRule: + + existing_rules_result = await db.execute( + select(ServiceRule).where( + ServiceRule.language == language, + ServiceRule.is_active == True + ) + ) + existing_rules = existing_rules_result.scalars().all() + + for rule in existing_rules: + rule.is_active = False + rule.updated_at = datetime.utcnow() + + new_rules = ServiceRule( + title=title, + content=content, + language=language, + is_active=True, + order=0 + ) + + db.add(new_rules) + await db.commit() + await db.refresh(new_rules) + + logger.info(f"✅ Правила для языка {language} обновлены") + return new_rules + + +async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str: + rules = await get_rules_by_language(db, language) + + if rules: + return rules.content + else: + return """ +🔒 Правила использования сервиса + +1. Сервис предоставляется "как есть" без каких-либо гарантий. + +2. Запрещается использование сервиса для незаконных действий. + +3. Администрация оставляет за собой право заблокировать доступ пользователя при нарушении правил. + +4. Возврат средств осуществляется в соответствии с политикой возврата. + +5. Пользователь несет полную ответственность за безопасность своего аккаунта. + +6. При возникновении вопросов обращайтесь в техническую поддержку. + +Используя сервис, вы соглашаетесь с данными правилами. +""" \ No newline at end of file diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py new file mode 100644 index 00000000..6e8f31fc --- /dev/null +++ b/app/database/crud/server_squad.py @@ -0,0 +1,355 @@ +import logging +from typing import List, Optional, Tuple +from sqlalchemy import select, and_, func, update, delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ServerSquad, SubscriptionServer + +logger = logging.getLogger(__name__) + + +async def create_server_squad( + db: AsyncSession, + squad_uuid: str, + display_name: str, + original_name: str = None, + country_code: str = None, + price_kopeks: int = 0, + description: str = None, + max_users: int = None, + is_available: bool = True +) -> ServerSquad: + + server_squad = ServerSquad( + squad_uuid=squad_uuid, + display_name=display_name, + original_name=original_name, + country_code=country_code, + price_kopeks=price_kopeks, + description=description, + max_users=max_users, + is_available=is_available + ) + + db.add(server_squad) + await db.commit() + await db.refresh(server_squad) + + logger.info(f"✅ Создан сервер {display_name} (UUID: {squad_uuid})") + return server_squad + + +async def get_server_squad_by_uuid( + db: AsyncSession, + squad_uuid: str +) -> Optional[ServerSquad]: + + result = await db.execute( + select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid) + ) + return result.scalar_one_or_none() + + +async def get_server_squad_by_id( + db: AsyncSession, + server_id: int +) -> Optional[ServerSquad]: + + result = await db.execute( + select(ServerSquad).where(ServerSquad.id == server_id) + ) + return result.scalar_one_or_none() + + +async def get_all_server_squads( + db: AsyncSession, + available_only: bool = False, + page: int = 1, + limit: int = 50 +) -> Tuple[List[ServerSquad], int]: + + query = select(ServerSquad) + + if available_only: + query = query.where(ServerSquad.is_available == True) + + count_query = select(func.count(ServerSquad.id)) + if available_only: + count_query = count_query.where(ServerSquad.is_available == True) + + count_result = await db.execute(count_query) + total_count = count_result.scalar() + + offset = (page - 1) * limit + query = query.order_by(ServerSquad.sort_order, ServerSquad.display_name) + query = query.offset(offset).limit(limit) + + result = await db.execute(query) + servers = result.scalars().all() + + return servers, total_count + + +async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]: + + result = await db.execute( + select(ServerSquad) + .where(ServerSquad.is_available == True) + .order_by(ServerSquad.sort_order, ServerSquad.display_name) + ) + return result.scalars().all() + + +async def update_server_squad( + db: AsyncSession, + server_id: int, + **updates +) -> Optional[ServerSquad]: + + valid_fields = { + 'display_name', 'country_code', 'price_kopeks', 'description', + 'max_users', 'is_available', 'sort_order' + } + + filtered_updates = {k: v for k, v in updates.items() if k in valid_fields} + + if not filtered_updates: + return None + + await db.execute( + update(ServerSquad) + .where(ServerSquad.id == server_id) + .values(**filtered_updates) + ) + + await db.commit() + + return await get_server_squad_by_id(db, server_id) + + +async def delete_server_squad(db: AsyncSession, server_id: int) -> bool: + + connections_result = await db.execute( + select(func.count(SubscriptionServer.id)) + .where(SubscriptionServer.server_squad_id == server_id) + ) + connections_count = connections_result.scalar() + + if connections_count > 0: + logger.warning(f"❌ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})") + return False + + await db.execute( + delete(ServerSquad).where(ServerSquad.id == server_id) + ) + await db.commit() + + logger.info(f"🗑️ Удален сервер (ID: {server_id})") + return True + + +async def sync_with_remnawave( + db: AsyncSession, + remnawave_squads: List[dict] +) -> Tuple[int, int, int]: + + created = 0 + updated = 0 + disabled = 0 + + existing_servers = {} + result = await db.execute(select(ServerSquad)) + for server in result.scalars().all(): + existing_servers[server.squad_uuid] = server + + remnawave_uuids = {squad['uuid'] for squad in remnawave_squads} + + for squad in remnawave_squads: + squad_uuid = squad['uuid'] + original_name = squad.get('name', f'Squad {squad_uuid[:8]}') + + if squad_uuid in existing_servers: + server = existing_servers[squad_uuid] + if server.original_name != original_name: + server.original_name = original_name + updated += 1 + else: + await create_server_squad( + db=db, + squad_uuid=squad_uuid, + display_name=_generate_display_name(original_name), + original_name=original_name, + country_code=_extract_country_code(original_name), + price_kopeks=1000, + is_available=False + ) + created += 1 + + for uuid, server in existing_servers.items(): + if uuid not in remnawave_uuids and server.is_available: + server.is_available = False + disabled += 1 + + await db.commit() + + logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}") + return created, updated, disabled + + +def _generate_display_name(original_name: str) -> str: + + country_names = { + 'NL': '🇳🇱 Нидерланды', + 'DE': '🇩🇪 Германия', + 'US': '🇺🇸 США', + 'FR': '🇫🇷 Франция', + 'GB': '🇬🇧 Великобритания', + 'IT': '🇮🇹 Италия', + 'ES': '🇪🇸 Испания', + 'CA': '🇨🇦 Канада', + 'JP': '🇯🇵 Япония', + 'SG': '🇸🇬 Сингапур', + 'AU': '🇦🇺 Австралия', + } + + name_upper = original_name.upper() + for code, display_name in country_names.items(): + if code in name_upper: + return display_name + + return f"🌍 {original_name}" + + +def _extract_country_code(original_name: str) -> Optional[str]: + + codes = ['NL', 'DE', 'US', 'FR', 'GB', 'IT', 'ES', 'CA', 'JP', 'SG', 'AU'] + name_upper = original_name.upper() + + for code in codes: + if code in name_upper: + return code + + return None + + +async def get_server_statistics(db: AsyncSession) -> dict: + + total_result = await db.execute(select(func.count(ServerSquad.id))) + total_servers = total_result.scalar() + + available_result = await db.execute( + select(func.count(ServerSquad.id)) + .where(ServerSquad.is_available == True) + ) + available_servers = available_result.scalar() + + with_connections_result = await db.execute( + select(func.count(func.distinct(SubscriptionServer.server_squad_id))) + ) + servers_with_connections = with_connections_result.scalar() + + revenue_result = await db.execute( + select(func.coalesce(func.sum(SubscriptionServer.paid_price_kopeks), 0)) + ) + total_revenue_kopeks = revenue_result.scalar() + + return { + 'total_servers': total_servers, + 'available_servers': available_servers, + 'unavailable_servers': total_servers - available_servers, + 'servers_with_connections': servers_with_connections, + 'total_revenue_kopeks': total_revenue_kopeks, + 'total_revenue_rubles': total_revenue_kopeks / 100 + } + +async def add_user_to_servers( + db: AsyncSession, + server_squad_ids: List[int] +) -> bool: + + try: + for server_id in server_squad_ids: + await db.execute( + update(ServerSquad) + .where(ServerSquad.id == server_id) + .values(current_users=ServerSquad.current_users + 1) + ) + + await db.commit() + logger.info(f"✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}") + return True + + except Exception as e: + logger.error(f"Ошибка увеличения счетчика пользователей: {e}") + await db.rollback() + return False + + +async def remove_user_from_servers( + db: AsyncSession, + server_squad_ids: List[int] +) -> bool: + + try: + for server_id in server_squad_ids: + await db.execute( + update(ServerSquad) + .where(ServerSquad.id == server_id) + .values(current_users=func.greatest(ServerSquad.current_users - 1, 0)) + ) + + await db.commit() + logger.info(f"✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}") + return True + + except Exception as e: + logger.error(f"Ошибка уменьшения счетчика пользователей: {e}") + await db.rollback() + return False + + +async def get_server_ids_by_uuids( + db: AsyncSession, + squad_uuids: List[str] +) -> List[int]: + + result = await db.execute( + select(ServerSquad.id) + .where(ServerSquad.squad_uuid.in_(squad_uuids)) + ) + return [row[0] for row in result.fetchall()] + + +async def sync_server_user_counts(db: AsyncSession) -> int: + + try: + result = await db.execute( + select( + ServerSquad.id, + ServerSquad.squad_uuid, + func.count(SubscriptionServer.id).label('actual_users') + ) + .outerjoin(SubscriptionServer, ServerSquad.id == SubscriptionServer.server_squad_id) + .join(Subscription, SubscriptionServer.subscription_id == Subscription.id) + .where(Subscription.status == 'active') + .group_by(ServerSquad.id, ServerSquad.squad_uuid) + ) + + updated_count = 0 + for server_id, squad_uuid, actual_users in result.fetchall(): + await db.execute( + update(ServerSquad) + .where(ServerSquad.id == server_id) + .values(current_users=actual_users) + ) + updated_count += 1 + + await db.commit() + logger.info(f"✅ Синхронизированы счетчики для {updated_count} серверов") + return updated_count + + except Exception as e: + logger.error(f"Ошибка синхронизации счетчиков пользователей: {e}") + await db.rollback() + return 0 \ No newline at end of file diff --git a/app/database/crud/squad.py b/app/database/crud/squad.py new file mode 100644 index 00000000..9ab0e93f --- /dev/null +++ b/app/database/crud/squad.py @@ -0,0 +1,61 @@ +import logging +from typing import Optional, List +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import Squad + +logger = logging.getLogger(__name__) + + +async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Optional[Squad]: + result = await db.execute( + select(Squad).where(Squad.uuid == uuid) + ) + return result.scalar_one_or_none() + + +async def get_available_squads(db: AsyncSession) -> List[Squad]: + result = await db.execute( + select(Squad).where(Squad.is_available == True) + ) + return result.scalars().all() + + +async def create_squad( + db: AsyncSession, + uuid: str, + name: str, + country_code: str = None, + price_kopeks: int = 0, + description: str = None +) -> Squad: + squad = Squad( + uuid=uuid, + name=name, + country_code=country_code, + price_kopeks=price_kopeks, + description=description + ) + + db.add(squad) + await db.commit() + await db.refresh(squad) + + logger.info(f"✅ Создан сквад: {name}") + return squad + + +async def update_squad( + db: AsyncSession, + squad: Squad, + **kwargs +) -> Squad: + for field, value in kwargs.items(): + if hasattr(squad, field): + setattr(squad, field, value) + + await db.commit() + await db.refresh(squad) + + return squad \ No newline at end of file diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py new file mode 100644 index 00000000..2ba95c54 --- /dev/null +++ b/app/database/crud/subscription.py @@ -0,0 +1,485 @@ +import logging +from datetime import datetime, timedelta +from typing import Optional, List, Tuple +from sqlalchemy import select, and_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + Subscription, SubscriptionStatus, User, + SubscriptionServer +) +from app.config import settings + +logger = logging.getLogger(__name__) + + +async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]: + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where(Subscription.user_id == user_id) + ) + return result.scalar_one_or_none() + + +async def create_trial_subscription( + db: AsyncSession, + user_id: int, + duration_days: int = None, + traffic_limit_gb: int = None, + device_limit: int = None, + squad_uuid: str = None +) -> Subscription: + + duration_days = duration_days or settings.TRIAL_DURATION_DAYS + traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB + device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT + squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID + + end_date = datetime.utcnow() + timedelta(days=duration_days) + + subscription = Subscription( + user_id=user_id, + status=SubscriptionStatus.ACTIVE.value, + is_trial=True, + start_date=datetime.utcnow(), + end_date=end_date, + traffic_limit_gb=traffic_limit_gb, + device_limit=device_limit, + connected_squads=[squad_uuid] if squad_uuid else [] + ) + + db.add(subscription) + await db.commit() + await db.refresh(subscription) + + logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}") + return subscription + + +async def create_paid_subscription( + db: AsyncSession, + user_id: int, + duration_days: int, + traffic_limit_gb: int = 0, + device_limit: int = 1, + connected_squads: List[str] = None +) -> Subscription: + + end_date = datetime.utcnow() + timedelta(days=duration_days) + + subscription = Subscription( + user_id=user_id, + status=SubscriptionStatus.ACTIVE.value, + is_trial=False, + start_date=datetime.utcnow(), + end_date=end_date, + traffic_limit_gb=traffic_limit_gb, + device_limit=device_limit, + connected_squads=connected_squads or [] + ) + + db.add(subscription) + await db.commit() + await db.refresh(subscription) + + logger.info(f"💎 Создана платная подписка для пользователя {user_id}") + return subscription + + +async def extend_subscription( + db: AsyncSession, + subscription: Subscription, + days: int +) -> Subscription: + + subscription.extend_subscription(days) + + if subscription.status == SubscriptionStatus.EXPIRED.value: + subscription.status = SubscriptionStatus.ACTIVE.value + + subscription.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(subscription) + + logger.info(f"⏰ Подписка пользователя {subscription.user_id} продлена на {days} дней") + return subscription + + +async def add_subscription_traffic( + db: AsyncSession, + subscription: Subscription, + gb: int +) -> Subscription: + + subscription.add_traffic(gb) + subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + + logger.info(f"📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика") + return subscription + + +async def add_subscription_devices( + db: AsyncSession, + subscription: Subscription, + devices: int +) -> Subscription: + + subscription.device_limit += devices + subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + + logger.info(f"📱 К подписке пользователя {subscription.user_id} добавлено {devices} устройств") + return subscription + + +async def add_subscription_squad( + db: AsyncSession, + subscription: Subscription, + squad_uuid: str +) -> Subscription: + + if squad_uuid not in subscription.connected_squads: + subscription.connected_squads = subscription.connected_squads + [squad_uuid] + subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + + logger.info(f"🌍 К подписке пользователя {subscription.user_id} добавлен сквад {squad_uuid}") + + return subscription + + +async def remove_subscription_squad( + db: AsyncSession, + subscription: Subscription, + squad_uuid: str +) -> Subscription: + + if squad_uuid in subscription.connected_squads: + squads = subscription.connected_squads.copy() + squads.remove(squad_uuid) + subscription.connected_squads = squads + subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + + logger.info(f"🚫 Из подписки пользователя {subscription.user_id} удален сквад {squad_uuid}") + + return subscription + + +async def update_subscription_autopay( + db: AsyncSession, + subscription: Subscription, + enabled: bool, + days_before: int = 3 +) -> Subscription: + + subscription.autopay_enabled = enabled + subscription.autopay_days_before = days_before + subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + + status = "включен" if enabled else "выключен" + logger.info(f"💳 Автоплатеж для подписки пользователя {subscription.user_id} {status}") + return subscription + + +async def deactivate_subscription( + db: AsyncSession, + subscription: Subscription +) -> Subscription: + + subscription.status = SubscriptionStatus.DISABLED.value + subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + + logger.info(f"❌ Подписка пользователя {subscription.user_id} деактивирована") + return subscription + + +async def get_expiring_subscriptions( + db: AsyncSession, + days_before: int = 3 +) -> List[Subscription]: + + threshold_date = datetime.utcnow() + timedelta(days=days_before) + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date <= threshold_date, + Subscription.end_date > datetime.utcnow() + ) + ) + ) + return result.scalars().all() + + +async def get_expired_subscriptions(db: AsyncSession) -> List[Subscription]: + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date <= datetime.utcnow() + ) + ) + ) + return result.scalars().all() + + +async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]: + current_time = datetime.utcnow() + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.autopay_enabled == True, + Subscription.is_trial == False + ) + ) + ) + all_autopay_subscriptions = result.scalars().all() + + ready_for_autopay = [] + for subscription in all_autopay_subscriptions: + days_until_expiry = (subscription.end_date - current_time).days + + if days_until_expiry <= subscription.autopay_days_before and subscription.end_date > current_time: + ready_for_autopay.append(subscription) + + return ready_for_autopay + + +async def get_subscriptions_statistics(db: AsyncSession) -> dict: + + total_result = await db.execute(select(func.count(Subscription.id))) + total_subscriptions = total_result.scalar() + + active_result = await db.execute( + select(func.count(Subscription.id)) + .where(Subscription.status == SubscriptionStatus.ACTIVE.value) + ) + active_subscriptions = active_result.scalar() + + trial_result = await db.execute( + select(func.count(Subscription.id)) + .where( + and_( + Subscription.is_trial == True, + Subscription.status == SubscriptionStatus.ACTIVE.value + ) + ) + ) + trial_subscriptions = trial_result.scalar() + + paid_subscriptions = active_subscriptions - trial_subscriptions + + today = datetime.utcnow().date() + today_result = await db.execute( + select(func.count(Subscription.id)) + .where( + and_( + Subscription.created_at >= today, + Subscription.is_trial == False + ) + ) + ) + purchased_today = today_result.scalar() + + week_ago = datetime.utcnow() - timedelta(days=7) + week_result = await db.execute( + select(func.count(Subscription.id)) + .where( + and_( + Subscription.created_at >= week_ago, + Subscription.is_trial == False + ) + ) + ) + purchased_week = week_result.scalar() + + month_ago = datetime.utcnow() - timedelta(days=30) + month_result = await db.execute( + select(func.count(Subscription.id)) + .where( + and_( + Subscription.created_at >= month_ago, + Subscription.is_trial == False + ) + ) + ) + purchased_month = month_result.scalar() + + return { + "total_subscriptions": total_subscriptions, + "active_subscriptions": active_subscriptions, + "trial_subscriptions": trial_subscriptions, + "paid_subscriptions": paid_subscriptions, + "purchased_today": purchased_today, + "purchased_week": purchased_week, + "purchased_month": purchased_month + } + + +async def update_subscription_usage( + db: AsyncSession, + subscription: Subscription, + used_gb: float +) -> Subscription: + subscription.traffic_used_gb = used_gb + subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + + return subscription + +async def get_all_subscriptions( + db: AsyncSession, + page: int = 1, + limit: int = 10 +) -> Tuple[List[Subscription], int]: + count_result = await db.execute( + select(func.count(Subscription.id)) + ) + total_count = count_result.scalar() + + offset = (page - 1) * limit + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .order_by(Subscription.created_at.desc()) + .offset(offset) + .limit(limit) + ) + + subscriptions = result.scalars().all() + + return subscriptions, total_count + +async def add_subscription_servers( + db: AsyncSession, + subscription: Subscription, + server_squad_ids: List[int], + paid_prices: List[int] = None +) -> Subscription: + + if paid_prices is None: + paid_prices = [0] * len(server_squad_ids) + + for i, server_id in enumerate(server_squad_ids): + subscription_server = SubscriptionServer( + subscription_id=subscription.id, + server_squad_id=server_id, + paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0 + ) + db.add(subscription_server) + + await db.commit() + await db.refresh(subscription) + + logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов") + return subscription + +async def get_subscription_server_ids( + db: AsyncSession, + subscription_id: int +) -> List[int]: + + result = await db.execute( + select(SubscriptionServer.server_squad_id) + .where(SubscriptionServer.subscription_id == subscription_id) + ) + return [row[0] for row in result.fetchall()] + + +async def get_subscription_servers( + db: AsyncSession, + subscription_id: int +) -> List[dict]: + + from app.database.models import ServerSquad + + result = await db.execute( + select(SubscriptionServer, ServerSquad) + .join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id) + .where(SubscriptionServer.subscription_id == subscription_id) + ) + + servers_info = [] + for sub_server, server_squad in result.fetchall(): + servers_info.append({ + 'server_id': server_squad.id, + 'squad_uuid': server_squad.squad_uuid, + 'display_name': server_squad.display_name, + 'country_code': server_squad.country_code, + 'paid_price_kopeks': sub_server.paid_price_kopeks, + 'connected_at': sub_server.connected_at, + 'is_available': server_squad.is_available + }) + + return servers_info + +async def create_subscription( + db: AsyncSession, + user_id: int, + status: str = "trial", + is_trial: bool = True, + end_date: datetime = None, + traffic_limit_gb: int = 10, + traffic_used_gb: float = 0.0, + device_limit: int = 1, + connected_squads: list = None, + remnawave_short_uuid: str = None, + subscription_url: str = "" +) -> Subscription: + + if end_date is None: + end_date = datetime.utcnow() + timedelta(days=3) + + if connected_squads is None: + connected_squads = [] + + subscription = Subscription( + user_id=user_id, + status=status, + is_trial=is_trial, + end_date=end_date, + traffic_limit_gb=traffic_limit_gb, + traffic_used_gb=traffic_used_gb, + device_limit=device_limit, + connected_squads=connected_squads, + remnawave_short_uuid=remnawave_short_uuid, + subscription_url=subscription_url + ) + + db.add(subscription) + await db.commit() + await db.refresh(subscription) + + logger.info(f"✅ Создана подписка для пользователя {user_id}") + return subscription \ No newline at end of file diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py new file mode 100644 index 00000000..ddf51e7f --- /dev/null +++ b/app/database/crud/transaction.py @@ -0,0 +1,278 @@ +import logging +from datetime import datetime, timedelta +from typing import Optional, List +from sqlalchemy import select, and_, or_, func, desc +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import Transaction, TransactionType, PaymentMethod, User + +logger = logging.getLogger(__name__) + + +async def create_transaction( + db: AsyncSession, + user_id: int, + type: TransactionType, + amount_kopeks: int, + description: str, + payment_method: Optional[PaymentMethod] = None, + external_id: Optional[str] = None, + is_completed: bool = True +) -> Transaction: + + transaction = Transaction( + user_id=user_id, + type=type.value, + amount_kopeks=amount_kopeks, + description=description, + payment_method=payment_method.value if payment_method else None, + external_id=external_id, + is_completed=is_completed, + completed_at=datetime.utcnow() if is_completed else None + ) + + db.add(transaction) + await db.commit() + await db.refresh(transaction) + + logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}") + return transaction + + +async def get_transaction_by_id(db: AsyncSession, transaction_id: int) -> Optional[Transaction]: + result = await db.execute( + select(Transaction) + .options(selectinload(Transaction.user)) + .where(Transaction.id == transaction_id) + ) + return result.scalar_one_or_none() + + +async def get_transaction_by_external_id( + db: AsyncSession, + external_id: str, + payment_method: PaymentMethod +) -> Optional[Transaction]: + result = await db.execute( + select(Transaction) + .where( + and_( + Transaction.external_id == external_id, + Transaction.payment_method == payment_method.value + ) + ) + ) + return result.scalar_one_or_none() + + +async def get_user_transactions( + db: AsyncSession, + user_id: int, + limit: int = 50, + offset: int = 0 +) -> List[Transaction]: + + result = await db.execute( + select(Transaction) + .where(Transaction.user_id == user_id) + .order_by(Transaction.created_at.desc()) + .offset(offset) + .limit(limit) + ) + return result.scalars().all() + + +async def get_user_transactions_count( + db: AsyncSession, + user_id: int, + transaction_type: Optional[TransactionType] = None +) -> int: + + query = select(func.count(Transaction.id)).where(Transaction.user_id == user_id) + + if transaction_type: + query = query.where(Transaction.type == transaction_type.value) + + result = await db.execute(query) + return result.scalar() + + +async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction: + + transaction.is_completed = True + transaction.completed_at = datetime.utcnow() + + await db.commit() + await db.refresh(transaction) + + logger.info(f"✅ Транзакция {transaction.id} завершена") + return transaction + + +async def get_pending_transactions(db: AsyncSession) -> List[Transaction]: + + result = await db.execute( + select(Transaction) + .options(selectinload(Transaction.user)) + .where(Transaction.is_completed == False) + .order_by(Transaction.created_at) + ) + return result.scalars().all() + + +async def get_transactions_statistics( + db: AsyncSession, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None +) -> dict: + + if not start_date: + start_date = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + if not end_date: + end_date = datetime.utcnow() + + income_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed == True, + Transaction.created_at >= start_date, + Transaction.created_at <= end_date + ) + ) + ) + total_income = income_result.scalar() + + expenses_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.type == TransactionType.WITHDRAWAL.value, + Transaction.is_completed == True, + Transaction.created_at >= start_date, + Transaction.created_at <= end_date + ) + ) + ) + total_expenses = expenses_result.scalar() + + subscription_income_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.is_completed == True, + Transaction.created_at >= start_date, + Transaction.created_at <= end_date + ) + ) + ) + subscription_income = subscription_income_result.scalar() + + transactions_count_result = await db.execute( + select( + Transaction.type, + func.count(Transaction.id).label('count'), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount') + ) + .where( + and_( + Transaction.is_completed == True, + Transaction.created_at >= start_date, + Transaction.created_at <= end_date + ) + ) + .group_by(Transaction.type) + ) + transactions_by_type = {row.type: {"count": row.count, "amount": row.total_amount} + for row in transactions_count_result} + + payment_methods_result = await db.execute( + select( + Transaction.payment_method, + func.count(Transaction.id).label('count'), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount') + ) + .where( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed == True, + Transaction.created_at >= start_date, + Transaction.created_at <= end_date + ) + ) + .group_by(Transaction.payment_method) + ) + payment_methods = {row.payment_method: {"count": row.count, "amount": row.total_amount} + for row in payment_methods_result} + + today = datetime.utcnow().date() + today_result = await db.execute( + select(func.count(Transaction.id)) + .where( + and_( + Transaction.is_completed == True, + Transaction.created_at >= today + ) + ) + ) + transactions_today = today_result.scalar() + + today_income_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed == True, + Transaction.created_at >= today + ) + ) + ) + income_today = today_income_result.scalar() + + return { + "period": { + "start_date": start_date, + "end_date": end_date + }, + "totals": { + "income_kopeks": total_income, + "expenses_kopeks": total_expenses, + "profit_kopeks": total_income - total_expenses, + "subscription_income_kopeks": subscription_income + }, + "today": { + "transactions_count": transactions_today, + "income_kopeks": income_today + }, + "by_type": transactions_by_type, + "by_payment_method": payment_methods + } + + +async def get_revenue_by_period( + db: AsyncSession, + days: int = 30 +) -> List[dict]: + + start_date = datetime.utcnow() - timedelta(days=days) + + result = await db.execute( + select( + func.date(Transaction.created_at).label('date'), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount') + ) + .where( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed == True, + Transaction.created_at >= start_date + ) + ) + .group_by(func.date(Transaction.created_at)) + .order_by(func.date(Transaction.created_at)) + ) + + return [{"date": row.date, "amount_kopeks": row.amount} for row in result] \ No newline at end of file diff --git a/app/database/crud/user.py b/app/database/crud/user.py new file mode 100644 index 00000000..7db3ceae --- /dev/null +++ b/app/database/crud/user.py @@ -0,0 +1,327 @@ +import logging +import secrets +import string +from datetime import datetime, timedelta +from typing import Optional, List +from sqlalchemy import select, and_, or_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import User, UserStatus, Subscription, Transaction +from app.config import settings + +logger = logging.getLogger(__name__) + + +def generate_referral_code() -> str: + alphabet = string.ascii_letters + string.digits + code_suffix = ''.join(secrets.choice(alphabet) for _ in range(8)) + return f"ref{code_suffix}" + + +async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]: + result = await db.execute( + select(User) + .options(selectinload(User.subscription)) + .where(User.id == user_id) + ) + user = result.scalar_one_or_none() + + if user and user.subscription: + _ = user.subscription.is_active + + return user + + +async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]: + result = await db.execute( + select(User) + .options(selectinload(User.subscription)) + .where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + + if user and user.subscription: + _ = user.subscription.is_active + + return user + + +async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]: + + result = await db.execute( + select(User).where(User.referral_code == referral_code) + ) + return result.scalar_one_or_none() + + +async def create_unique_referral_code(db: AsyncSession) -> str: + max_attempts = 10 + + for _ in range(max_attempts): + code = generate_referral_code() + existing_user = await get_user_by_referral_code(db, code) + if not existing_user: + return code + + timestamp = str(int(datetime.utcnow().timestamp()))[-6:] + return f"ref{timestamp}" + + +async def create_user( + db: AsyncSession, + telegram_id: int, + username: str = None, + first_name: str = None, + last_name: str = None, + language: str = "ru", + referred_by_id: int = None, + referral_code: str = None +) -> User: + + if not referral_code: + from app.utils.user_utils import generate_unique_referral_code + referral_code = await generate_unique_referral_code(db, telegram_id) + + user = User( + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + language=language, + referred_by_id=referred_by_id, + referral_code=referral_code, + balance_kopeks=0, + has_had_paid_subscription=False + ) + + db.add(user) + await db.commit() + await db.refresh(user) + + logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}") + + return user + + +async def update_user( + db: AsyncSession, + user: User, + **kwargs +) -> User: + + for field, value in kwargs.items(): + if hasattr(user, field): + setattr(user, field, value) + + user.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(user) + + return user + + +async def add_user_balance( + db: AsyncSession, + user: User, + amount_kopeks: int, + description: str = "Пополнение баланса" +) -> User: + + user.add_balance(amount_kopeks) + user.updated_at = datetime.utcnow() + + from app.database.crud.transaction import create_transaction + from app.database.models import TransactionType + + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.DEPOSIT, + amount_kopeks=amount_kopeks, + description=description + ) + + await db.commit() + await db.refresh(user) + + logger.info(f"💰 Пополнен баланс пользователя {user.telegram_id}: +{amount_kopeks/100}₽") + return user + + +async def subtract_user_balance( + db: AsyncSession, + user: User, + amount_kopeks: int, + description: str = "Списание с баланса" +) -> bool: + + if not user.subtract_balance(amount_kopeks): + return False + + user.updated_at = datetime.utcnow() + + from app.database.crud.transaction import create_transaction + from app.database.models import TransactionType + + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.WITHDRAWAL, + amount_kopeks=amount_kopeks, + description=description + ) + + await db.commit() + await db.refresh(user) + + logger.info(f"💸 Списан баланс пользователя {user.telegram_id}: -{amount_kopeks/100}₽") + return True + + +async def get_users_list( + db: AsyncSession, + offset: int = 0, + limit: int = 50, + search: Optional[str] = None, + status: Optional[UserStatus] = None +) -> List[User]: + + query = select(User).options(selectinload(User.subscription)) + + if status: + query = query.where(User.status == status.value) + + if search: + search_term = f"%{search}%" + conditions = [ + User.first_name.ilike(search_term), + User.last_name.ilike(search_term), + User.username.ilike(search_term) + ] + + if search.isdigit(): + conditions.append(User.telegram_id == int(search)) + + query = query.where(or_(*conditions)) + + query = query.order_by(User.created_at.desc()).offset(offset).limit(limit) + + result = await db.execute(query) + return result.scalars().all() + + +async def get_users_count( + db: AsyncSession, + status: Optional[UserStatus] = None, + search: Optional[str] = None +) -> int: + + query = select(func.count(User.id)) + + if status: + query = query.where(User.status == status.value) + + if search: + search_term = f"%{search}%" + conditions = [ + User.first_name.ilike(search_term), + User.last_name.ilike(search_term), + User.username.ilike(search_term) + ] + + if search.isdigit(): + conditions.append(User.telegram_id == int(search)) + + query = query.where(or_(*conditions)) + + result = await db.execute(query) + return result.scalar() + + +async def get_referrals(db: AsyncSession, user_id: int) -> List[User]: + result = await db.execute( + select(User) + .options(selectinload(User.subscription)) + .where(User.referred_by_id == user_id) + .order_by(User.created_at.desc()) + ) + return result.scalars().all() + + +async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]: + threshold_date = datetime.utcnow() - timedelta(days=months * 30) + + result = await db.execute( + select(User) + .options(selectinload(User.subscription)) + .where( + and_( + User.last_activity < threshold_date, + User.status == UserStatus.ACTIVE.value + ) + ) + ) + return result.scalars().all() + + +async def delete_user(db: AsyncSession, user: User) -> bool: + user.status = UserStatus.DELETED.value + user.updated_at = datetime.utcnow() + + await db.commit() + logger.info(f"🗑️ Пользователь {user.telegram_id} помечен как удаленный") + return True + + +async def get_users_statistics(db: AsyncSession) -> dict: + + total_result = await db.execute(select(func.count(User.id))) + total_users = total_result.scalar() + + active_result = await db.execute( + select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value) + ) + active_users = active_result.scalar() + + today = datetime.utcnow().date() + today_result = await db.execute( + select(func.count(User.id)).where( + and_( + User.created_at >= today, + User.status == UserStatus.ACTIVE.value + ) + ) + ) + new_today = today_result.scalar() + + week_ago = datetime.utcnow() - timedelta(days=7) + week_result = await db.execute( + select(func.count(User.id)).where( + and_( + User.created_at >= week_ago, + User.status == UserStatus.ACTIVE.value + ) + ) + ) + new_week = week_result.scalar() + + month_ago = datetime.utcnow() - timedelta(days=30) + month_result = await db.execute( + select(func.count(User.id)).where( + and_( + User.created_at >= month_ago, + User.status == UserStatus.ACTIVE.value + ) + ) + ) + new_month = month_result.scalar() + + return { + "total_users": total_users, + "active_users": active_users, + "blocked_users": total_users - active_users, + "new_today": new_today, + "new_week": new_week, + "new_month": new_month + } \ No newline at end of file diff --git a/app/database/database.py b/app/database/database.py new file mode 100644 index 00000000..94db65fb --- /dev/null +++ b/app/database/database.py @@ -0,0 +1,51 @@ +import logging +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.pool import NullPool + +from app.config import settings +from app.database.models import Base + +logger = logging.getLogger(__name__) + +engine = create_async_engine( + settings.DATABASE_URL, + poolclass=NullPool, + echo=settings.DEBUG, + future=True +) + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=True, + autocommit=False +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def init_db(): + logger.info("Создание таблиц базы данных...") + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + logger.info("✅ База данных успешно инициализирована") + + +async def close_db(): + await engine.dispose() + logger.info("✅ Подключение к базе данных закрыто") \ No newline at end of file diff --git a/app/database/models.py b/app/database/models.py new file mode 100644 index 00000000..2473493e --- /dev/null +++ b/app/database/models.py @@ -0,0 +1,421 @@ +from datetime import datetime, timedelta +from typing import Optional, List +from enum import Enum + +from sqlalchemy import ( + Column, Integer, String, DateTime, Boolean, Text, + ForeignKey, Float, JSON, BigInteger +) +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship, Mapped +from sqlalchemy.sql import func + + +Base = declarative_base() + + +class UserStatus(Enum): + ACTIVE = "active" + BLOCKED = "blocked" + DELETED = "deleted" + + +class SubscriptionStatus(Enum): + TRIAL = "trial" + ACTIVE = "active" + EXPIRED = "expired" + DISABLED = "disabled" + + +class TransactionType(Enum): + DEPOSIT = "deposit" + WITHDRAWAL = "withdrawal" + SUBSCRIPTION_PAYMENT = "subscription_payment" + REFUND = "refund" # Возврат + REFERRAL_REWARD = "referral_reward" + + +class PromoCodeType(Enum): + BALANCE = "balance" + SUBSCRIPTION_DAYS = "subscription_days" + TRIAL_SUBSCRIPTION = "trial_subscription" + + +class PaymentMethod(Enum): + TELEGRAM_STARS = "telegram_stars" + TRIBUTE = "tribute" + MANUAL = "manual" + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + telegram_id = Column(BigInteger, unique=True, index=True, nullable=False) + username = Column(String(255), nullable=True) + first_name = Column(String(255), nullable=True) + last_name = Column(String(255), nullable=True) + + status = Column(String(20), default=UserStatus.ACTIVE.value) + language = Column(String(5), default="ru") + + balance_kopeks = Column(Integer, default=0) + + used_promocodes = Column(Integer, default=0) + + has_had_paid_subscription = Column(Boolean, default=False, nullable=False) + + referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + referral_code = Column(String(20), unique=True, nullable=True) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + last_activity = Column(DateTime, default=func.now()) + + remnawave_uuid = Column(String(255), nullable=True, unique=True) + + broadcasts = relationship("BroadcastHistory", back_populates="admin") + + referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id") + subscription = relationship("Subscription", back_populates="user", uselist=False) + transactions = relationship("Transaction", back_populates="user") + referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") + + @property + def balance_rubles(self) -> float: + return self.balance_kopeks / 100 + + @property + def full_name(self) -> str: + parts = [self.first_name, self.last_name] + return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}" + + def add_balance(self, kopeks: int) -> None: + self.balance_kopeks += kopeks + + def subtract_balance(self, kopeks: int) -> bool: + if self.balance_kopeks >= kopeks: + self.balance_kopeks -= kopeks + return True + return False + + +class Subscription(Base): + __tablename__ = "subscriptions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + status = Column(String(20), default=SubscriptionStatus.TRIAL.value) + is_trial = Column(Boolean, default=True) + + start_date = Column(DateTime, default=func.now()) + end_date = Column(DateTime, nullable=False) + + traffic_limit_gb = Column(Integer, default=0) + traffic_used_gb = Column(Float, default=0.0) + + subscription_url = Column(String, nullable=True) + + device_limit = Column(Integer, default=1) + + connected_squads = Column(JSON, default=list) + + autopay_enabled = Column(Boolean, default=False) + autopay_days_before = Column(Integer, default=3) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + remnawave_short_uuid = Column(String(255), nullable=True) + + user = relationship("User", back_populates="subscription") + + @property + def is_active(self) -> bool: + return ( + self.status == SubscriptionStatus.ACTIVE.value and + self.end_date > datetime.utcnow() + ) + + @property + def is_expired(self) -> bool: + return self.end_date <= datetime.utcnow() + + @property + def days_left(self) -> int: + if self.is_expired: + return 0 + delta = self.end_date - datetime.utcnow() + return delta.days + + @property + def traffic_used_percent(self) -> float: + if self.traffic_limit_gb == 0: + return 0.0 + if self.traffic_limit_gb > 0: + return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0) + return 0.0 + + def extend_subscription(self, days: int): + from datetime import timedelta, datetime + + if self.end_date > datetime.utcnow(): + self.end_date = self.end_date + timedelta(days=days) + else: + self.end_date = datetime.utcnow() + timedelta(days=days) + + if self.status == SubscriptionStatus.EXPIRED.value: + self.status = SubscriptionStatus.ACTIVE.value + + def add_traffic(self, gb: int): + if self.traffic_limit_gb == 0: + return + self.traffic_limit_gb += gb + + +class Transaction(Base): + __tablename__ = "transactions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + type = Column(String(50), nullable=False) + amount_kopeks = Column(Integer, nullable=False) + description = Column(Text, nullable=True) + + payment_method = Column(String(50), nullable=True) + external_id = Column(String(255), nullable=True) + + is_completed = Column(Boolean, default=True) + + created_at = Column(DateTime, default=func.now()) + completed_at = Column(DateTime, nullable=True) + + user = relationship("User", back_populates="transactions") + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + +class PromoCode(Base): + __tablename__ = "promocodes" + + id = Column(Integer, primary_key=True, index=True) + + code = Column(String(50), unique=True, nullable=False, index=True) + type = Column(String(50), nullable=False) + + balance_bonus_kopeks = Column(Integer, default=0) + subscription_days = Column(Integer, default=0) + + max_uses = Column(Integer, default=1) + current_uses = Column(Integer, default=0) + + valid_from = Column(DateTime, default=func.now()) + valid_until = Column(DateTime, nullable=True) + + is_active = Column(Boolean, default=True) + + created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + uses = relationship("PromoCodeUse", back_populates="promocode") + + @property + def is_valid(self) -> bool: + now = datetime.utcnow() + return ( + self.is_active and + self.current_uses < self.max_uses and + self.valid_from <= now and + (self.valid_until is None or self.valid_until >= now) + ) + + @property + def uses_left(self) -> int: + return max(0, self.max_uses - self.current_uses) + + +class PromoCodeUse(Base): + __tablename__ = "promocode_uses" + + id = Column(Integer, primary_key=True, index=True) + promocode_id = Column(Integer, ForeignKey("promocodes.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + used_at = Column(DateTime, default=func.now()) + + promocode = relationship("PromoCode", back_populates="uses") + user = relationship("User") + + +class ReferralEarning(Base): + __tablename__ = "referral_earnings" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + referral_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + amount_kopeks = Column(Integer, nullable=False) + reason = Column(String(100), nullable=False) + + referral_transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + + created_at = Column(DateTime, default=func.now()) + + user = relationship("User", foreign_keys=[user_id], back_populates="referral_earnings") + referral = relationship("User", foreign_keys=[referral_id]) + referral_transaction = relationship("Transaction") + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + +class Squad(Base): + __tablename__ = "squads" + + id = Column(Integer, primary_key=True, index=True) + + uuid = Column(String(255), unique=True, nullable=False) + name = Column(String(255), nullable=False) + country_code = Column(String(5), nullable=True) + + is_available = Column(Boolean, default=True) + price_kopeks = Column(Integer, default=0) + + description = Column(Text, nullable=True) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + @property + def price_rubles(self) -> float: + return self.price_kopeks / 100 + + +class ServiceRule(Base): + __tablename__ = "service_rules" + + id = Column(Integer, primary_key=True, index=True) + + order = Column(Integer, default=0) + title = Column(String(255), nullable=False) + + content = Column(Text, nullable=False) + + is_active = Column(Boolean, default=True) + + language = Column(String(5), default="ru") + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +class SystemSetting(Base): + __tablename__ = "system_settings" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String(255), unique=True, nullable=False) + value = Column(Text, nullable=True) + description = Column(Text, nullable=True) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +class MonitoringLog(Base): + __tablename__ = "monitoring_logs" + + id = Column(Integer, primary_key=True, index=True) + + event_type = Column(String(100), nullable=False) + + message = Column(Text, nullable=False) + data = Column(JSON, nullable=True) + + is_success = Column(Boolean, default=True) + + created_at = Column(DateTime, default=func.now()) + +class BroadcastHistory(Base): + __tablename__ = "broadcast_history" + + id = Column(Integer, primary_key=True, index=True) + target_type = Column(String(100), nullable=False) + message_text = Column(Text, nullable=False) + total_count = Column(Integer, default=0) + sent_count = Column(Integer, default=0) + failed_count = Column(Integer, default=0) + status = Column(String(50), default="in_progress") + admin_id = Column(Integer, ForeignKey("users.id")) + admin_name = Column(String(255)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + completed_at = Column(DateTime(timezone=True), nullable=True) + + admin = relationship("User", back_populates="broadcasts") + +class ServerSquad(Base): + __tablename__ = "server_squads" + + id = Column(Integer, primary_key=True, index=True) + + squad_uuid = Column(String(255), unique=True, nullable=False, index=True) + + display_name = Column(String(255), nullable=False) + + original_name = Column(String(255), nullable=True) + + country_code = Column(String(5), nullable=True) + + is_available = Column(Boolean, default=True) + + price_kopeks = Column(Integer, default=0) + + description = Column(Text, nullable=True) + + sort_order = Column(Integer, default=0) + + max_users = Column(Integer, nullable=True) + current_users = Column(Integer, default=0) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + @property + def price_rubles(self) -> float: + return self.price_kopeks / 100 + + @property + def is_full(self) -> bool: + if self.max_users is None: + return False + return self.current_users >= self.max_users + + @property + def availability_status(self) -> str: + if not self.is_available: + return "Недоступен" + elif self.is_full: + return "Переполнен" + else: + return "Доступен" + + +class SubscriptionServer(Base): + __tablename__ = "subscription_servers" + + id = Column(Integer, primary_key=True, index=True) + subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False) + server_squad_id = Column(Integer, ForeignKey("server_squads.id"), nullable=False) + + connected_at = Column(DateTime, default=func.now()) + + paid_price_kopeks = Column(Integer, default=0) + + subscription = relationship("Subscription", backref="subscription_servers") + server_squad = relationship("ServerSquad", backref="subscription_servers") \ No newline at end of file diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py new file mode 100644 index 00000000..527ede11 --- /dev/null +++ b/app/external/remnawave_api.py @@ -0,0 +1,521 @@ +import asyncio +import json +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Union, Any +import aiohttp +import logging +from dataclasses import dataclass +from enum import Enum + +logger = logging.getLogger(__name__) + + +class UserStatus(Enum): + ACTIVE = "ACTIVE" + DISABLED = "DISABLED" + LIMITED = "LIMITED" + EXPIRED = "EXPIRED" + + +class TrafficLimitStrategy(Enum): + NO_RESET = "NO_RESET" + DAY = "DAY" + WEEK = "WEEK" + MONTH = "MONTH" + + +@dataclass +class RemnaWaveUser: + uuid: str + short_uuid: str + username: str + status: UserStatus + used_traffic_bytes: int + lifetime_used_traffic_bytes: int + traffic_limit_bytes: int + traffic_limit_strategy: TrafficLimitStrategy + expire_at: datetime + telegram_id: Optional[int] + email: Optional[str] + hwid_device_limit: Optional[int] + description: Optional[str] + tag: Optional[str] + subscription_url: str + active_internal_squads: List[Dict[str, str]] + created_at: datetime + updated_at: datetime + + +@dataclass +class RemnaWaveInternalSquad: + uuid: str + name: str + members_count: int + inbounds_count: int + inbounds: List[Dict[str, Any]] + + +@dataclass +class RemnaWaveNode: + uuid: str + name: str + address: str + country_code: str + is_connected: bool + is_disabled: bool + is_node_online: bool + is_xray_running: bool + users_online: Optional[int] + traffic_used_bytes: Optional[int] + traffic_limit_bytes: Optional[int] + + +class RemnaWaveAPIError(Exception): + def __init__(self, message: str, status_code: int = None, response_data: dict = None): + self.message = message + self.status_code = status_code + self.response_data = response_data + super().__init__(self.message) + + +class RemnaWaveAPI: + + def __init__(self, base_url: str, api_key: str): + self.base_url = base_url.rstrip('/') + self.api_key = api_key + self.session: Optional[aiohttp.ClientSession] = None + + async def __aenter__(self): + self.session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + headers={ + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + ) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.session: + await self.session.close() + + async def _make_request( + self, + method: str, + endpoint: str, + data: Optional[Dict] = None, + params: Optional[Dict] = None + ) -> Dict: + if not self.session: + raise RemnaWaveAPIError("Session not initialized. Use async context manager.") + + url = f"{self.base_url}{endpoint}" + + try: + kwargs = { + 'url': url, + 'params': params + } + + if data: + kwargs['json'] = data + + async with self.session.request(method, **kwargs) as response: + response_text = await response.text() + + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + response_data = {'raw_response': response_text} + + if response.status >= 400: + error_message = response_data.get('message', f'HTTP {response.status}') + raise RemnaWaveAPIError( + error_message, + response.status, + response_data + ) + + return response_data + + except aiohttp.ClientError as e: + logger.error(f"Request failed: {e}") + raise RemnaWaveAPIError(f"Request failed: {str(e)}") + + + async def create_user( + self, + username: str, + expire_at: datetime, + status: UserStatus = UserStatus.ACTIVE, + traffic_limit_bytes: int = 0, + traffic_limit_strategy: TrafficLimitStrategy = TrafficLimitStrategy.NO_RESET, + telegram_id: Optional[int] = None, + email: Optional[str] = None, + hwid_device_limit: Optional[int] = None, + description: Optional[str] = None, + tag: Optional[str] = None, + active_internal_squads: Optional[List[str]] = None + ) -> RemnaWaveUser: + data = { + 'username': username, + 'status': status.value, + 'expireAt': expire_at.isoformat(), + 'trafficLimitBytes': traffic_limit_bytes, + 'trafficLimitStrategy': traffic_limit_strategy.value + } + + if telegram_id: + data['telegramId'] = telegram_id + if email: + data['email'] = email + if hwid_device_limit: + data['hwidDeviceLimit'] = hwid_device_limit + if description: + data['description'] = description + if tag: + data['tag'] = tag + if active_internal_squads: + data['activeInternalSquads'] = active_internal_squads + + response = await self._make_request('POST', '/api/users', data) + return self._parse_user(response['response']) + + async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]: + try: + response = await self._make_request('GET', f'/api/users/{uuid}') + return self._parse_user(response['response']) + except RemnaWaveAPIError as e: + if e.status_code == 404: + return None + raise + + async def get_user_by_telegram_id(self, telegram_id: int) -> List[RemnaWaveUser]: + try: + response = await self._make_request('GET', f'/api/users/by-telegram-id/{telegram_id}') + return [self._parse_user(user) for user in response['response']] + except RemnaWaveAPIError as e: + if e.status_code == 404: + return [] + raise + + async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]: + try: + response = await self._make_request('GET', f'/api/users/by-username/{username}') + return self._parse_user(response['response']) + except RemnaWaveAPIError as e: + if e.status_code == 404: + return None + raise + + async def update_user( + self, + uuid: str, + status: Optional[UserStatus] = None, + traffic_limit_bytes: Optional[int] = None, + traffic_limit_strategy: Optional[TrafficLimitStrategy] = None, + expire_at: Optional[datetime] = None, + telegram_id: Optional[int] = None, + email: Optional[str] = None, + hwid_device_limit: Optional[int] = None, + description: Optional[str] = None, + tag: Optional[str] = None, + active_internal_squads: Optional[List[str]] = None + ) -> RemnaWaveUser: + data = {'uuid': uuid} + + if status: + data['status'] = status.value + if traffic_limit_bytes is not None: + data['trafficLimitBytes'] = traffic_limit_bytes + if traffic_limit_strategy: + data['trafficLimitStrategy'] = traffic_limit_strategy.value + if expire_at: + data['expireAt'] = expire_at.isoformat() + if telegram_id is not None: + data['telegramId'] = telegram_id + if email is not None: + data['email'] = email + if hwid_device_limit is not None: + data['hwidDeviceLimit'] = hwid_device_limit + if description is not None: + data['description'] = description + if tag is not None: + data['tag'] = tag + if active_internal_squads is not None: + data['activeInternalSquads'] = active_internal_squads + + response = await self._make_request('PATCH', '/api/users', data) + return self._parse_user(response['response']) + + async def delete_user(self, uuid: str) -> bool: + response = await self._make_request('DELETE', f'/api/users/{uuid}') + return response['response']['isDeleted'] + + async def enable_user(self, uuid: str) -> RemnaWaveUser: + response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable') + return self._parse_user(response['response']) + + async def disable_user(self, uuid: str) -> RemnaWaveUser: + response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable') + return self._parse_user(response['response']) + + async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser: + response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic') + return self._parse_user(response['response']) + + async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser: + data = {} + if new_short_uuid: + data['shortUuid'] = new_short_uuid + + response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data) + return self._parse_user(response['response']) + + async def get_all_users(self, start: int = 0, size: int = 100) -> Dict[str, Any]: + params = {'start': start, 'size': size} + response = await self._make_request('GET', '/api/users', params=params) + + return { + 'users': [self._parse_user(user) for user in response['response']['users']], + 'total': response['response']['total'] + } + + + async def get_internal_squads(self) -> List[RemnaWaveInternalSquad]: + response = await self._make_request('GET', '/api/internal-squads') + return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']] + + async def get_internal_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveInternalSquad]: + try: + response = await self._make_request('GET', f'/api/internal-squads/{uuid}') + return self._parse_internal_squad(response['response']) + except RemnaWaveAPIError as e: + if e.status_code == 404: + return None + raise + + async def create_internal_squad(self, name: str, inbounds: List[str]) -> RemnaWaveInternalSquad: + data = { + 'name': name, + 'inbounds': inbounds + } + response = await self._make_request('POST', '/api/internal-squads', data) + return self._parse_internal_squad(response['response']) + + async def update_internal_squad( + self, + uuid: str, + name: Optional[str] = None, + inbounds: Optional[List[str]] = None + ) -> RemnaWaveInternalSquad: + data = {'uuid': uuid} + if name: + data['name'] = name + if inbounds is not None: + data['inbounds'] = inbounds + + response = await self._make_request('PATCH', '/api/internal-squads', data) + return self._parse_internal_squad(response['response']) + + async def delete_internal_squad(self, uuid: str) -> bool: + response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}') + return response['response']['isDeleted'] + + + async def get_all_nodes(self) -> List[RemnaWaveNode]: + response = await self._make_request('GET', '/api/nodes') + return [self._parse_node(node) for node in response['response']] + + async def get_node_by_uuid(self, uuid: str) -> Optional[RemnaWaveNode]: + try: + response = await self._make_request('GET', f'/api/nodes/{uuid}') + return self._parse_node(response['response']) + except RemnaWaveAPIError as e: + if e.status_code == 404: + return None + raise + + async def enable_node(self, uuid: str) -> RemnaWaveNode: + response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/enable') + return self._parse_node(response['response']) + + async def disable_node(self, uuid: str) -> RemnaWaveNode: + response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/disable') + return self._parse_node(response['response']) + + async def restart_node(self, uuid: str) -> bool: + response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/restart') + return response['response']['eventSent'] + + async def restart_all_nodes(self) -> bool: + response = await self._make_request('POST', '/api/nodes/actions/restart-all') + return response['response']['eventSent'] + + + async def get_subscription_info(self, short_uuid: str) -> Dict[str, Any]: + response = await self._make_request('GET', f'/api/sub/{short_uuid}/info') + return response['response'] + + + async def get_system_stats(self) -> Dict[str, Any]: + response = await self._make_request('GET', '/api/system/stats') + return response['response'] + + async def get_bandwidth_stats(self) -> Dict[str, Any]: + response = await self._make_request('GET', '/api/system/stats/bandwidth') + return response['response'] + + async def get_nodes_statistics(self) -> Dict[str, Any]: + response = await self._make_request('GET', '/api/system/stats/nodes') + return response['response'] + + async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]: + response = await self._make_request('GET', '/api/nodes/usage/realtime') + return response['response'] + + + def _parse_user(self, user_data: Dict) -> RemnaWaveUser: + return RemnaWaveUser( + uuid=user_data['uuid'], + short_uuid=user_data['shortUuid'], + username=user_data['username'], + status=UserStatus(user_data['status']), + used_traffic_bytes=int(user_data['usedTrafficBytes']), + lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']), + traffic_limit_bytes=user_data['trafficLimitBytes'], + traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']), + expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')), + telegram_id=user_data.get('telegramId'), + email=user_data.get('email'), + hwid_device_limit=user_data.get('hwidDeviceLimit'), + description=user_data.get('description'), + tag=user_data.get('tag'), + subscription_url=user_data['subscriptionUrl'], + active_internal_squads=user_data['activeInternalSquads'], + created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')), + updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')) + ) + + def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad: + return RemnaWaveInternalSquad( + uuid=squad_data['uuid'], + name=squad_data['name'], + members_count=squad_data['info']['membersCount'], + inbounds_count=squad_data['info']['inboundsCount'], + inbounds=squad_data['inbounds'] + ) + + def _parse_node(self, node_data: Dict) -> RemnaWaveNode: + return RemnaWaveNode( + uuid=node_data['uuid'], + name=node_data['name'], + address=node_data['address'], + country_code=node_data['countryCode'], + is_connected=node_data['isConnected'], + is_disabled=node_data['isDisabled'], + is_node_online=node_data['isNodeOnline'], + is_xray_running=node_data['isXrayRunning'], + users_online=node_data.get('usersOnline'), + traffic_used_bytes=node_data.get('trafficUsedBytes'), + traffic_limit_bytes=node_data.get('trafficLimitBytes') + ) + + + +def format_bytes(bytes_value: int) -> str: + if bytes_value == 0: + return "0 B" + + units = ["B", "KB", "MB", "GB", "TB"] + size = bytes_value + unit_index = 0 + + while size >= 1024 and unit_index < len(units) - 1: + size /= 1024 + unit_index += 1 + + return f"{size:.1f} {units[unit_index]}" + + +def parse_bytes(size_str: str) -> int: + size_str = size_str.upper().strip() + + units = { + 'B': 1, + 'KB': 1024, + 'MB': 1024 ** 2, + 'GB': 1024 ** 3, + 'TB': 1024 ** 4 + } + + for unit, multiplier in units.items(): + if size_str.endswith(unit): + try: + value = float(size_str[:-len(unit)].strip()) + return int(value * multiplier) + except ValueError: + break + + return 0 + + +async def test_api_connection(api: RemnaWaveAPI) -> bool: + try: + await api.get_system_stats() + return True + except Exception as e: + logger.error(f"API connection test failed: {e}") + return False + +async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]: + try: + response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}') + return response['response'] + except RemnaWaveAPIError as e: + if e.status_code == 404: + return {'total': 0, 'devices': []} + raise + + +async def reset_user_devices(self, user_uuid: str) -> bool: + try: + devices_info = await self.get_user_devices(user_uuid) + devices = devices_info.get('devices', []) + + if not devices: + return True + + failed_count = 0 + for device in devices: + device_hwid = device.get('hwid') + if device_hwid: + try: + delete_data = { + "userUuid": user_uuid, + "hwid": device_hwid + } + await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data) + except Exception as device_error: + logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}") + failed_count += 1 + + return failed_count < len(devices) / 2 + + except Exception as e: + logger.error(f"Ошибка при сбросе устройств: {e}") + return False + + + +async def remove_device(self, user_uuid: str, device_hwid: str) -> bool: + try: + delete_data = { + "userUuid": user_uuid, + "hwid": device_hwid + } + await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data) + return True + except Exception as e: + logger.error(f"Ошибка удаления устройства {device_hwid}: {e}") + return False \ No newline at end of file diff --git a/app/external/telegram_stars.py b/app/external/telegram_stars.py new file mode 100644 index 00000000..0a189105 --- /dev/null +++ b/app/external/telegram_stars.py @@ -0,0 +1,100 @@ +import logging +from typing import Optional, Dict, Any +from aiogram import Bot +from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class TelegramStarsService: + + def __init__(self, bot: Bot): + self.bot = bot + + async def create_invoice( + self, + chat_id: int, + title: str, + description: str, + amount_kopeks: int, + payload: str, + start_parameter: Optional[str] = None + ) -> Optional[str]: + try: + stars_amount = max(1, amount_kopeks // 100) + + invoice_link = await self.bot.create_invoice_link( + title=title, + description=description, + payload=payload, + provider_token="", + currency="XTR", + prices=[LabeledPrice(label=title, amount=stars_amount)], + start_parameter=start_parameter + ) + + logger.info(f"Создан Stars invoice на {stars_amount} звезд для {chat_id}") + return invoice_link + + except Exception as e: + logger.error(f"Ошибка создания Stars invoice: {e}") + return None + + async def send_invoice( + self, + chat_id: int, + title: str, + description: str, + amount_kopeks: int, + payload: str, + keyboard: Optional[InlineKeyboardMarkup] = None + ) -> Optional[Dict[str, Any]]: + try: + stars_amount = max(1, amount_kopeks // 100) + + message = await self.bot.send_invoice( + chat_id=chat_id, + title=title, + description=description, + payload=payload, + provider_token="", + currency="XTR", + prices=[LabeledPrice(label=title, amount=stars_amount)], + reply_markup=keyboard + ) + + logger.info(f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд") + return { + "message_id": message.message_id, + "stars_amount": stars_amount, + "payload": payload + } + + except Exception as e: + logger.error(f"Ошибка отправки Stars invoice: {e}") + return None + + async def answer_pre_checkout_query( + self, + pre_checkout_query_id: str, + ok: bool = True, + error_message: Optional[str] = None + ) -> bool: + try: + await self.bot.answer_pre_checkout_query( + pre_checkout_query_id=pre_checkout_query_id, + ok=ok, + error_message=error_message + ) + return True + except Exception as e: + logger.error(f"Ошибка ответа на pre_checkout_query: {e}") + return False + + def calculate_stars_amount(self, rubles: float) -> int: + return max(1, int(rubles)) + + def calculate_rubles_from_stars(self, stars: int) -> float: + return float(stars) \ No newline at end of file diff --git a/app/external/tribute.py b/app/external/tribute.py new file mode 100644 index 00000000..f9f847be --- /dev/null +++ b/app/external/tribute.py @@ -0,0 +1,108 @@ +import logging +import hashlib +import hmac +import json +from typing import Optional, Dict, Any + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class TributeService: + + def __init__(self): + self.api_key = settings.TRIBUTE_API_KEY + self.webhook_secret = settings.TRIBUTE_WEBHOOK_SECRET + self.donate_link = settings.TRIBUTE_DONATE_LINK + + async def create_payment_link( + self, + user_id: int, + amount_kopeks: int = 0, + description: str = "Пополнение баланса" + ) -> Optional[str]: + + if not settings.TRIBUTE_ENABLED: + logger.warning("Tribute платежи отключены") + return None + + try: + + payment_url = f"{self.donate_link}&user_id={user_id}" + + logger.info(f"Создана ссылка Tribute для пользователя {user_id}") + return payment_url + + except Exception as e: + logger.error(f"Ошибка создания Tribute ссылки: {e}") + return None + + def verify_webhook_signature(self, payload: str, signature: str) -> bool: + + if not self.webhook_secret: + logger.warning("Webhook secret не настроен") + return True + + try: + expected_signature = hmac.new( + self.webhook_secret.encode(), + payload.encode(), + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(signature, expected_signature) + + except Exception as e: + logger.error(f"Ошибка проверки подписи webhook: {e}") + return False + + async def process_webhook(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + + try: + + payment_id = payload.get("id") or payload.get("payment_id") + status = payload.get("status") + amount_rubles = payload.get("amount", 0) + telegram_user_id = payload.get("telegram_user_id") or payload.get("user_id") + + if not payment_id and "payload" in payload: + data = payload["payload"] + payment_id = data.get("id") or data.get("payment_id") + status = data.get("status") + amount_rubles = data.get("amount", 0) + telegram_user_id = data.get("telegram_user_id") or data.get("user_id") + + if not payment_id and "name" in payload: + event_name = payload.get("name") + data = payload.get("payload", {}) + payment_id = str(data.get("donation_request_id")) + amount_kopeks = data.get("amount", 0) + telegram_user_id = data.get("telegram_user_id") + + if event_name == "new_donation": + status = "paid" + elif event_name == "cancelled_subscription": + status = "cancelled" + else: + status = "unknown" + + logger.info(f"Обработка Tribute webhook: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}") + + if not telegram_user_id: + logger.error("Не найден telegram_user_id в webhook данных") + return None + + return { + "event_type": "payment", + "payment_id": payment_id or f"tribute_{telegram_user_id}_{amount_kopeks}", + "user_id": int(telegram_user_id), + "amount_kopeks": amount_kopeks, + "status": status or "paid", + "external_id": f"donation_{payment_id}" + } + + except Exception as e: + logger.error(f"Ошибка обработки Tribute webhook: {e}") + logger.error(f"Webhook payload: {json.dumps(payload, ensure_ascii=False)}") + return None \ No newline at end of file diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py new file mode 100644 index 00000000..417473f6 --- /dev/null +++ b/app/external/webhook_server.py @@ -0,0 +1,98 @@ +import logging +from typing import Optional + +from aiohttp import web +from aiogram import Bot + +from app.config import settings +from app.services.tribute_service import TributeService + +logger = logging.getLogger(__name__) + + +class WebhookServer: + + def __init__(self, bot: Bot): + self.bot = bot + self.app = None + self.runner = None + self.site = None + self.tribute_service = TributeService(bot) + + async def create_app(self) -> web.Application: + + self.app = web.Application() + + self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler) + self.app.router.add_get('/health', self._health_check) + + logger.info(f"Webhook сервер настроен:") + logger.info(f" - Tribute webhook: {settings.TRIBUTE_WEBHOOK_PATH}") + logger.info(f" - Health check: /health") + + return self.app + + async def start(self): + + try: + if not self.app: + await self.create_app() + + self.runner = web.AppRunner(self.app) + await self.runner.setup() + + self.site = web.TCPSite( + self.runner, + host='0.0.0.0', + port=settings.TRIBUTE_WEBHOOK_PORT + ) + + await self.site.start() + + logger.info(f"✅ Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}") + logger.info(f"🎯 Tribute webhook URL: http://your-server:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}") + + except Exception as e: + logger.error(f"❌ Ошибка запуска webhook сервера: {e}") + raise + + async def stop(self): + + try: + if self.site: + await self.site.stop() + logger.info("Webhook сайт остановлен") + + if self.runner: + await self.runner.cleanup() + logger.info("Webhook runner очищен") + + except Exception as e: + logger.error(f"Ошибка остановки webhook сервера: {e}") + + async def _tribute_webhook_handler(self, request: web.Request) -> web.Response: + + try: + raw_body = await request.read() + payload = raw_body.decode('utf-8') + + signature = request.headers.get('X-Tribute-Signature') + + result = await self.tribute_service.process_webhook(payload, signature) + + return web.json_response(result, status=200) + + except Exception as e: + logger.error(f"Ошибка обработки Tribute webhook: {e}") + return web.json_response( + {"status": "error", "reason": "internal_error"}, + status=500 + ) + + async def _health_check(self, request: web.Request) -> web.Response: + + return web.json_response({ + "status": "ok", + "service": "tribute-webhooks", + "tribute_enabled": settings.TRIBUTE_ENABLED + }) \ No newline at end of file diff --git a/app/handlers/__init__.py b/app/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/handlers/admin/__init__.py b/app/handlers/admin/__init__.py new file mode 100644 index 00000000..f6894672 --- /dev/null +++ b/app/handlers/admin/__init__.py @@ -0,0 +1 @@ +# Инициализация админских обработчиков \ No newline at end of file diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py new file mode 100644 index 00000000..953cbf1e --- /dev/null +++ b/app/handlers/admin/main.py @@ -0,0 +1,37 @@ +import logging +from aiogram import Dispatcher, types, F +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.admin import get_admin_main_keyboard +from app.localization.texts import get_texts +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_admin_panel( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + + admin_text = texts.ADMIN_PANEL + + await callback.message.edit_text( + admin_text, + reply_markup=get_admin_main_keyboard(db_user.language) + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + + dp.callback_query.register( + show_admin_panel, + F.data == "admin_panel" + ) \ No newline at end of file diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py new file mode 100644 index 00000000..638e7c15 --- /dev/null +++ b/app/handlers/admin/messages.py @@ -0,0 +1,551 @@ +import logging +import asyncio +from datetime import datetime, timedelta +from typing import Optional +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_ + +from app.config import settings +from app.states import AdminStates +from app.database.models import User, UserStatus, Subscription, BroadcastHistory +from app.keyboards.admin import ( + get_admin_messages_keyboard, get_broadcast_target_keyboard, + get_custom_criteria_keyboard, get_broadcast_history_keyboard, + get_admin_pagination_keyboard +) +from app.localization.texts import get_texts +from app.database.crud.user import get_users_list +from app.database.crud.subscription import get_expiring_subscriptions +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_messages_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + text = """ +📨 Управление рассылками + +Выберите тип рассылки: + +- Всем пользователям - рассылка всем активным пользователям +- По подпискам - фильтрация по типу подписки +- По критериям - настраиваемые фильтры +- История - просмотр предыдущих рассылок + +⚠️ Будьте осторожны с массовыми рассылками! +""" + + await callback.message.edit_text( + text, + reply_markup=get_admin_messages_keyboard(db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_broadcast_targets( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + await callback.message.edit_text( + "🎯 Выбор целевой аудитории\n\n" + "Выберите категорию пользователей для рассылки:", + reply_markup=get_broadcast_target_keyboard(db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_messages_history( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + page = 1 + if '_page_' in callback.data: + page = int(callback.data.split('_page_')[1]) + + limit = 10 + offset = (page - 1) * limit + + stmt = select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit) + result = await db.execute(stmt) + broadcasts = result.scalars().all() + + count_stmt = select(func.count(BroadcastHistory.id)) + count_result = await db.execute(count_stmt) + total_count = count_result.scalar() or 0 + total_pages = (total_count + limit - 1) // limit + + if not broadcasts: + text = """ +📋 История рассылок + +❌ История рассылок пуста. +Отправьте первую рассылку, чтобы увидеть её здесь. +""" + keyboard = [[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]] + else: + text = f"📋 История рассылок (страница {page}/{total_pages})\n\n" + + for broadcast in broadcasts: + status_emoji = "✅" if broadcast.status == "completed" else "❌" if broadcast.status == "failed" else "⏳" + success_rate = round((broadcast.sent_count / broadcast.total_count * 100), 1) if broadcast.total_count > 0 else 0 + + message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text + + text += f""" +{status_emoji} {broadcast.created_at.strftime('%d.%m.%Y %H:%M')} +📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%) +🎯 Аудитория: {get_target_name(broadcast.target_type)} +👤 Админ: {broadcast.admin_name} +📝 Сообщение: {message_preview} +━━━━━━━━━━━━━━━━━━━━ +""" + + keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_custom_broadcast( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession +): + + stats = await get_users_statistics(db) + + text = f""" +🔍 Рассылка по критериям + +📊 Доступные фильтры: + +👥 По регистрации: +• Сегодня: {stats['today']} чел. +• За неделю: {stats['week']} чел. +• За месяц: {stats['month']} чел. + +💼 По активности: +• Активные сегодня: {stats['active_today']} чел. +• Неактивные 7+ дней: {stats['inactive_week']} чел. +• Неактивные 30+ дней: {stats['inactive_month']} чел. + +🔗 По источнику: +• Через рефералов: {stats['referrals']} чел. +• Прямая регистрация: {stats['direct']} чел. + +Выберите критерий для фильтрации: +""" + + await callback.message.edit_text( + text, + reply_markup=get_custom_criteria_keyboard(db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def select_custom_criteria( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession +): + criteria = callback.data.replace('criteria_', '') + + criteria_names = { + "today": "Зарегистрированные сегодня", + "week": "Зарегистрированные за неделю", + "month": "Зарегистрированные за месяц", + "active_today": "Активные сегодня", + "inactive_week": "Неактивные 7+ дней", + "inactive_month": "Неактивные 30+ дней", + "referrals": "Пришедшие через рефералов", + "direct": "Прямая регистрация" + } + + user_count = await get_custom_users_count(db, criteria) + + await state.update_data(broadcast_target=f"custom_{criteria}") + + await callback.message.edit_text( + f"📨 Создание рассылки\n\n" + f"🎯 Критерий: {criteria_names.get(criteria, criteria)}\n" + f"👥 Получателей: {user_count}\n\n" + f"Введите текст сообщения для рассылки:\n\n" + f"Поддерживается HTML разметка", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] + ]) + ) + + await state.set_state(AdminStates.waiting_for_broadcast_message) + await callback.answer() + + +@admin_required +@error_handler +async def select_broadcast_target( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession +): + target = callback.data.split('_')[-1] + + target_names = { + "all": "Всем пользователям", + "active": "С активной подпиской", + "trial": "С триальной подпиской", + "no": "Без подписки", + "expiring": "С истекающей подпиской" + } + + user_count = await get_target_users_count(db, target) + + await state.update_data(broadcast_target=target) + + await callback.message.edit_text( + f"📨 Создание рассылки\n\n" + f"🎯 Аудитория: {target_names.get(target, target)}\n" + f"👥 Получателей: {user_count}\n\n" + f"Введите текст сообщения для рассылки:\n\n" + f"Поддерживается HTML разметка", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] + ]) + ) + + await state.set_state(AdminStates.waiting_for_broadcast_message) + await callback.answer() + + +@admin_required +@error_handler +async def process_broadcast_message( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + broadcast_text = message.text + + if len(broadcast_text) > 4000: + await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)") + return + + data = await state.get_data() + target = data.get('broadcast_target') + + user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', '')) + + await state.update_data(broadcast_message=broadcast_text) + + target_display = get_target_display_name(target) + + preview_text = f""" +📨 Предварительный просмотр рассылки + +🎯 Аудитория: {target_display} +👥 Получателей: {user_count} + +📝 Сообщение: +{broadcast_text} + +Подтвердить отправку? +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"), + types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages") + ] + ] + + await message.answer( + preview_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await state.set_state(AdminStates.confirming_broadcast) + + +@admin_required +@error_handler +async def confirm_broadcast( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession +): + data = await state.get_data() + target = data.get('broadcast_target') + message_text = data.get('broadcast_message') + + await callback.message.edit_text( + "📨 Начинаю рассылку...\n\n" + "⏳ Это может занять несколько минут.", + reply_markup=None + ) + + if target.startswith('custom_'): + users = await get_custom_users(db, target.replace('custom_', '')) + else: + users = await get_target_users(db, target) + + broadcast_history = BroadcastHistory( + target_type=target, + message_text=message_text, + total_count=len(users), + sent_count=0, + failed_count=0, + admin_id=db_user.id, + admin_name=db_user.full_name, + status="in_progress" + ) + db.add(broadcast_history) + await db.commit() + await db.refresh(broadcast_history) + + sent_count = 0 + failed_count = 0 + + for user in users: + try: + await callback.bot.send_message( + chat_id=user.telegram_id, + text=message_text, + parse_mode="HTML" + ) + sent_count += 1 + + if sent_count % 20 == 0: + await asyncio.sleep(1) + + except Exception as e: + failed_count += 1 + logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}") + + broadcast_history.sent_count = sent_count + broadcast_history.failed_count = failed_count + broadcast_history.status = "completed" if failed_count == 0 else "partial" + broadcast_history.completed_at = datetime.utcnow() + await db.commit() + + result_text = f""" +✅ Рассылка завершена! + +📊 Результат: +- Отправлено: {sent_count} +- Не доставлено: {failed_count} +- Всего пользователей: {len(users)} +- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}% + +Администратор: {db_user.full_name} +""" + + await callback.message.edit_text( + result_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")] + ]) + ) + + await state.clear() + logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)}") + + +async def get_target_users_count(db: AsyncSession, target: str) -> int: + users = await get_target_users(db, target) + return len(users) + + +async def get_target_users(db: AsyncSession, target: str) -> list: + if target == "all": + return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + elif target == "active": + users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial] + elif target == "trial": + users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + return [user for user in users if user.subscription and user.subscription.is_trial] + elif target == "no": + users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + return [user for user in users if not user.subscription or not user.subscription.is_active] + elif target == "expiring": + expiring_subs = await get_expiring_subscriptions(db, 3) + return [sub.user for sub in expiring_subs if sub.user] + else: + return [] + + +async def get_custom_users_count(db: AsyncSession, criteria: str) -> int: + users = await get_custom_users(db, criteria) + return len(users) + + +async def get_custom_users(db: AsyncSession, criteria: str) -> list: + """Получение пользователей по настраиваемым критериям""" + now = datetime.utcnow() + today = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + + if criteria == "today": + stmt = select(User).where( + and_(User.status == "active", User.created_at >= today) + ) + elif criteria == "week": + stmt = select(User).where( + and_(User.status == "active", User.created_at >= week_ago) + ) + elif criteria == "month": + stmt = select(User).where( + and_(User.status == "active", User.created_at >= month_ago) + ) + elif criteria == "active_today": + stmt = select(User).where( + and_(User.status == "active", User.last_activity >= today) + ) + elif criteria == "inactive_week": + stmt = select(User).where( + and_(User.status == "active", User.last_activity < week_ago) + ) + elif criteria == "inactive_month": + stmt = select(User).where( + and_(User.status == "active", User.last_activity < month_ago) + ) + elif criteria == "referrals": + stmt = select(User).where( + and_(User.status == "active", User.referred_by_id.isnot(None)) + ) + elif criteria == "direct": + stmt = select(User).where( + and_( + User.status == "active", + User.referred_by_id.is_(None) + ) + ) + else: + return [] + + result = await db.execute(stmt) + return result.scalars().all() + + +async def get_users_statistics(db: AsyncSession) -> dict: + """Получение статистики пользователей для отображения""" + now = datetime.utcnow() + today = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + + stats = {} + + stats['today'] = await db.scalar( + select(func.count(User.id)).where( + and_(User.status == "active", User.created_at >= today) + ) + ) or 0 + + stats['week'] = await db.scalar( + select(func.count(User.id)).where( + and_(User.status == "active", User.created_at >= week_ago) + ) + ) or 0 + + stats['month'] = await db.scalar( + select(func.count(User.id)).where( + and_(User.status == "active", User.created_at >= month_ago) + ) + ) or 0 + + stats['active_today'] = await db.scalar( + select(func.count(User.id)).where( + and_(User.status == "active", User.last_activity >= today) + ) + ) or 0 + + stats['inactive_week'] = await db.scalar( + select(func.count(User.id)).where( + and_(User.status == "active", User.last_activity < week_ago) + ) + ) or 0 + + stats['inactive_month'] = await db.scalar( + select(func.count(User.id)).where( + and_(User.status == "active", User.last_activity < month_ago) + ) + ) or 0 + + stats['referrals'] = await db.scalar( + select(func.count(User.id)).where( + and_(User.status == "active", User.referred_by_id.isnot(None)) + ) + ) or 0 + + stats['direct'] = await db.scalar( + select(func.count(User.id)).where( + and_( + User.status == "active", + User.referred_by_id.is_(None) + ) + ) + ) or 0 + + return stats + + +def get_target_name(target_type: str) -> str: + names = { + "all": "Всем пользователям", + "active": "С активной подпиской", + "trial": "С триальной подпиской", + "no": "Без подписки", + "expiring": "С истекающей подпиской", + "custom_today": "Зарегистрированные сегодня", + "custom_week": "Зарегистрированные за неделю", + "custom_month": "Зарегистрированные за месяц", + "custom_active_today": "Активные сегодня", + "custom_inactive_week": "Неактивные 7+ дней", + "custom_inactive_month": "Неактивные 30+ дней", + "custom_referrals": "Через рефералов", + "custom_direct": "Прямая регистрация" + } + return names.get(target_type, target_type) + + +def get_target_display_name(target: str) -> str: + return get_target_name(target) + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_messages_menu, F.data == "admin_messages") + dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"])) + dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_")) + dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast") + + dp.callback_query.register(show_messages_history, F.data.startswith("admin_msg_history")) + dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom") + dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_")) + + dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message) \ No newline at end of file diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py new file mode 100644 index 00000000..e7120300 --- /dev/null +++ b/app/handlers/admin/monitoring.py @@ -0,0 +1,313 @@ +import asyncio +import logging +from datetime import datetime, timedelta +from aiogram import Router, F +from aiogram.types import Message, CallbackQuery +from aiogram.filters import Command + +from app.config import settings +from app.database.database import get_db +from app.services.monitoring_service import monitoring_service +from app.utils.decorators import admin_required +from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard +from app.localization.texts import get_texts + +logger = logging.getLogger(__name__) +router = Router() + + +@router.callback_query(F.data == "admin_monitoring") +@admin_required +async def admin_monitoring_menu(callback: CallbackQuery): + """Главное меню мониторинга""" + try: + async for db in get_db(): + status = await monitoring_service.get_monitoring_status(db) + + running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен" + last_update = status['last_update'].strftime('%H:%M:%S') if status['last_update'] else "Никогда" + + text = f""" +🔍 Система мониторинга + +📊 Статус: {running_status} +🕐 Последнее обновление: {last_update} +⚙️ Интервал проверки: {settings.MONITORING_INTERVAL} мин + +📈 Статистика за 24 часа: +• Всего событий: {status['stats_24h']['total_events']} +• Успешных: {status['stats_24h']['successful']} +• Ошибок: {status['stats_24h']['failed']} +• Успешность: {status['stats_24h']['success_rate']}% + +🔧 Выберите действие: +""" + + keyboard = get_monitoring_keyboard() + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + break + + except Exception as e: + logger.error(f"Ошибка в админ меню мониторинга: {e}") + await callback.answer("❌ Ошибка получения данных", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_start") +@admin_required +async def start_monitoring_callback(callback: CallbackQuery): + try: + if monitoring_service.is_running: + await callback.answer("ℹ️ Мониторинг уже запущен") + return + + if not monitoring_service.bot: + monitoring_service.bot = callback.bot + + asyncio.create_task(monitoring_service.start_monitoring()) + + await callback.answer("✅ Мониторинг запущен!") + + await admin_monitoring_menu(callback) + + except Exception as e: + logger.error(f"Ошибка запуска мониторинга: {e}") + await callback.answer(f"❌ Ошибка запуска: {str(e)}", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_stop") +@admin_required +async def stop_monitoring_callback(callback: CallbackQuery): + try: + if not monitoring_service.is_running: + await callback.answer("ℹ️ Мониторинг уже остановлен") + return + + monitoring_service.stop_monitoring() + await callback.answer("⏹️ Мониторинг остановлен!") + + await admin_monitoring_menu(callback) + + except Exception as e: + logger.error(f"Ошибка остановки мониторинга: {e}") + await callback.answer(f"❌ Ошибка остановки: {str(e)}", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_force_check") +@admin_required +async def force_check_callback(callback: CallbackQuery): + try: + await callback.answer("⏳ Выполняем проверку подписок...") + + async for db in get_db(): + results = await monitoring_service.force_check_subscriptions(db) + + text = f""" +✅ Принудительная проверка завершена + +📊 Результаты проверки: +• Истекших подписок: {results['expired']} +• Истекающих подписок: {results['expiring']} +• Готовых к автооплате: {results['autopay_ready']} + +🕐 Время проверки: {datetime.now().strftime('%H:%M:%S')} + +Нажмите "Назад" для возврата в меню мониторинга. +""" + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")] + ]) + + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + break + + except Exception as e: + logger.error(f"Ошибка принудительной проверки: {e}") + await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_logs") +@admin_required +async def monitoring_logs_callback(callback: CallbackQuery): + try: + async for db in get_db(): + logs = await monitoring_service.get_monitoring_logs(db, limit=15) + + if not logs: + text = "📝 Логи мониторинга пусты\n\nСистема еще не выполняла проверки." + else: + text = "📝 Последние логи мониторинга:\n\n" + + for log in logs: + icon = "✅" if log['is_success'] else "❌" + time_str = log['created_at'].strftime('%m-%d %H:%M') + event_type = log['event_type'].replace('_', ' ').title() + + text += f"{icon} {time_str} {event_type}\n" + + message = log['message'] + if len(message) > 60: + message = message[:60] + "..." + + text += f" 📄 {message}\n\n" + + if len(text) > 3500: + text += "...\n\nПоказаны последние записи. Для просмотра всех логов используйте файл логов." + break + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"), + InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs") + ], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")] + ]) + + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + break + + except Exception as e: + logger.error(f"Ошибка получения логов: {e}") + await callback.answer(f"❌ Ошибка получения логов: {str(e)}", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_clear_logs") +@admin_required +async def clear_logs_callback(callback: CallbackQuery): + try: + async for db in get_db(): + deleted_count = await monitoring_service.cleanup_old_logs(db, days=7) + + if deleted_count > 0: + await callback.answer(f"🗑️ Удалено {deleted_count} старых записей логов") + else: + await callback.answer("ℹ️ Нет старых логов для удаления") + + await monitoring_logs_callback(callback) + break + + except Exception as e: + logger.error(f"Ошибка очистки логов: {e}") + await callback.answer(f"❌ Ошибка очистки: {str(e)}", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_test_notifications") +@admin_required +async def test_notifications_callback(callback: CallbackQuery): + """Тест системы уведомлений""" + try: + test_message = f""" +🧪 Тестовое уведомление системы мониторинга + +Это тестовое сообщение для проверки работы системы уведомлений. + +📊 Статус системы: +• Мониторинг: {'🟢 Работает' if monitoring_service.is_running else '🔴 Остановлен'} +• Уведомления: {'🟢 Включены' if settings.ENABLE_NOTIFICATIONS else '🔴 Отключены'} +• Время теста: {datetime.now().strftime('%H:%M:%S %d.%m.%Y')} + +✅ Если вы получили это сообщение, система уведомлений работает корректно! +""" + + await callback.bot.send_message( + callback.from_user.id, + test_message, + parse_mode="HTML" + ) + + await callback.answer("✅ Тестовое уведомление отправлено!") + + except Exception as e: + logger.error(f"Ошибка отправки тестового уведомления: {e}") + await callback.answer(f"❌ Ошибка отправки: {str(e)}", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_statistics") +@admin_required +async def monitoring_statistics_callback(callback: CallbackQuery): + try: + async for db in get_db(): + from app.database.crud.subscription import get_subscriptions_statistics + sub_stats = await get_subscriptions_statistics(db) + + mon_status = await monitoring_service.get_monitoring_status(db) + + week_ago = datetime.now() - timedelta(days=7) + week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000) + week_logs = [log for log in week_logs if log['created_at'] >= week_ago] + + week_success = sum(1 for log in week_logs if log['is_success']) + week_errors = len(week_logs) - week_success + + text = f""" +📊 Статистика мониторинга + +📱 Подписки: +• Всего: {sub_stats['total_subscriptions']} +• Активных: {sub_stats['active_subscriptions']} +• Тестовых: {sub_stats['trial_subscriptions']} +• Платных: {sub_stats['paid_subscriptions']} + +📈 За сегодня: +• Успешных операций: {mon_status['stats_24h']['successful']} +• Ошибок: {mon_status['stats_24h']['failed']} +• Успешность: {mon_status['stats_24h']['success_rate']}% + +📊 За неделю: +• Всего событий: {len(week_logs)} +• Успешных: {week_success} +• Ошибок: {week_errors} +• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}% + +🔧 Система: +• Интервал: {settings.MONITORING_INTERVAL} мин +• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'} +• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней +""" + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")] + ]) + + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + break + + except Exception as e: + logger.error(f"Ошибка получения статистики: {e}") + await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True) + + +@router.message(Command("monitoring")) +@admin_required +async def monitoring_command(message: Message): + """Команда /monitoring для быстрого доступа""" + try: + async for db in get_db(): + status = await monitoring_service.get_monitoring_status(db) + + running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен" + + text = f""" +🔍 Быстрый статус мониторинга + +📊 Статус: {running_status} +📈 События за 24ч: {status['stats_24h']['total_events']} +✅ Успешность: {status['stats_24h']['success_rate']}% + +Для подробного управления используйте админ-панель. +""" + + await message.answer(text, parse_mode="HTML") + break + + except Exception as e: + logger.error(f"Ошибка команды /monitoring: {e}") + await message.answer(f"❌ Ошибка: {str(e)}") + + +def register_handlers(dp): + """Регистрация обработчиков мониторинга""" + dp.include_router(router) \ No newline at end of file diff --git a/app/handlers/admin/promocodes.py b/app/handlers/admin/promocodes.py new file mode 100644 index 00000000..1e86b05d --- /dev/null +++ b/app/handlers/admin/promocodes.py @@ -0,0 +1,562 @@ +import logging +from datetime import datetime, timedelta +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.states import AdminStates +from app.database.models import User, PromoCodeType +from app.keyboards.admin import ( + get_admin_promocodes_keyboard, get_promocode_type_keyboard, + get_admin_pagination_keyboard, get_confirmation_keyboard +) +from app.localization.texts import get_texts +from app.database.crud.promocode import ( + get_promocodes_list, get_promocodes_count, create_promocode, + get_promocode_statistics, get_promocode_by_code, update_promocode, + delete_promocode +) +from app.utils.decorators import admin_required, error_handler +from app.utils.formatters import format_datetime + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_promocodes_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + total_codes = await get_promocodes_count(db) + active_codes = await get_promocodes_count(db, is_active=True) + + text = f""" +🎫 Управление промокодами + +📊 Статистика: +- Всего промокодов: {total_codes} +- Активных: {active_codes} +- Неактивных: {total_codes - active_codes} + +Выберите действие: +""" + + await callback.message.edit_text( + text, + reply_markup=get_admin_promocodes_keyboard(db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_promocodes_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + page: int = 1 +): + limit = 10 + offset = (page - 1) * limit + + promocodes = await get_promocodes_list(db, offset=offset, limit=limit) + total_count = await get_promocodes_count(db) + total_pages = (total_count + limit - 1) // limit + + if not promocodes: + await callback.message.edit_text( + "🎫 Промокоды не найдены", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")] + ]) + ) + await callback.answer() + return + + text = f"🎫 Список промокодов (стр. {page}/{total_pages})\n\n" + + for promo in promocodes: + status_emoji = "✅" if promo.is_active else "❌" + type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫") + + text += f"{status_emoji} {type_emoji} {promo.code}\n" + text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n" + + if promo.type == PromoCodeType.BALANCE.value: + text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n" + elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value: + text += f"📅 Дней: {promo.subscription_days}\n" + + if promo.valid_until: + text += f"⏰ До: {format_datetime(promo.valid_until)}\n" + + text += f"🔧 Управление: /promo_{promo.id}\n\n" + + keyboard = [] + + if total_pages > 1: + pagination_row = get_admin_pagination_keyboard( + page, total_pages, "admin_promo_list", "admin_promocodes", db_user.language + ).inline_keyboard[0] + keyboard.append(pagination_row) + + keyboard.extend([ + [types.InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_promocode_management( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + promo_id = int(callback.data.split('_')[-1]) + + promo = await db.get(PromoCode, promo_id) + if not promo: + await callback.answer("❌ Промокод не найден", show_alert=True) + return + + status_emoji = "✅" if promo.is_active else "❌" + type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫") + + text = f""" +🎫 Управление промокодом + +{type_emoji} Код: {promo.code} +{status_emoji} Статус: {'Активен' if promo.is_active else 'Неактивен'} +📊 Использований: {promo.current_uses}/{promo.max_uses} +""" + + if promo.type == PromoCodeType.BALANCE.value: + text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n" + elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value: + text += f"📅 Дней: {promo.subscription_days}\n" + + if promo.valid_until: + text += f"⏰ Действует до: {format_datetime(promo.valid_until)}\n" + + text += f"📅 Создан: {format_datetime(promo.created_at)}\n" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="✏️ Редактировать", + callback_data=f"promo_edit_{promo.id}" + ), + types.InlineKeyboardButton( + text="🔄 Переключить статус", + callback_data=f"promo_toggle_{promo.id}" + ) + ], + [ + types.InlineKeyboardButton( + text="📊 Статистика", + callback_data=f"promo_stats_{promo.id}" + ), + types.InlineKeyboardButton( + text="🗑️ Удалить", + callback_data=f"promo_delete_{promo.id}" + ) + ], + [ + types.InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_promo_list") + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_promocode_status( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + promo_id = int(callback.data.split('_')[-1]) + + promo = await db.get(PromoCode, promo_id) + if not promo: + await callback.answer("❌ Промокод не найден", show_alert=True) + return + + new_status = not promo.is_active + await update_promocode(db, promo, is_active=new_status) + + status_text = "активирован" if new_status else "деактивирован" + await callback.answer(f"✅ Промокод {status_text}", show_alert=True) + + await show_promocode_management(callback, db_user, db) + + +@admin_required +@error_handler +async def confirm_delete_promocode( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + promo_id = int(callback.data.split('_')[-1]) + + promo = await db.get(PromoCode, promo_id) + if not promo: + await callback.answer("❌ Промокод не найден", show_alert=True) + return + + text = f""" +⚠️ Подтверждение удаления + +Вы действительно хотите удалить промокод {promo.code}? + +Внимание: Это действие нельзя отменить! +""" + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="✅ Да, удалить", + callback_data=f"promo_delete_confirm_{promo.id}" + ), + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"promo_manage_{promo.id}" + ) + ] + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) + await callback.answer() + + +@admin_required +@error_handler +async def delete_promocode_confirmed( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + promo_id = int(callback.data.split('_')[-1]) + + promo = await db.get(PromoCode, promo_id) + if not promo: + await callback.answer("❌ Промокод не найден", show_alert=True) + return + + code = promo.code + success = await delete_promocode(db, promo) + + if success: + await callback.answer(f"✅ Промокод {code} удален", show_alert=True) + await show_promocodes_list(callback, db_user, db) + else: + await callback.answer("❌ Ошибка удаления промокода", show_alert=True) + + +@admin_required +@error_handler +async def show_promocode_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + promo_id = int(callback.data.split('_')[-1]) + + promo = await db.get(PromoCode, promo_id) + if not promo: + await callback.answer("❌ Промокод не найден", show_alert=True) + return + + stats = await get_promocode_statistics(db, promo_id) + + text = f""" +📊 Статистика промокода {promo.code} + +📈 Общая статистика: +- Всего использований: {stats['total_uses']} +- Использований сегодня: {stats['today_uses']} +- Осталось использований: {promo.max_uses - promo.current_uses} + +📅 Последние использования: +""" + + for use in stats['recent_uses'][:5]: + use_date = format_datetime(use.used_at) + text += f"- {use_date} (ID: {use.user_id})\n" + + if not stats['recent_uses']: + text += "- Пока не было использований\n" + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data=f"promo_manage_{promo.id}" + ) + ] + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) + await callback.answer() + + +@admin_required +@error_handler +async def start_promocode_creation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + await callback.message.edit_text( + "🎫 Создание промокода\n\n" + "Выберите тип промокода:", + reply_markup=get_promocode_type_keyboard(db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def select_promocode_type( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + promo_type = callback.data.split('_')[-1] + + type_names = { + "balance": "💰 Пополнение баланса", + "days": "📅 Дни подписки", + "trial": "🎁 Тестовая подписка" + } + + await state.update_data(promocode_type=promo_type) + + await callback.message.edit_text( + f"🎫 Создание промокода\n\n" + f"Тип: {type_names.get(promo_type, promo_type)}\n\n" + f"Введите код промокода (только латинские буквы и цифры):", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")] + ]) + ) + + await state.set_state(AdminStates.creating_promocode) + await callback.answer() + + +@admin_required +@error_handler +async def process_promocode_code( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + code = message.text.strip().upper() + + if not code.isalnum() or len(code) < 3 or len(code) > 20: + await message.answer("❌ Код должен содержать только латинские буквы и цифры (3-20 символов)") + return + + from app.database.crud.promocode import get_promocode_by_code + existing = await get_promocode_by_code(db, code) + if existing: + await message.answer("❌ Промокод с таким кодом уже существует") + return + + await state.update_data(promocode_code=code) + + data = await state.get_data() + promo_type = data.get('promocode_type') + + if promo_type == "balance": + await message.answer( + f"💰 Промокод: {code}\n\n" + f"Введите сумму пополнения баланса (в рублях):" + ) + await state.set_state(AdminStates.setting_promocode_value) + elif promo_type == "days": + await message.answer( + f"📅 Промокод: {code}\n\n" + f"Введите количество дней подписки:" + ) + await state.set_state(AdminStates.setting_promocode_value) + elif promo_type == "trial": + await message.answer( + f"🎁 Промокод: {code}\n\n" + f"Введите количество дней тестовой подписки:" + ) + await state.set_state(AdminStates.setting_promocode_value) + + +@admin_required +@error_handler +async def process_promocode_value( + message: types.Message, + db_user: User, + state: FSMContext +): + try: + value = int(message.text.strip()) + + data = await state.get_data() + promo_type = data.get('promocode_type') + + if promo_type == "balance" and (value < 1 or value > 10000): + await message.answer("❌ Сумма должна быть от 1 до 10,000 рублей") + return + elif promo_type in ["days", "trial"] and (value < 1 or value > 3650): + await message.answer("❌ Количество дней должно быть от 1 до 3650") + return + + await state.update_data(promocode_value=value) + + await message.answer( + f"📊 Введите количество использований промокода (или 0 для безлимита):" + ) + await state.set_state(AdminStates.setting_promocode_uses) + + except ValueError: + await message.answer("❌ Введите корректное число") + + +@admin_required +@error_handler +async def process_promocode_uses( + message: types.Message, + db_user: User, + state: FSMContext +): + try: + max_uses = int(message.text.strip()) + + if max_uses < 0 or max_uses > 100000: + await message.answer("❌ Количество использований должно быть от 0 до 100,000") + return + + if max_uses == 0: + max_uses = 999999 + + await state.update_data(promocode_max_uses=max_uses) + + await message.answer( + f"⏰ Введите срок действия промокода в днях (или 0 для бессрочного):" + ) + await state.set_state(AdminStates.setting_promocode_expiry) + + except ValueError: + await message.answer("❌ Введите корректное число") + + +@admin_required +@error_handler +async def process_promocode_expiry( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + try: + expiry_days = int(message.text.strip()) + + if expiry_days < 0 or expiry_days > 3650: + await message.answer("❌ Срок действия должен быть от 0 до 3650 дней") + return + + data = await state.get_data() + code = data.get('promocode_code') + promo_type = data.get('promocode_type') + value = data.get('promocode_value', 0) + max_uses = data.get('promocode_max_uses', 1) + + valid_until = None + if expiry_days > 0: + valid_until = datetime.utcnow() + timedelta(days=expiry_days) + + type_map = { + "balance": PromoCodeType.BALANCE, + "days": PromoCodeType.SUBSCRIPTION_DAYS, + "trial": PromoCodeType.TRIAL_SUBSCRIPTION + } + + promocode = await create_promocode( + db=db, + code=code, + type=type_map[promo_type], + balance_bonus_kopeks=value * 100 if promo_type == "balance" else 0, + subscription_days=value if promo_type in ["days", "trial"] else 0, + max_uses=max_uses, + valid_until=valid_until, + created_by=db_user.id + ) + + type_names = { + "balance": "Пополнение баланса", + "days": "Дни подписки", + "trial": "Тестовая подписка" + } + + summary_text = f""" +✅ Промокод создан! + +🎫 Код: {promocode.code} +📝 Тип: {type_names.get(promo_type)} +""" + + if promo_type == "balance": + summary_text += f"💰 Сумма: {settings.format_price(promocode.balance_bonus_kopeks)}\n" + elif promo_type in ["days", "trial"]: + summary_text += f"📅 Дней: {promocode.subscription_days}\n" + + summary_text += f"📊 Использований: {promocode.max_uses}\n" + + if promocode.valid_until: + summary_text += f"⏰ Действует до: {format_datetime(promocode.valid_until)}\n" + + await message.answer( + summary_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promocodes")] + ]) + ) + + await state.clear() + logger.info(f"Создан промокод {code} администратором {db_user.telegram_id}") + + except ValueError: + await message.answer("❌ Введите корректное число дней") + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes") + dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list") + dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create") + dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_")) + + dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_")) + dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_")) + dp.callback_query.register(confirm_delete_promocode, F.data.startswith("promo_delete_")) + dp.callback_query.register(delete_promocode_confirmed, F.data.startswith("promo_delete_confirm_")) + dp.callback_query.register(show_promocode_stats, F.data.startswith("promo_stats_")) + + dp.message.register(process_promocode_code, AdminStates.creating_promocode) + dp.message.register(process_promocode_value, AdminStates.setting_promocode_value) + dp.message.register(process_promocode_uses, AdminStates.setting_promocode_uses) + dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry) \ No newline at end of file diff --git a/app/handlers/admin/referrals.py b/app/handlers/admin/referrals.py new file mode 100644 index 00000000..acad0aac --- /dev/null +++ b/app/handlers/admin/referrals.py @@ -0,0 +1,72 @@ +import logging +from aiogram import Dispatcher, types, F +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.localization.texts import get_texts +from app.database.crud.referral import get_referral_statistics, get_user_referral_stats +from app.database.crud.user import get_user_by_id +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_referral_statistics( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + stats = await get_referral_statistics(db) + + avg_per_referrer = 0 + if stats['active_referrers'] > 0: + avg_per_referrer = stats['total_paid_kopeks'] / stats['active_referrers'] + + text = f""" +🤝 Реферальная статистика + +Общие показатели: +- Пользователей с рефералами: {stats['users_with_referrals']} +- Активных рефереров: {stats['active_referrers']} +- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])} + +За период: +- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])} +- За неделю: {settings.format_price(stats['week_earnings_kopeks'])} +- За месяц: {settings.format_price(stats['month_earnings_kopeks'])} + +Средние показатели: +- На одного реферера: {settings.format_price(int(avg_per_referrer))} + +Топ-5 рефереров: +""" + + for i, referrer in enumerate(stats['top_referrers'][:5], 1): + text += f"{i}. ID {referrer['user_id']}: {settings.format_price(referrer['total_earned_kopeks'])} ({referrer['referrals_count']} реф.)\n" + + if not stats['top_referrers']: + text += "Нет данных\n" + + text += f""" + +Настройки: +- Бонус за регистрацию: {settings.format_price(settings.REFERRAL_REGISTRATION_REWARD)} +- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)} +- Комиссия: {settings.REFERRAL_COMMISSION_PERCENT}% +""" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")] + ]) + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals") \ No newline at end of file diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py new file mode 100644 index 00000000..58ebe182 --- /dev/null +++ b/app/handlers/admin/remnawave.py @@ -0,0 +1,1477 @@ +import logging +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from app.states import SquadRenameStates, SquadCreateStates +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.admin import ( + get_admin_remnawave_keyboard, get_sync_options_keyboard, + get_node_management_keyboard, get_confirmation_keyboard, + get_squad_management_keyboard, get_squad_edit_keyboard +) +from app.localization.texts import get_texts +from app.services.remnawave_service import RemnaWaveService +from app.utils.decorators import admin_required, error_handler +from app.utils.formatters import format_bytes, format_datetime + +logger = logging.getLogger(__name__) + +squad_inbound_selections = {} +squad_create_data = {} + +@admin_required +@error_handler +async def show_remnawave_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + remnawave_service = RemnaWaveService() + connection_test = await remnawave_service.test_api_connection() + + status_emoji = "✅" if connection_test["status"] == "connected" else "❌" + + text = f""" +🖥️ Управление RemnaWave + +📡 Соединение: {status_emoji} {connection_test["message"]} +🌐 URL: {settings.REMNAWAVE_API_URL} + +Выберите действие: +""" + + await callback.message.edit_text( + text, + reply_markup=get_admin_remnawave_keyboard(db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_system_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + from datetime import datetime, timedelta + + remnawave_service = RemnaWaveService() + stats = await remnawave_service.get_system_statistics() + + if "error" in stats: + await callback.message.edit_text( + f"❌ Ошибка получения статистики: {stats['error']}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + ]) + ) + await callback.answer() + return + + system = stats.get("system", {}) + users_by_status = stats.get("users_by_status", {}) + server_info = stats.get("server_info", {}) + bandwidth = stats.get("bandwidth", {}) + traffic_periods = stats.get("traffic_periods", {}) + nodes_realtime = stats.get("nodes_realtime", []) + nodes_weekly = stats.get("nodes_weekly", []) + + memory_total = server_info.get('memory_total', 1) + memory_used_percent = (server_info.get('memory_used', 0) / memory_total * 100) if memory_total > 0 else 0 + + uptime_seconds = server_info.get('uptime_seconds', 0) + uptime_days = int(uptime_seconds // 86400) + uptime_hours = int((uptime_seconds % 86400) // 3600) + uptime_str = f"{uptime_days}д {uptime_hours}ч" + + users_status_text = "" + for status, count in users_by_status.items(): + status_emoji = { + 'ACTIVE': '✅', + 'DISABLED': '❌', + 'LIMITED': '⚠️', + 'EXPIRED': '⏰' + }.get(status, '❓') + users_status_text += f" {status_emoji} {status}: {count}\n" + + top_nodes_text = "" + for i, node in enumerate(nodes_weekly[:3], 1): + top_nodes_text += f" {i}. {node['name']}: {format_bytes(node['total_bytes'])}\n" + + realtime_nodes_text = "" + for node in nodes_realtime[:3]: + node_total = node.get('downloadBytes', 0) + node.get('uploadBytes', 0) + if node_total > 0: + realtime_nodes_text += f" 📡 {node.get('nodeName', 'Unknown')}: {format_bytes(node_total)}\n" + + def format_traffic_change(difference_str): + if not difference_str or difference_str == '0': + return "" + elif difference_str.startswith('-'): + return f" (🔻 {difference_str[1:]})" + else: + return f" (🔺 {difference_str})" + + text = f""" +📊 Детальная статистика RemnaWave + +🖥️ Сервер: +- CPU: {server_info.get('cpu_cores', 0)} ядер ({server_info.get('cpu_physical_cores', 0)} физ.) +- RAM: {format_bytes(server_info.get('memory_used', 0))} / {format_bytes(memory_total)} ({memory_used_percent:.1f}%) +- Свободно: {format_bytes(server_info.get('memory_available', 0))} +- Uptime: {uptime_str} + +👥 Пользователи ({system.get('total_users', 0)} всего): +- 🟢 Онлайн сейчас: {system.get('users_online', 0)} +- 📅 За сутки: {system.get('users_last_day', 0)} +- 📊 За неделю: {system.get('users_last_week', 0)} +- 💤 Никогда не заходили: {system.get('users_never_online', 0)} + +Статусы пользователей: +{users_status_text} + +🌐 Ноды ({system.get('nodes_online', 0)} онлайн):""" + + if realtime_nodes_text: + text += f""" +Реалтайм активность: +{realtime_nodes_text}""" + + if top_nodes_text: + text += f""" +Топ нод за неделю: +{top_nodes_text}""" + + text += f""" + +📈 Общий трафик пользователей: {format_bytes(system.get('total_user_traffic', 0))} + +📊 Трафик по периодам: +- 2 дня: {format_bytes(traffic_periods.get('last_2_days', {}).get('current', 0))}{format_traffic_change(traffic_periods.get('last_2_days', {}).get('difference', ''))} +- 7 дней: {format_bytes(traffic_periods.get('last_7_days', {}).get('current', 0))}{format_traffic_change(traffic_periods.get('last_7_days', {}).get('difference', ''))} +- 30 дней: {format_bytes(traffic_periods.get('last_30_days', {}).get('current', 0))}{format_traffic_change(traffic_periods.get('last_30_days', {}).get('difference', ''))} +- Месяц: {format_bytes(traffic_periods.get('current_month', {}).get('current', 0))}{format_traffic_change(traffic_periods.get('current_month', {}).get('difference', ''))} +- Год: {format_bytes(traffic_periods.get('current_year', {}).get('current', 0))}{format_traffic_change(traffic_periods.get('current_year', {}).get('difference', ''))} +""" + + if bandwidth.get('realtime_total', 0) > 0: + text += f""" +⚡ Реалтайм трафик: +- Скачивание: {format_bytes(bandwidth.get('realtime_download', 0))} +- Загрузка: {format_bytes(bandwidth.get('realtime_upload', 0))} +- Итого: {format_bytes(bandwidth.get('realtime_total', 0))} +""" + + text += f""" +🕒 Обновлено: {format_datetime(stats.get('last_updated', datetime.now()))} +""" + + keyboard = [ + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_rw_system")], + [types.InlineKeyboardButton(text="📈 Ноды", callback_data="admin_rw_nodes"), + types.InlineKeyboardButton(text="👥 Синхронизация", callback_data="admin_rw_sync")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + +@admin_required +@error_handler +async def show_traffic_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + from datetime import datetime, timedelta + + remnawave_service = RemnaWaveService() + + try: + async with remnawave_service.api as api: + bandwidth_stats = await api.get_bandwidth_stats() + + realtime_usage = await api.get_nodes_realtime_usage() + + nodes_stats = await api.get_nodes_statistics() + + except Exception as e: + await callback.message.edit_text( + f"❌ Ошибка получения статистики трафика: {str(e)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + ]) + ) + await callback.answer() + return + + def parse_bandwidth(bandwidth_str): + return remnawave_service._parse_bandwidth_string(bandwidth_str) + + total_realtime_download = sum(node.get('downloadBytes', 0) for node in realtime_usage) + total_realtime_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage) + total_realtime = total_realtime_download + total_realtime_upload + + total_download_speed = sum(node.get('downloadSpeedBps', 0) for node in realtime_usage) + total_upload_speed = sum(node.get('uploadSpeedBps', 0) for node in realtime_usage) + + periods = { + 'last_2_days': bandwidth_stats.get('bandwidthLastTwoDays', {}), + 'last_7_days': bandwidth_stats.get('bandwidthLastSevenDays', {}), + 'last_30_days': bandwidth_stats.get('bandwidthLast30Days', {}), + 'current_month': bandwidth_stats.get('bandwidthCalendarMonth', {}), + 'current_year': bandwidth_stats.get('bandwidthCurrentYear', {}) + } + + def format_change(diff_str): + if not diff_str or diff_str == '0': + return "" + elif diff_str.startswith('-'): + return f" 🔻 {diff_str[1:]}" + else: + return f" 🔺 {diff_str}" + + text = f""" +📊 Статистика трафика RemnaWave + +⚡ Реалтайм данные: +- Скачивание: {format_bytes(total_realtime_download)} +- Загрузка: {format_bytes(total_realtime_upload)} +- Общий трафик: {format_bytes(total_realtime)} + +🚀 Текущие скорости: +- Скорость скачивания: {format_bytes(total_download_speed)}/с +- Скорость загрузки: {format_bytes(total_upload_speed)}/с +- Общая скорость: {format_bytes(total_download_speed + total_upload_speed)}/с + +📈 Статистика по периодам: + +За 2 дня: +- Текущий: {format_bytes(parse_bandwidth(periods['last_2_days'].get('current', '0')))} +- Предыдущий: {format_bytes(parse_bandwidth(periods['last_2_days'].get('previous', '0')))} +- Изменение:{format_change(periods['last_2_days'].get('difference', ''))} + +За 7 дней: +- Текущий: {format_bytes(parse_bandwidth(periods['last_7_days'].get('current', '0')))} +- Предыдущий: {format_bytes(parse_bandwidth(periods['last_7_days'].get('previous', '0')))} +- Изменение:{format_change(periods['last_7_days'].get('difference', ''))} + +За 30 дней: +- Текущий: {format_bytes(parse_bandwidth(periods['last_30_days'].get('current', '0')))} +- Предыдущий: {format_bytes(parse_bandwidth(periods['last_30_days'].get('previous', '0')))} +- Изменение:{format_change(periods['last_30_days'].get('difference', ''))} + +Текущий месяц: +- Текущий: {format_bytes(parse_bandwidth(periods['current_month'].get('current', '0')))} +- Предыдущий: {format_bytes(parse_bandwidth(periods['current_month'].get('previous', '0')))} +- Изменение:{format_change(periods['current_month'].get('difference', ''))} + +Текущий год: +- Текущий: {format_bytes(parse_bandwidth(periods['current_year'].get('current', '0')))} +- Предыдущий: {format_bytes(parse_bandwidth(periods['current_year'].get('previous', '0')))} +- Изменение:{format_change(periods['current_year'].get('difference', ''))} +""" + + if realtime_usage: + text += "\n🌐 Трафик по нодам (реалтайм):\n" + for node in sorted(realtime_usage, key=lambda x: x.get('totalBytes', 0), reverse=True): + node_total = node.get('totalBytes', 0) + if node_total > 0: + text += f"- {node.get('nodeName', 'Unknown')}: {format_bytes(node_total)}\n" + + if nodes_stats.get('lastSevenDays'): + text += "\n📊 Топ нод за 7 дней:\n" + + nodes_weekly = {} + for day_data in nodes_stats['lastSevenDays']: + node_name = day_data['nodeName'] + if node_name not in nodes_weekly: + nodes_weekly[node_name] = 0 + nodes_weekly[node_name] += int(day_data['totalBytes']) + + sorted_nodes = sorted(nodes_weekly.items(), key=lambda x: x[1], reverse=True) + for i, (node_name, total_bytes) in enumerate(sorted_nodes[:5], 1): + text += f"{i}. {node_name}: {format_bytes(total_bytes)}\n" + + text += f"\n🕒 Обновлено: {format_datetime(datetime.now())}" + + keyboard = [ + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_rw_traffic")], + [types.InlineKeyboardButton(text="📈 Ноды", callback_data="admin_rw_nodes"), + types.InlineKeyboardButton(text="📊 Система", callback_data="admin_rw_system")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_nodes_management( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + remnawave_service = RemnaWaveService() + nodes = await remnawave_service.get_all_nodes() + + if not nodes: + await callback.message.edit_text( + "🖥️ Ноды не найдены или ошибка подключения", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + ]) + ) + await callback.answer() + return + + text = "🖥️ Управление нодами\n\n" + keyboard = [] + + for node in nodes: + status_emoji = "🟢" if node["is_node_online"] else "🔴" + connection_emoji = "📡" if node["is_connected"] else "📵" + + text += f"{status_emoji} {connection_emoji} {node['name']}\n" + text += f"🌍 {node['country_code']} • {node['address']}\n" + text += f"👥 Онлайн: {node['users_online'] or 0}\n\n" + + keyboard.append([ + types.InlineKeyboardButton( + text=f"⚙️ {node['name']}", + callback_data=f"admin_node_manage_{node['uuid']}" + ) + ]) + + keyboard.extend([ + [types.InlineKeyboardButton(text="🔄 Перезагрузить все", callback_data="admin_restart_all_nodes")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_node_details( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + node_uuid = callback.data.split('_')[-1] + + remnawave_service = RemnaWaveService() + node = await remnawave_service.get_node_details(node_uuid) + + if not node: + await callback.answer("❌ Нода не найдена", show_alert=True) + return + + status_emoji = "🟢" if node["is_node_online"] else "🔴" + xray_emoji = "✅" if node["is_xray_running"] else "❌" + + text = f""" +🖥️ Нода: {node['name']} + +Статус: +- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'} +- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'} +- Подключена: {'📡 Да' if node['is_connected'] else '📵 Нет'} +- Отключена: {'❌ Да' if node['is_disabled'] else '✅ Нет'} + +Информация: +- Адрес: {node['address']} +- Страна: {node['country_code']} +- Пользователей онлайн: {node['users_online']} + +Трафик: +- Использовано: {format_bytes(node['traffic_used_bytes'])} +- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'} +""" + + await callback.message.edit_text( + text, + reply_markup=get_node_management_keyboard(node_uuid, db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def manage_node( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1] + + remnawave_service = RemnaWaveService() + success = await remnawave_service.manage_node(node_uuid, action) + + if success: + action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"} + await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}") + else: + await callback.answer("❌ Ошибка выполнения действия", show_alert=True) + + await show_node_details( + types.CallbackQuery( + id=callback.id, + from_user=callback.from_user, + chat_instance=callback.chat_instance, + data=f"admin_node_manage_{node_uuid}", + message=callback.message + ), + db_user, + db + ) + +@admin_required +@error_handler +async def show_node_statistics( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + node_uuid = callback.data.split('_')[-1] + + remnawave_service = RemnaWaveService() + + node = await remnawave_service.get_node_details(node_uuid) + + if not node: + await callback.answer("❌ Нода не найдена", show_alert=True) + return + + try: + from datetime import datetime, timedelta + + end_date = datetime.now() + start_date = end_date - timedelta(days=7) + + node_usage = await remnawave_service.get_node_user_usage_by_range( + node_uuid, start_date, end_date + ) + + realtime_stats = await remnawave_service.get_nodes_realtime_usage() + + node_realtime = None + for stats in realtime_stats: + if stats.get('nodeUuid') == node_uuid: + node_realtime = stats + break + + status_emoji = "🟢" if node["is_node_online"] else "🔴" + xray_emoji = "✅" if node["is_xray_running"] else "❌" + + text = f""" +📊 Статистика ноды: {node['name']} + +Статус: +- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'} +- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'} +- Пользователей онлайн: {node['users_online'] or 0} + +Трафик: +- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)} +- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'} +""" + + if node_realtime: + text += f""" +Реалтайм статистика: +- Скачано: {format_bytes(node_realtime.get('downloadBytes', 0))} +- Загружено: {format_bytes(node_realtime.get('uploadBytes', 0))} +- Общий трафик: {format_bytes(node_realtime.get('totalBytes', 0))} +- Скорость скачивания: {format_bytes(node_realtime.get('downloadSpeedBps', 0))}/с +- Скорость загрузки: {format_bytes(node_realtime.get('uploadSpeedBps', 0))}/с +""" + + if node_usage: + text += f"\nСтатистика за 7 дней:\n" + total_usage = 0 + for usage in node_usage[-5:]: + daily_usage = usage.get('total', 0) + total_usage += daily_usage + text += f"- {usage.get('date', 'N/A')}: {format_bytes(daily_usage)}\n" + + text += f"\nОбщий трафик за 7 дней: {format_bytes(total_usage)}" + else: + text += "\nСтатистика за 7 дней: Данные недоступны" + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data=f"node_stats_{node_uuid}")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_node_manage_{node_uuid}")] + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}") + + text = f""" +📊 Статистика ноды: {node['name']} + +Статус: +- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'} +- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'} +- Пользователей онлайн: {node['users_online'] or 0} + +Трафик: +- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)} +- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'} + +⚠️ Детальная статистика временно недоступна +Возможные причины: +• Проблемы с подключением к API +• Нода недавно добавлена +• Недостаточно данных для отображения + +Обновлено: {format_datetime('now')} +""" + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data=f"node_stats_{node_uuid}")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_node_manage_{node_uuid}")] + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) + await callback.answer() + +@admin_required +@error_handler +async def show_squad_details( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + squad_uuid = callback.data.split('_')[-1] + + remnawave_service = RemnaWaveService() + squad = await remnawave_service.get_squad_details(squad_uuid) + + if not squad: + await callback.answer("❌ Сквад не найден", show_alert=True) + return + + text = f""" +🌐 Сквад: {squad['name']} + +Информация: +- UUID: {squad['uuid']} +- Участников: {squad['members_count']} +- Инбаундов: {squad['inbounds_count']} + +Инбаунды: +""" + + if squad.get('inbounds'): + for inbound in squad['inbounds']: + text += f"- {inbound['tag']} ({inbound['type']})\n" + else: + text += "Нет активных инбаундов" + + await callback.message.edit_text( + text, + reply_markup=get_squad_management_keyboard(squad_uuid, db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def manage_squad_action( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + parts = callback.data.split('_') + action = parts[1] + squad_uuid = parts[-1] + + remnawave_service = RemnaWaveService() + + if action == "add_users": + success = await remnawave_service.add_all_users_to_squad(squad_uuid) + if success: + await callback.answer("✅ Задача добавления пользователей в очередь") + else: + await callback.answer("❌ Ошибка добавления пользователей", show_alert=True) + + elif action == "remove_users": + success = await remnawave_service.remove_all_users_from_squad(squad_uuid) + if success: + await callback.answer("✅ Задача удаления пользователей в очередь") + else: + await callback.answer("❌ Ошибка удаления пользователей", show_alert=True) + + elif action == "delete": + success = await remnawave_service.delete_squad(squad_uuid) + if success: + await callback.message.edit_text( + "✅ Сквад успешно удален", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ К сквадам", callback_data="admin_rw_squads")] + ]) + ) + else: + await callback.answer("❌ Ошибка удаления сквада", show_alert=True) + return + + await show_squad_details( + types.CallbackQuery( + id=callback.id, + from_user=callback.from_user, + chat_instance=callback.chat_instance, + data=f"admin_squad_manage_{squad_uuid}", + message=callback.message + ), + db_user, + db + ) + +@admin_required +@error_handler +async def show_squad_edit_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + squad_uuid = callback.data.split('_')[-1] + + remnawave_service = RemnaWaveService() + squad = await remnawave_service.get_squad_details(squad_uuid) + + if not squad: + await callback.answer("❌ Сквад не найден", show_alert=True) + return + + text = f""" +✏️ Редактирование сквада: {squad['name']} + +Текущие инбаунды: +""" + + if squad.get('inbounds'): + for inbound in squad['inbounds']: + text += f"✅ {inbound['tag']} ({inbound['type']})\n" + else: + text += "Нет активных инбаундов\n" + + text += "\nДоступные действия:" + + await callback.message.edit_text( + text, + reply_markup=get_squad_edit_keyboard(squad_uuid, db_user.language) + ) + await callback.answer() + +@admin_required +@error_handler +async def show_squad_inbounds_selection( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + squad_uuid = callback.data.split('_')[-1] + + remnawave_service = RemnaWaveService() + + squad = await remnawave_service.get_squad_details(squad_uuid) + all_inbounds = await remnawave_service.get_all_inbounds() + + if not squad: + await callback.answer("❌ Сквад не найден", show_alert=True) + return + + if not all_inbounds: + await callback.answer("❌ Нет доступных инбаундов", show_alert=True) + return + + if squad_uuid not in squad_inbound_selections: + squad_inbound_selections[squad_uuid] = set( + inbound['uuid'] for inbound in squad.get('inbounds', []) + ) + + text = f""" +🔧 Изменение инбаундов + +Сквад: {squad['name']} +Текущих инбаундов: {len(squad_inbound_selections[squad_uuid])} + +Доступные инбаунды: +""" + + keyboard = [] + + for i, inbound in enumerate(all_inbounds[:15]): + is_selected = inbound['uuid'] in squad_inbound_selections[squad_uuid] + emoji = "✅" if is_selected else "☐" + + keyboard.append([ + types.InlineKeyboardButton( + text=f"{emoji} {inbound['tag']} ({inbound['type']})", + callback_data=f"sqd_tgl_{i}_{squad_uuid[:8]}" + ) + ]) + + if len(all_inbounds) > 15: + text += f"\n⚠️ Показано первые 15 из {len(all_inbounds)} инбаундов" + + keyboard.extend([ + [types.InlineKeyboardButton(text="💾 Сохранить изменения", callback_data=f"sqd_save_{squad_uuid[:8]}")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"sqd_edit_{squad_uuid[:8]}")] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + +@admin_required +@error_handler +async def show_squad_rename_form( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + squad_uuid = callback.data.split('_')[-1] + + remnawave_service = RemnaWaveService() + squad = await remnawave_service.get_squad_details(squad_uuid) + + if not squad: + await callback.answer("❌ Сквад не найден", show_alert=True) + return + + await state.update_data(squad_uuid=squad_uuid, squad_name=squad['name']) + await state.set_state(SquadRenameStates.waiting_for_new_name) + + text = f""" +✏️ Переименование сквада + +Текущее название: {squad['name']} + +📝 Введите новое название сквада: + +Требования к названию: +• От 2 до 20 символов +• Только буквы, цифры, дефисы и подчеркивания +• Без пробелов и специальных символов + +Отправьте сообщение с новым названием или нажмите "Отмена" для выхода. +""" + + keyboard = [ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"cancel_rename_{squad_uuid}")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + +@admin_required +@error_handler +async def cancel_squad_rename( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + squad_uuid = callback.data.split('_')[-1] + + await state.clear() + + new_callback = types.CallbackQuery( + id=callback.id, + from_user=callback.from_user, + chat_instance=callback.chat_instance, + data=f"squad_edit_{squad_uuid}", + message=callback.message + ) + + await show_squad_edit_menu(new_callback, db_user, db) + +@admin_required +@error_handler +async def process_squad_new_name( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext +): + data = await state.get_data() + squad_uuid = data.get('squad_uuid') + old_name = data.get('squad_name') + + if not squad_uuid: + await message.answer("❌ Ошибка: сквад не найден") + await state.clear() + return + + new_name = message.text.strip() + + if not new_name: + await message.answer("❌ Название не может быть пустым. Попробуйте еще раз:") + return + + if len(new_name) < 2 or len(new_name) > 20: + await message.answer("❌ Название должно быть от 2 до 20 символов. Попробуйте еще раз:") + return + + import re + if not re.match(r'^[A-Za-z0-9_-]+$', new_name): + await message.answer("❌ Название может содержать только буквы, цифры, дефисы и подчеркивания. Попробуйте еще раз:") + return + + if new_name == old_name: + await message.answer("❌ Новое название совпадает с текущим. Введите другое название:") + return + + remnawave_service = RemnaWaveService() + success = await remnawave_service.rename_squad(squad_uuid, new_name) + + if success: + await message.answer( + f"✅ Сквад успешно переименован!\n\n" + f"Старое название: {old_name}\n" + f"Новое название: {new_name}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📋 Детали сквада", callback_data=f"admin_squad_manage_{squad_uuid}")], + [types.InlineKeyboardButton(text="⬅️ К сквадам", callback_data="admin_rw_squads")] + ]) + ) + await state.clear() + else: + await message.answer( + "❌ Ошибка переименования сквада\n\n" + "Возможные причины:\n" + "• Сквад с таким названием уже существует\n" + "• Проблемы с подключением к API\n" + "• Недостаточно прав\n\n" + "Попробуйте другое название:", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"cancel_rename_{squad_uuid}")] + ]) + ) + + +@admin_required +@error_handler +async def toggle_squad_inbound( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + parts = callback.data.split('_') + inbound_index = int(parts[2]) + short_squad_uuid = parts[3] + + remnawave_service = RemnaWaveService() + squads = await remnawave_service.get_all_squads() + + full_squad_uuid = None + for squad in squads: + if squad['uuid'].startswith(short_squad_uuid): + full_squad_uuid = squad['uuid'] + break + + if not full_squad_uuid: + await callback.answer("❌ Сквад не найден", show_alert=True) + return + + all_inbounds = await remnawave_service.get_all_inbounds() + if inbound_index >= len(all_inbounds): + await callback.answer("❌ Инбаунд не найден", show_alert=True) + return + + selected_inbound = all_inbounds[inbound_index] + + if full_squad_uuid not in squad_inbound_selections: + squad_inbound_selections[full_squad_uuid] = set() + + if selected_inbound['uuid'] in squad_inbound_selections[full_squad_uuid]: + squad_inbound_selections[full_squad_uuid].remove(selected_inbound['uuid']) + await callback.answer(f"➖ Убран: {selected_inbound['tag']}") + else: + squad_inbound_selections[full_squad_uuid].add(selected_inbound['uuid']) + await callback.answer(f"➕ Добавлен: {selected_inbound['tag']}") + + text = f""" +🔧 Изменение инбаундов + +Сквад: {squads[0]['name'] if squads else 'Неизвестно'} +Выбрано инбаундов: {len(squad_inbound_selections[full_squad_uuid])} + +Доступные инбаунды: +""" + + keyboard = [] + for i, inbound in enumerate(all_inbounds[:15]): + is_selected = inbound['uuid'] in squad_inbound_selections[full_squad_uuid] + emoji = "✅" if is_selected else "☐" + + keyboard.append([ + types.InlineKeyboardButton( + text=f"{emoji} {inbound['tag']} ({inbound['type']})", + callback_data=f"sqd_tgl_{i}_{short_squad_uuid}" + ) + ]) + + keyboard.extend([ + [types.InlineKeyboardButton(text="💾 Сохранить изменения", callback_data=f"sqd_save_{short_squad_uuid}")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"sqd_edit_{short_squad_uuid}")] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + + +@admin_required +@error_handler +async def save_squad_inbounds( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + short_squad_uuid = callback.data.split('_')[-1] + + remnawave_service = RemnaWaveService() + squads = await remnawave_service.get_all_squads() + + full_squad_uuid = None + squad_name = None + for squad in squads: + if squad['uuid'].startswith(short_squad_uuid): + full_squad_uuid = squad['uuid'] + squad_name = squad['name'] + break + + if not full_squad_uuid: + await callback.answer("❌ Сквад не найден", show_alert=True) + return + + selected_inbounds = squad_inbound_selections.get(full_squad_uuid, set()) + + try: + success = await remnawave_service.update_squad_inbounds(full_squad_uuid, list(selected_inbounds)) + + if success: + if full_squad_uuid in squad_inbound_selections: + del squad_inbound_selections[full_squad_uuid] + + await callback.message.edit_text( + f"✅ Инбаунды сквада обновлены\n\n" + f"Сквад: {squad_name}\n" + f"Количество инбаундов: {len(selected_inbounds)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ К сквадам", callback_data="admin_rw_squads")], + [types.InlineKeyboardButton(text="📋 Детали сквада", callback_data=f"admin_squad_manage_{full_squad_uuid}")] + ]) + ) + await callback.answer("✅ Изменения сохранены!") + else: + await callback.answer("❌ Ошибка сохранения изменений", show_alert=True) + + except Exception as e: + logger.error(f"Error saving squad inbounds: {e}") + await callback.answer("❌ Ошибка при сохранении", show_alert=True) + +@admin_required +@error_handler +async def show_squad_edit_menu_short( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + short_squad_uuid = callback.data.split('_')[-1] + + remnawave_service = RemnaWaveService() + squads = await remnawave_service.get_all_squads() + + full_squad_uuid = None + for squad in squads: + if squad['uuid'].startswith(short_squad_uuid): + full_squad_uuid = squad['uuid'] + break + + if not full_squad_uuid: + await callback.answer("❌ Сквад не найден", show_alert=True) + return + + new_callback = types.CallbackQuery( + id=callback.id, + from_user=callback.from_user, + chat_instance=callback.chat_instance, + data=f"squad_edit_{full_squad_uuid}", + message=callback.message + ) + + await show_squad_edit_menu(new_callback, db_user, db) + +@admin_required +@error_handler +async def start_squad_creation( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + await state.set_state(SquadCreateStates.waiting_for_name) + + text = """ +➕ Создание нового сквада + +Шаг 1 из 2: Название сквада + +📝 Введите название для нового сквада: + +Требования к названию: +• От 2 до 20 символов +• Только буквы, цифры, дефисы и подчеркивания +• Без пробелов и специальных символов + +Отправьте сообщение с названием или нажмите "Отмена" для выхода. +""" + + keyboard = [ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_squad_create")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_squad_name( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext +): + squad_name = message.text.strip() + + if not squad_name: + await message.answer("❌ Название не может быть пустым. Попробуйте еще раз:") + return + + if len(squad_name) < 2 or len(squad_name) > 20: + await message.answer("❌ Название должно быть от 2 до 20 символов. Попробуйте еще раз:") + return + + import re + if not re.match(r'^[A-Za-z0-9_-]+$', squad_name): + await message.answer("❌ Название может содержать только буквы, цифры, дефисы и подчеркивания. Попробуйте еще раз:") + return + + await state.update_data(squad_name=squad_name) + await state.set_state(SquadCreateStates.selecting_inbounds) + + user_id = message.from_user.id + squad_create_data[user_id] = {'name': squad_name, 'selected_inbounds': set()} + + remnawave_service = RemnaWaveService() + all_inbounds = await remnawave_service.get_all_inbounds() + + if not all_inbounds: + await message.answer( + "❌ Нет доступных инбаундов\n\n" + "Для создания сквада необходимо иметь хотя бы один инбаунд.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ К сквадам", callback_data="admin_rw_squads")] + ]) + ) + await state.clear() + return + + text = f""" +➕ Создание сквада: {squad_name} + +Шаг 2 из 2: Выбор инбаундов + +Выбрано инбаундов: 0 + +Доступные инбаунды: +""" + + keyboard = [] + + for i, inbound in enumerate(all_inbounds[:15]): + keyboard.append([ + types.InlineKeyboardButton( + text=f"☐ {inbound['tag']} ({inbound['type']})", + callback_data=f"create_tgl_{i}" + ) + ]) + + if len(all_inbounds) > 15: + text += f"\n⚠️ Показано первые 15 из {len(all_inbounds)} инбаундов" + + keyboard.extend([ + [types.InlineKeyboardButton(text="✅ Создать сквад", callback_data="create_squad_finish")], + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_squad_create")] + ]) + + await message.answer( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + +@admin_required +@error_handler +async def toggle_create_inbound( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + inbound_index = int(callback.data.split('_')[-1]) + user_id = callback.from_user.id + + if user_id not in squad_create_data: + await callback.answer("❌ Ошибка: данные сессии не найдены", show_alert=True) + await state.clear() + return + + remnawave_service = RemnaWaveService() + all_inbounds = await remnawave_service.get_all_inbounds() + + if inbound_index >= len(all_inbounds): + await callback.answer("❌ Инбаунд не найден", show_alert=True) + return + + selected_inbound = all_inbounds[inbound_index] + selected_inbounds = squad_create_data[user_id]['selected_inbounds'] + + if selected_inbound['uuid'] in selected_inbounds: + selected_inbounds.remove(selected_inbound['uuid']) + await callback.answer(f"➖ Убран: {selected_inbound['tag']}") + else: + selected_inbounds.add(selected_inbound['uuid']) + await callback.answer(f"➕ Добавлен: {selected_inbound['tag']}") + + squad_name = squad_create_data[user_id]['name'] + + text = f""" +➕ Создание сквада: {squad_name} + +Шаг 2 из 2: Выбор инбаундов + +Выбрано инбаундов: {len(selected_inbounds)} + +Доступные инбаунды: +""" + + keyboard = [] + + for i, inbound in enumerate(all_inbounds[:15]): + is_selected = inbound['uuid'] in selected_inbounds + emoji = "✅" if is_selected else "☐" + + keyboard.append([ + types.InlineKeyboardButton( + text=f"{emoji} {inbound['tag']} ({inbound['type']})", + callback_data=f"create_tgl_{i}" + ) + ]) + + keyboard.extend([ + [types.InlineKeyboardButton(text="✅ Создать сквад", callback_data="create_squad_finish")], + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_squad_create")] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + +@admin_required +@error_handler +async def finish_squad_creation( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + user_id = callback.from_user.id + + if user_id not in squad_create_data: + await callback.answer("❌ Ошибка: данные сессии не найдены", show_alert=True) + await state.clear() + return + + squad_name = squad_create_data[user_id]['name'] + selected_inbounds = list(squad_create_data[user_id]['selected_inbounds']) + + if not selected_inbounds: + await callback.answer("❌ Необходимо выбрать хотя бы один инбаунд", show_alert=True) + return + + remnawave_service = RemnaWaveService() + success = await remnawave_service.create_squad(squad_name, selected_inbounds) + + if user_id in squad_create_data: + del squad_create_data[user_id] + await state.clear() + + if success: + await callback.message.edit_text( + f"✅ Сквад успешно создан!\n\n" + f"Название: {squad_name}\n" + f"Количество инбаундов: {len(selected_inbounds)}\n\n" + f"Сквад готов к использованию!", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📋 Список сквадов", callback_data="admin_rw_squads")], + [types.InlineKeyboardButton(text="⬅️ К панели RemnaWave", callback_data="admin_remnawave")] + ]) + ) + await callback.answer("✅ Сквад создан!") + else: + await callback.message.edit_text( + f"❌ Ошибка создания сквада\n\n" + f"Название: {squad_name}\n\n" + f"Возможные причины:\n" + f"• Сквад с таким названием уже существует\n" + f"• Проблемы с подключением к API\n" + f"• Недостаточно прав\n" + f"• Некорректные инбаунды", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_squad_create")], + [types.InlineKeyboardButton(text="⬅️ К сквадам", callback_data="admin_rw_squads")] + ]) + ) + await callback.answer("❌ Ошибка создания сквада", show_alert=True) + +@admin_required +@error_handler +async def cancel_squad_creation( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + user_id = callback.from_user.id + + if user_id in squad_create_data: + del squad_create_data[user_id] + await state.clear() + + await show_squads_management(callback, db_user, db) + + +@admin_required +@error_handler +async def restart_all_nodes( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + remnawave_service = RemnaWaveService() + success = await remnawave_service.restart_all_nodes() + + if success: + await callback.message.edit_text( + "✅ Команда перезагрузки всех нод отправлена", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ К нодам", callback_data="admin_rw_nodes")] + ]) + ) + else: + await callback.message.edit_text( + "❌ Ошибка перезагрузки нод", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ К нодам", callback_data="admin_rw_nodes")] + ]) + ) + + await callback.answer() + + +@admin_required +@error_handler +async def show_sync_options( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + text = """ +🔄 Синхронизация с RemnaWave + +Выберите тип синхронизации: + +- Синхронизировать всех - полная синхронизация всех пользователей +- Только новых - создание пользователей из панели, которых нет в боте +- Обновить данные - обновление информации о трафике и подписках + +⚠️ Процесс может занять несколько минут +""" + + await callback.message.edit_text( + text, + reply_markup=get_sync_options_keyboard(db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def sync_users( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + sync_type = callback.data.split('_')[-2] + "_" + callback.data.split('_')[-1] + + await callback.message.edit_text( + "🔄 Выполняется синхронизация...\n\nПожалуйста, подождите.", + reply_markup=None + ) + + remnawave_service = RemnaWaveService() + + if sync_type in ["all_users", "new_users", "update_data"]: + sync_map = { + "all_users": "all", + "new_users": "new_only", + "update_data": "update_only" + } + stats = await remnawave_service.sync_users_from_panel(db, sync_map[sync_type]) + else: + stats = {"created": 0, "updated": 0, "errors": 0} + + text = f""" +✅ Синхронизация завершена + +📊 Результат: +- Создано: {stats['created']} +- Обновлено: {stats['updated']} +- Ошибок: {stats['errors']} +""" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_squads_management( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + remnawave_service = RemnaWaveService() + squads = await remnawave_service.get_all_squads() + + text = "🌍 Управление сквадами\n\n" + keyboard = [] + + if squads: + for squad in squads: + text += f"🔹 {squad['name']}\n" + text += f"👥 Участников: {squad['members_count']}\n" + text += f"📡 Инбаундов: {squad['inbounds_count']}\n\n" + + keyboard.append([ + types.InlineKeyboardButton( + text=f"⚙️ {squad['name']}", + callback_data=f"admin_squad_manage_{squad['uuid']}" + ) + ]) + else: + text += "Сквады не найдены" + + keyboard.extend([ + [types.InlineKeyboardButton(text="➕ Создать сквад", callback_data="admin_squad_create")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_remnawave_menu, F.data == "admin_remnawave") + dp.callback_query.register(show_system_stats, F.data == "admin_rw_system") + dp.callback_query.register(show_traffic_stats, F.data == "admin_rw_traffic") + dp.callback_query.register(show_nodes_management, F.data == "admin_rw_nodes") + dp.callback_query.register(show_node_details, F.data.startswith("admin_node_manage_")) + dp.callback_query.register(show_node_statistics, F.data.startswith("node_stats_")) + dp.callback_query.register(manage_node, F.data.startswith("node_enable_")) + dp.callback_query.register(manage_node, F.data.startswith("node_disable_")) + dp.callback_query.register(manage_node, F.data.startswith("node_restart_")) + dp.callback_query.register(restart_all_nodes, F.data == "admin_restart_all_nodes") + dp.callback_query.register(show_sync_options, F.data == "admin_rw_sync") + dp.callback_query.register(sync_users, F.data.startswith("sync_")) + dp.callback_query.register(show_squads_management, F.data == "admin_rw_squads") + + dp.callback_query.register(show_squad_details, F.data.startswith("admin_squad_manage_")) + + dp.callback_query.register(manage_squad_action, F.data.startswith("squad_add_users_")) + dp.callback_query.register(manage_squad_action, F.data.startswith("squad_remove_users_")) + dp.callback_query.register(manage_squad_action, F.data.startswith("squad_delete_")) + + dp.callback_query.register(show_squad_edit_menu, F.data.startswith("squad_edit_") & ~F.data.startswith("squad_edit_inbounds_")) + + dp.callback_query.register(show_squad_inbounds_selection, F.data.startswith("squad_edit_inbounds_")) + dp.callback_query.register(show_squad_rename_form, F.data.startswith("squad_rename_")) + + dp.callback_query.register(cancel_squad_rename, F.data.startswith("cancel_rename_")) + + dp.callback_query.register(toggle_squad_inbound, F.data.startswith("sqd_tgl_")) + dp.callback_query.register(save_squad_inbounds, F.data.startswith("sqd_save_")) + + dp.callback_query.register(show_squad_edit_menu_short, F.data.startswith("sqd_edit_")) + + + dp.callback_query.register(start_squad_creation, F.data == "admin_squad_create") + + dp.callback_query.register(cancel_squad_creation, F.data == "cancel_squad_create") + + dp.callback_query.register(toggle_create_inbound, F.data.startswith("create_tgl_")) + + dp.callback_query.register(finish_squad_creation, F.data == "create_squad_finish") + + dp.message.register( + process_squad_new_name, + SquadRenameStates.waiting_for_new_name, + F.text + ) + + dp.message.register( + process_squad_name, + SquadCreateStates.waiting_for_name, + F.text + ) \ No newline at end of file diff --git a/app/handlers/admin/rules.py b/app/handlers/admin/rules.py new file mode 100644 index 00000000..3b224338 --- /dev/null +++ b/app/handlers/admin/rules.py @@ -0,0 +1,168 @@ +import logging +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.states import AdminStates +from app.database.models import User +from app.localization.texts import get_texts +from app.utils.decorators import admin_required, error_handler +from app.database.crud.rules import get_current_rules_content, create_or_update_rules + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_rules_management( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + text = """ +📋 Управление правилами сервиса + +Текущие правила показываются пользователям при регистрации и в главном меню. + +Выберите действие: +""" + + keyboard = [ + [types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")], + [types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def view_current_rules( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + current_rules = await get_current_rules_content(db, db_user.language) + + await callback.message.edit_text( + f"📋 Текущие правила сервиса\n\n{current_rules}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_edit_rules( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession +): + current_rules = await get_current_rules_content(db, db_user.language) + + await callback.message.edit_text( + "✏️ Редактирование правил\n\n" + f"Текущие правила:\n{current_rules[:500]}{'...' if len(current_rules) > 500 else ''}\n\n" + "Отправьте новый текст правил сервиса.\n\n" + "Поддерживается HTML разметка", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")] + ]) + ) + + await state.set_state(AdminStates.editing_rules_page) + await callback.answer() + + +@admin_required +@error_handler +async def process_rules_edit( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + new_rules = message.text + + if len(new_rules) > 4000: + await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)") + return + + await message.answer( + f"📋 Предварительный просмотр новых правил:\n\n{new_rules}\n\n" + f"⚠️ Внимание! Новые правила будут показываться всем пользователям.\n\n" + f"Сохранить изменения?", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"), + types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules") + ] + ]) + ) + + await state.update_data(new_rules=new_rules) + + +@admin_required +@error_handler +async def save_rules( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession +): + data = await state.get_data() + new_rules = data.get('new_rules') + + if not new_rules: + await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True) + return + + try: + await create_or_update_rules( + db=db, + content=new_rules, + language=db_user.language + ) + + from app.localization.texts import clear_rules_cache + clear_rules_cache() + + from app.localization.texts import refresh_rules_cache + await refresh_rules_cache(db_user.language) + + await callback.message.edit_text( + "✅ Правила сервиса обновлены!\n\n" + "Новые правила сохранены в базе данных и будут показываться пользователям.\n\n" + "Кеш правил очищен и обновлен.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")] + ]) + ) + + await state.clear() + logger.info(f"Правила сервиса обновлены администратором {db_user.telegram_id}") + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка сохранения правил: {e}") + await callback.answer("❌ Ошибка сохранения правил", show_alert=True) + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_rules_management, F.data == "admin_rules") + dp.callback_query.register(view_current_rules, F.data == "admin_view_rules") + dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules") + dp.callback_query.register(save_rules, F.data == "admin_save_rules") + + dp.message.register(process_rules_edit, AdminStates.editing_rules_page) \ No newline at end of file diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py new file mode 100644 index 00000000..0bbbf515 --- /dev/null +++ b/app/handlers/admin/servers.py @@ -0,0 +1,962 @@ +import logging +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.states import AdminStates +from app.database.models import User +from app.database.crud.server_squad import ( + get_all_server_squads, get_server_squad_by_id, update_server_squad, + delete_server_squad, sync_with_remnawave, get_server_statistics, + create_server_squad, get_available_server_squads +) +from app.services.remnawave_service import RemnaWaveService +from app.utils.decorators import admin_required, error_handler +from app.utils.cache import cache + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_servers_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + stats = await get_server_statistics(db) + + text = f""" +🌐 Управление серверами + +📊 Статистика: +• Всего серверов: {stats['total_servers']} +• Доступные: {stats['available_servers']} +• Недоступные: {stats['unavailable_servers']} +• С подключениями: {stats['servers_with_connections']} + +💰 Выручка от серверов: +• Общая: {stats['total_revenue_rubles']:.2f} ₽ + +Выберите действие: +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"), + types.InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync") + ], + [ + types.InlineKeyboardButton(text="📊 Синхронизировать счетчики", callback_data="admin_servers_sync_counts"), + types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats") + ], + [ + types.InlineKeyboardButton(text="➕ Добавить сервер", callback_data="admin_servers_add") + ], + [ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions") + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_servers_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + page: int = 1 +): + + servers, total_count = await get_all_server_squads(db, page=page, limit=10) + total_pages = (total_count + 9) // 10 + + if not servers: + text = "🌐 Список серверов\n\n❌ Серверы не найдены." + else: + text = f"🌐 Список серверов\n\n" + text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n" + + for i, server in enumerate(servers, 1 + (page - 1) * 10): + status_emoji = "✅" if server.is_available else "❌" + price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно" + + text += f"{i}. {status_emoji} {server.display_name}\n" + text += f" 💰 Цена: {price_text}" + + if server.max_users: + text += f" | 👥 {server.current_users}/{server.max_users}" + + text += f"\n UUID: {server.squad_uuid}\n\n" + + keyboard = [] + + for i, server in enumerate(servers): + row_num = i // 2 + if len(keyboard) <= row_num: + keyboard.append([]) + + status_emoji = "✅" if server.is_available else "❌" + keyboard[row_num].append( + types.InlineKeyboardButton( + text=f"{status_emoji} {server.display_name[:15]}...", + callback_data=f"admin_server_edit_{server.id}" + ) + ) + + if total_pages > 1: + nav_row = [] + if page > 1: + nav_row.append(types.InlineKeyboardButton( + text="⬅️", callback_data=f"admin_servers_list_page_{page-1}" + )) + + nav_row.append(types.InlineKeyboardButton( + text=f"{page}/{total_pages}", callback_data="current_page" + )) + + if page < total_pages: + nav_row.append(types.InlineKeyboardButton( + text="➡️", callback_data=f"admin_servers_list_page_{page+1}" + )) + + keyboard.append(nav_row) + + keyboard.extend([ + [ + types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_servers_list"), + types.InlineKeyboardButton(text="➕ Добавить", callback_data="admin_servers_add") + ], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def sync_servers_with_remnawave( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + await callback.message.edit_text( + "🔄 Синхронизация с RemnaWave...\n\nПодождите, это может занять время.", + reply_markup=None + ) + + try: + remnawave_service = RemnaWaveService() + squads = await remnawave_service.get_all_squads() + + if not squads: + await callback.message.edit_text( + "❌ Не удалось получить данные о сквадах из RemnaWave.\n\nПроверьте настройки API.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] + ]) + ) + return + + created, updated, disabled = await sync_with_remnawave(db, squads) + + await cache.delete("available_countries") + + text = f""" +✅ Синхронизация завершена + +📊 Результаты: +• Создано новых серверов: {created} +• Обновлено существующих: {updated} +• Отключено неактивных: {disabled} +• Всего обработано: {len(squads)} + +ℹ️ Новые серверы созданы как недоступные. +Настройте их в списке серверов. +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"), + types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync") + ], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + + except Exception as e: + logger.error(f"Ошибка синхронизации серверов: {e}") + await callback.message.edit_text( + f"❌ Ошибка синхронизации: {str(e)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] + ]) + ) + + await callback.answer() + + +@admin_required +@error_handler +async def show_server_edit_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" + price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно" + + text = f""" +🌐 Редактирование сервера + +Информация: +• ID: {server.id} +• UUID: {server.squad_uuid} +• Название: {server.display_name} +• Оригинальное: {server.original_name or 'Не указано'} +• Статус: {status_emoji} + +Настройки: +• Цена: {price_text} +• Код страны: {server.country_code or 'Не указан'} +• Лимит пользователей: {server.max_users or 'Без лимита'} +• Текущих пользователей: {server.current_users} + +Описание: +{server.description or 'Не указано'} + +Выберите что изменить: +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), + types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}") + ], + [ + types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"), + types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}") + ], + [ + types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") + ], + [ + types.InlineKeyboardButton( + text="❌ Отключить" if server.is_available else "✅ Включить", + callback_data=f"admin_server_toggle_{server.id}" + ) + ], + [ + types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_server_availability( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + new_status = not server.is_available + await update_server_squad(db, server_id, is_available=new_status) + + await cache.delete("available_countries") + + status_text = "включен" if new_status else "отключен" + await callback.answer(f"✅ Сервер {status_text}!") + + server = await get_server_squad_by_id(db, server_id) + + status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" + price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно" + + text = f""" +🌐 Редактирование сервера + +Информация: +• ID: {server.id} +• UUID: {server.squad_uuid} +• Название: {server.display_name} +• Оригинальное: {server.original_name or 'Не указано'} +• Статус: {status_emoji} + +Настройки: +• Цена: {price_text} +• Код страны: {server.country_code or 'Не указан'} +• Лимит пользователей: {server.max_users or 'Без лимита'} +• Текущих пользователей: {server.current_users} + +Описание: +{server.description or 'Не указано'} + +Выберите что изменить: +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"), + types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}") + ], + [ + types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"), + types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}") + ], + [ + types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}") + ], + [ + types.InlineKeyboardButton( + text="❌ Отключить" if server.is_available else "✅ Включить", + callback_data=f"admin_server_toggle_{server.id}" + ) + ], + [ + types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def start_server_edit_price( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + await state.set_data({'server_id': server_id}) + await state.set_state(AdminStates.editing_server_price) + + current_price = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно" + + await callback.message.edit_text( + f"💰 Редактирование цены\n\n" + f"Текущая цена: {current_price}\n\n" + f"Отправьте новую цену в рублях (например: 15.50) или 0 для бесплатного доступа:", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_server_price_edit( + message: types.Message, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + data = await state.get_data() + server_id = data.get('server_id') + + try: + price_rubles = float(message.text.replace(',', '.')) + + if price_rubles < 0: + await message.answer("❌ Цена не может быть отрицательной") + return + + if price_rubles > 10000: + await message.answer("❌ Слишком высокая цена (максимум 10,000 ₽)") + return + + price_kopeks = int(price_rubles * 100) + + server = await update_server_squad(db, server_id, price_kopeks=price_kopeks) + + if server: + await state.clear() + + await cache.delete("available_countries") + + price_text = f"{price_rubles:.2f} ₽" if price_kopeks > 0 else "Бесплатно" + await message.answer( + f"✅ Цена сервера изменена на: {price_text}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML" + ) + else: + await message.answer("❌ Ошибка при обновлении сервера") + + except ValueError: + await message.answer("❌ Неверный формат цены. Используйте числа (например: 15.50)") + + +@admin_required +@error_handler +async def start_server_edit_name( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + await state.set_data({'server_id': server_id}) + await state.set_state(AdminStates.editing_server_name) + + await callback.message.edit_text( + f"✏️ Редактирование названия\n\n" + f"Текущее название: {server.display_name}\n\n" + f"Отправьте новое название для сервера:", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_server_name_edit( + message: types.Message, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + data = await state.get_data() + server_id = data.get('server_id') + + new_name = message.text.strip() + + if len(new_name) > 255: + await message.answer("❌ Название слишком длинное (максимум 255 символов)") + return + + if len(new_name) < 3: + await message.answer("❌ Название слишком короткое (минимум 3 символа)") + return + + server = await update_server_squad(db, server_id, display_name=new_name) + + if server: + await state.clear() + + await cache.delete("available_countries") + + await message.answer( + f"✅ Название сервера изменено на: {new_name}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML" + ) + else: + await message.answer("❌ Ошибка при обновлении сервера") + + +@admin_required +@error_handler +async def delete_server_confirm( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + text = f""" +🗑️ Удаление сервера + +Вы действительно хотите удалить сервер: +{server.display_name} + +⚠️ Внимание! +Сервер можно удалить только если к нему нет активных подключений. + +Это действие нельзя отменить! +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="🗑️ Да, удалить", callback_data=f"admin_server_delete_confirm_{server_id}"), + types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}") + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_server_execute( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + success = await delete_server_squad(db, server_id) + + if success: + await cache.delete("available_countries") + + await callback.message.edit_text( + f"✅ Сервер {server.display_name} успешно удален!", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📋 К списку серверов", callback_data="admin_servers_list")] + ]), + parse_mode="HTML" + ) + else: + await callback.message.edit_text( + f"❌ Не удалось удалить сервер {server.display_name}\n\n" + f"Возможно, к нему есть активные подключения.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML" + ) + + await callback.answer() + + +@admin_required +@error_handler +async def show_server_detailed_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + stats = await get_server_statistics(db) + available_servers = await get_available_server_squads(db) + + text = f""" +📊 Подробная статистика серверов + +🌐 Общая информация: +• Всего серверов: {stats['total_servers']} +• Доступные: {stats['available_servers']} +• Недоступные: {stats['unavailable_servers']} +• С активными подключениями: {stats['servers_with_connections']} + +💰 Финансовая статистика: +• Общая выручка: {stats['total_revenue_rubles']:.2f} ₽ +• Средняя цена за сервер: {(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1)):.2f} ₽ + +🔥 Топ серверов по цене: +""" + + sorted_servers = sorted(available_servers, key=lambda x: x.price_kopeks, reverse=True) + + for i, server in enumerate(sorted_servers[:5], 1): + price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно" + text += f"{i}. {server.display_name} - {price_text}\n" + + if not sorted_servers: + text += "Нет доступных серверов\n" + + keyboard = [ + [ + types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_servers_stats"), + types.InlineKeyboardButton(text="📋 Список", callback_data="admin_servers_list") + ], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_server_edit_country( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + await state.set_data({'server_id': server_id}) + await state.set_state(AdminStates.editing_server_country) + + current_country = server.country_code or "Не указан" + + await callback.message.edit_text( + f"🌍 Редактирование кода страны\n\n" + f"Текущий код страны: {current_country}\n\n" + f"Отправьте новый код страны (например: RU, US, DE) или '-' для удаления:", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_server_country_edit( + message: types.Message, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + data = await state.get_data() + server_id = data.get('server_id') + + new_country = message.text.strip().upper() + + if new_country == "-": + new_country = None + elif len(new_country) > 5: + await message.answer("❌ Код страны слишком длинный (максимум 5 символов)") + return + + server = await update_server_squad(db, server_id, country_code=new_country) + + if server: + await state.clear() + + await cache.delete("available_countries") + + country_text = new_country or "Удален" + await message.answer( + f"✅ Код страны изменен на: {country_text}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML" + ) + else: + await message.answer("❌ Ошибка при обновлении сервера") + + +@admin_required +@error_handler +async def start_server_edit_limit( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + await state.set_data({'server_id': server_id}) + await state.set_state(AdminStates.editing_server_limit) + + current_limit = server.max_users or "Без лимита" + + await callback.message.edit_text( + f"👥 Редактирование лимита пользователей\n\n" + f"Текущий лимит: {current_limit}\n\n" + f"Отправьте новый лимит пользователей (число) или 0 для безлимитного доступа:", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_server_limit_edit( + message: types.Message, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + data = await state.get_data() + server_id = data.get('server_id') + + try: + limit = int(message.text.strip()) + + if limit < 0: + await message.answer("❌ Лимит не может быть отрицательным") + return + + if limit > 10000: + await message.answer("❌ Слишком большой лимит (максимум 10,000)") + return + + max_users = limit if limit > 0 else None + + server = await update_server_squad(db, server_id, max_users=max_users) + + if server: + await state.clear() + + limit_text = f"{limit} пользователей" if limit > 0 else "Без лимита" + await message.answer( + f"✅ Лимит пользователей изменен на: {limit_text}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML" + ) + else: + await message.answer("❌ Ошибка при обновлении сервера") + + except ValueError: + await message.answer("❌ Неверный формат числа. Введите целое число.") + + +@admin_required +@error_handler +async def start_server_edit_description( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + await state.set_data({'server_id': server_id}) + await state.set_state(AdminStates.editing_server_description) + + current_desc = server.description or "Не указано" + + await callback.message.edit_text( + f"📝 Редактирование описания\n\n" + f"Текущее описание:\n{current_desc}\n\n" + f"Отправьте новое описание сервера или '-' для удаления:", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_server_description_edit( + message: types.Message, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + data = await state.get_data() + server_id = data.get('server_id') + + new_description = message.text.strip() + + if new_description == "-": + new_description = None + elif len(new_description) > 1000: + await message.answer("❌ Описание слишком длинное (максимум 1000 символов)") + return + + server = await update_server_squad(db, server_id, description=new_description) + + if server: + await state.clear() + + desc_text = new_description or "Удалено" + await message.answer( + f"✅ Описание сервера изменено:\n\n{desc_text}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] + ]), + parse_mode="HTML" + ) + else: + await message.answer("❌ Ошибка при обновлении сервера") + +@admin_required +@error_handler +async def sync_server_user_counts_handler( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + await callback.message.edit_text( + "🔄 Синхронизация счетчиков пользователей...", + reply_markup=None + ) + + try: + from app.database.crud.server_squad import sync_server_user_counts + + updated_count = await sync_server_user_counts(db) + + text = f""" +✅ Синхронизация завершена + +📊 Результат: +• Обновлено серверов: {updated_count} + +Счетчики пользователей синхронизированы с реальными данными. +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"), + types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync_counts") + ], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + + except Exception as e: + logger.error(f"Ошибка синхронизации счетчиков: {e}") + await callback.message.edit_text( + f"❌ Ошибка синхронизации: {str(e)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] + ]) + ) + + await callback.answer() + + +@admin_required +@error_handler +async def handle_servers_pagination( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + page = int(callback.data.split('_')[-1]) + await show_servers_list(callback, db_user, db, page) + + +def register_handlers(dp: Dispatcher): + + dp.callback_query.register(show_servers_menu, F.data == "admin_servers") + dp.callback_query.register(show_servers_list, F.data == "admin_servers_list") + dp.callback_query.register(sync_servers_with_remnawave, F.data == "admin_servers_sync") + dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts") + dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats") + + dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc")) + dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_")) + + dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_")) + dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_")) + dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_")) + dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_")) + dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_")) + + dp.message.register(process_server_name_edit, AdminStates.editing_server_name) + dp.message.register(process_server_price_edit, AdminStates.editing_server_price) + dp.message.register(process_server_country_edit, AdminStates.editing_server_country) + dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit) + dp.message.register(process_server_description_edit, AdminStates.editing_server_description) + + dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm")) + dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_")) + + dp.callback_query.register(handle_servers_pagination, F.data.startswith("admin_servers_list_page_")) \ No newline at end of file diff --git a/app/handlers/admin/statistics.py b/app/handlers/admin/statistics.py new file mode 100644 index 00000000..764b3ec8 --- /dev/null +++ b/app/handlers/admin/statistics.py @@ -0,0 +1,350 @@ +import logging +from datetime import datetime, timedelta +from aiogram import Dispatcher, types, F +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.admin import get_admin_statistics_keyboard, get_period_selection_keyboard +from app.localization.texts import get_texts +from app.services.user_service import UserService +from app.database.crud.subscription import get_subscriptions_statistics +from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period +from app.database.crud.referral import get_referral_statistics +from app.utils.decorators import admin_required, error_handler +from app.utils.formatters import format_datetime, format_percentage + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_statistics_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + text = """ +📊 Статистика системы + +Выберите раздел для просмотра статистики: +""" + + await callback.message.edit_text( + text, + reply_markup=get_admin_statistics_keyboard(db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_users_statistics( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + user_service = UserService() + stats = await user_service.get_user_statistics(db) + + total_users = stats['total_users'] + active_rate = format_percentage(stats['active_users'] / total_users * 100 if total_users > 0 else 0) + + text = f""" +👥 Статистика пользователей + +Общие показатели: +- Всего зарегистрировано: {stats['total_users']} +- Активных: {stats['active_users']} ({active_rate}) +- Заблокированных: {stats['blocked_users']} + +Новые регистрации: +- Сегодня: {stats['new_today']} +- За неделю: {stats['new_week']} +- За месяц: {stats['new_month']} + +Активность: +- Коэффициент активности: {active_rate} +- Рост за месяц: +{stats['new_month']} ({format_percentage(stats['new_month'] / total_users * 100 if total_users > 0 else 0)}) +""" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_users")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_subscriptions_statistics( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + stats = await get_subscriptions_statistics(db) + + total_subs = stats['total_subscriptions'] + conversion_rate = format_percentage(stats['paid_subscriptions'] / total_subs * 100 if total_subs > 0 else 0) + + text = f""" +📱 Статистика подписок + +Общие показатели: +- Всего подписок: {stats['total_subscriptions']} +- Активных: {stats['active_subscriptions']} +- Платных: {stats['paid_subscriptions']} +- Триальных: {stats['trial_subscriptions']} + +Конверсия: +- Из триала в платную: {conversion_rate} +- Активных платных: {stats['paid_subscriptions']} + +Продажи: +- Сегодня: {stats['purchased_today']} +- За неделю: {stats['purchased_week']} +- За месяц: {stats['purchased_month']} +""" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_subs")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_revenue_statistics( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + now = datetime.utcnow() + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + month_stats = await get_transactions_statistics(db, month_start, now) + all_time_stats = await get_transactions_statistics(db) + + text = f""" +💰 Статистика доходов + +За текущий месяц: +- Доходы: {settings.format_price(month_stats['totals']['income_kopeks'])} +- Расходы: {settings.format_price(month_stats['totals']['expenses_kopeks'])} +- Прибыль: {settings.format_price(month_stats['totals']['profit_kopeks'])} +- От подписок: {settings.format_price(month_stats['totals']['subscription_income_kopeks'])} + +Сегодня: +- Транзакций: {month_stats['today']['transactions_count']} +- Доходы: {settings.format_price(month_stats['today']['income_kopeks'])} + +За все время: +- Общий доход: {settings.format_price(all_time_stats['totals']['income_kopeks'])} +- Общая прибыль: {settings.format_price(all_time_stats['totals']['profit_kopeks'])} + +Способы оплаты: +""" + + for method, data in month_stats['by_payment_method'].items(): + if method and data['count'] > 0: + text += f"• {method}: {data['count']} ({settings.format_price(data['amount'])})\n" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")], + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_revenue")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_referral_statistics( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + stats = await get_referral_statistics(db) + + avg_per_referrer = 0 + if stats['active_referrers'] > 0: + avg_per_referrer = stats['total_paid_kopeks'] / stats['active_referrers'] + + text = f""" +🤝 Реферальная статистика + +Общие показатели: +- Пользователей с рефералами: {stats['users_with_referrals']} +- Активных рефереров: {stats['active_referrers']} +- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])} + +За период: +- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])} +- За неделю: {settings.format_price(stats['week_earnings_kopeks'])} +- За месяц: {settings.format_price(stats['month_earnings_kopeks'])} + +Средние показатели: +- На одного реферера: {settings.format_price(int(avg_per_referrer))} + +Топ рефереры: +""" + + if stats['top_referrers']: + for i, referrer in enumerate(stats['top_referrers'][:5], 1): + name = referrer['display_name'] + earned = settings.format_price(referrer['total_earned_kopeks']) + count = referrer['referrals_count'] + text += f"{i}. {name}: {earned} ({count} реф.)\n" + else: + text += "Пока нет активных рефереров" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_referrals")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_summary_statistics( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + user_service = UserService() + user_stats = await user_service.get_user_statistics(db) + sub_stats = await get_subscriptions_statistics(db) + + now = datetime.utcnow() + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + revenue_stats = await get_transactions_statistics(db, month_start, now) + + conversion_rate = 0 + if user_stats['total_users'] > 0: + conversion_rate = sub_stats['paid_subscriptions'] / user_stats['total_users'] * 100 + + arpu = 0 + if user_stats['active_users'] > 0: + arpu = revenue_stats['totals']['income_kopeks'] / user_stats['active_users'] + + text = f""" +📊 Общая сводка системы + +Пользователи: +- Всего: {user_stats['total_users']} +- Активных: {user_stats['active_users']} +- Новых за месяц: {user_stats['new_month']} + +Подписки: +- Активных: {sub_stats['active_subscriptions']} +- Платных: {sub_stats['paid_subscriptions']} +- Конверсия: {format_percentage(conversion_rate)} + +Финансы (месяц): +- Доходы: {settings.format_price(revenue_stats['totals']['income_kopeks'])} +- ARPU: {settings.format_price(int(arpu))} +- Транзакций: {sum(data['count'] for data in revenue_stats['by_type'].values())} + +Рост: +- Пользователи: +{user_stats['new_month']} за месяц +- Продажи: +{sub_stats['purchased_month']} за месяц + +Обновлено: {format_datetime(datetime.utcnow())} +""" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_summary")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_revenue_by_period( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + period = callback.data.split('_')[-1] + + period_map = { + "today": 1, + "yesterday": 1, + "week": 7, + "month": 30, + "all": 365 + } + + days = period_map.get(period, 30) + revenue_data = await get_revenue_by_period(db, days) + + if period == "yesterday": + yesterday = datetime.utcnow().date() - timedelta(days=1) + revenue_data = [r for r in revenue_data if r['date'] == yesterday] + elif period == "today": + today = datetime.utcnow().date() + revenue_data = [r for r in revenue_data if r['date'] == today] + + total_revenue = sum(r['amount_kopeks'] for r in revenue_data) + avg_daily = total_revenue / len(revenue_data) if revenue_data else 0 + + text = f""" +📈 Доходы за период: {period} + +Сводка: +- Общий доход: {settings.format_price(total_revenue)} +- Дней с данными: {len(revenue_data)} +- Средний доход в день: {settings.format_price(int(avg_daily))} + +По дням: +""" + + for revenue in revenue_data[-10:]: + text += f"• {revenue['date'].strftime('%d.%m')}: {settings.format_price(revenue['amount_kopeks'])}\n" + + if len(revenue_data) > 10: + text += f"... и еще {len(revenue_data) - 10} дней" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📊 Другой период", callback_data="admin_revenue_period")], + [types.InlineKeyboardButton(text="⬅️ К доходам", callback_data="admin_stats_revenue")] + ]) + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_statistics_menu, F.data == "admin_statistics") + dp.callback_query.register(show_users_statistics, F.data == "admin_stats_users") + dp.callback_query.register(show_subscriptions_statistics, F.data == "admin_stats_subs") + dp.callback_query.register(show_revenue_statistics, F.data == "admin_stats_revenue") + dp.callback_query.register(show_referral_statistics, F.data == "admin_stats_referrals") + dp.callback_query.register(show_summary_statistics, F.data == "admin_stats_summary") + dp.callback_query.register(show_revenue_by_period, F.data.startswith("period_")) + + periods = ["today", "yesterday", "week", "month", "all"] + for period in periods: + dp.callback_query.register( + show_revenue_by_period, + F.data == f"period_{period}" + ) \ No newline at end of file diff --git a/app/handlers/admin/subscriptions.py b/app/handlers/admin/subscriptions.py new file mode 100644 index 00000000..9da23e88 --- /dev/null +++ b/app/handlers/admin/subscriptions.py @@ -0,0 +1,498 @@ +import logging +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.config import settings +from app.states import AdminStates +from app.database.models import User +from app.keyboards.admin import get_admin_subscriptions_keyboard +from app.localization.texts import get_texts +from app.database.crud.subscription import ( + get_expiring_subscriptions, get_subscriptions_statistics, get_expired_subscriptions, + get_all_subscriptions +) +from app.services.subscription_service import SubscriptionService +from app.utils.decorators import admin_required, error_handler +from app.utils.formatters import format_datetime, format_time_ago + + +def get_country_flag(country_name: str) -> str: + flags = { + 'USA': '🇺🇸', 'United States': '🇺🇸', 'US': '🇺🇸', + 'Germany': '🇩🇪', 'DE': '🇩🇪', 'Deutschland': '🇩🇪', + 'Netherlands': '🇳🇱', 'NL': '🇳🇱', 'Holland': '🇳🇱', + 'United Kingdom': '🇬🇧', 'UK': '🇬🇧', 'GB': '🇬🇧', + 'Japan': '🇯🇵', 'JP': '🇯🇵', + 'France': '🇫🇷', 'FR': '🇫🇷', + 'Canada': '🇨🇦', 'CA': '🇨🇦', + 'Russia': '🇷🇺', 'RU': '🇷🇺', + 'Singapore': '🇸🇬', 'SG': '🇸🇬', + } + return flags.get(country_name, '🌍') + + +async def get_users_by_countries(db: AsyncSession) -> dict: + try: + result = await db.execute( + select(User.preferred_location, func.count(User.id)) + .where(User.preferred_location.isnot(None)) + .group_by(User.preferred_location) + ) + + stats = {} + for location, count in result.fetchall(): + if location: + stats[location] = count + + return stats + except Exception as e: + logger.error(f"Ошибка получения статистики по странам: {e}") + return {} + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_subscriptions_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + stats = await get_subscriptions_statistics(db) + + text = f""" +📱 Управление подписками + +📊 Статистика: +- Всего: {stats['total_subscriptions']} +- Активных: {stats['active_subscriptions']} +- Платных: {stats['paid_subscriptions']} +- Триальных: {stats['trial_subscriptions']} + +📈 Продажи: +- Сегодня: {stats['purchased_today']} +- За неделю: {stats['purchased_week']} +- За месяц: {stats['purchased_month']} + +Выберите действие: +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="📋 Список подписок", callback_data="admin_subs_list"), + types.InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring") + ], + [ + types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"), + types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing") + ], + [ + types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"), + types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries") + ], + [ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_subscriptions_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + page: int = 1 +): + + subscriptions, total_count = await get_all_subscriptions(db, page=page, limit=10) + total_pages = (total_count + 9) // 10 + + if not subscriptions: + text = "📱 Список подписок\n\n❌ Подписки не найдены." + else: + text = f"📱 Список подписок\n\n" + text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n" + + for i, sub in enumerate(subscriptions, 1 + (page - 1) * 10): + user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно" + sub_type = "🎁" if sub.is_trial else "💎" + status = "✅ Активна" if sub.is_active else "❌ Неактивна" + + text += f"{i}. {sub_type} {user_info}\n" + text += f" {status} | До: {format_datetime(sub.end_date)}\n" + if sub.device_limit > 0: + text += f" 📱 Устройств: {sub.device_limit}\n" + text += "\n" + + keyboard = [] + + if total_pages > 1: + nav_row = [] + if page > 1: + nav_row.append(types.InlineKeyboardButton( + text="⬅️", callback_data=f"admin_subs_list_page_{page-1}" + )) + + nav_row.append(types.InlineKeyboardButton( + text=f"{page}/{total_pages}", callback_data="current_page" + )) + + if page < total_pages: + nav_row.append(types.InlineKeyboardButton( + text="➡️", callback_data=f"admin_subs_list_page_{page+1}" + )) + + keyboard.append(nav_row) + + keyboard.extend([ + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_list")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_expiring_subscriptions( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + expiring_3d = await get_expiring_subscriptions(db, 3) + expiring_1d = await get_expiring_subscriptions(db, 1) + expired = await get_expired_subscriptions(db) + + text = f""" +⏰ Истекающие подписки + +📊 Статистика: +- Истекают через 3 дня: {len(expiring_3d)} +- Истекают завтра: {len(expiring_1d)} +- Уже истекли: {len(expired)} + +Истекают через 3 дня: +""" + + for sub in expiring_3d[:5]: + user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно" + sub_type = "🎁" if sub.is_trial else "💎" + text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n" + + if len(expiring_3d) > 5: + text += f"... и еще {len(expiring_3d) - 5}\n" + + text += f"\nИстекают завтра:\n" + for sub in expiring_1d[:5]: + user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно" + sub_type = "🎁" if sub.is_trial else "💎" + text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n" + + if len(expiring_1d) > 5: + text += f"... и еще {len(expiring_1d) - 5}\n" + + keyboard = [ + [types.InlineKeyboardButton(text="📨 Отправить напоминания", callback_data="admin_send_expiry_reminders")], + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_expiring")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_subscriptions_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + stats = await get_subscriptions_statistics(db) + + expiring_3d = await get_expiring_subscriptions(db, 3) + expiring_7d = await get_expiring_subscriptions(db, 7) + expired = await get_expired_subscriptions(db) + + text = f""" +📊 Детальная статистика подписок + +📱 Общая информация: +• Всего подписок: {stats['total_subscriptions']} +• Активных: {stats['active_subscriptions']} +• Неактивных: {stats['total_subscriptions'] - stats['active_subscriptions']} + +💎 По типам: +• Платных: {stats['paid_subscriptions']} +• Триальных: {stats['trial_subscriptions']} + +📈 Продажи: +• Сегодня: {stats['purchased_today']} +• За неделю: {stats['purchased_week']} +• За месяц: {stats['purchased_month']} + +⏰ Истечение: +• Истекают через 3 дня: {len(expiring_3d)} +• Истекают через 7 дней: {len(expiring_7d)} +• Уже истекли: {len(expired)} + +💰 Конверсия: +• Из триала в платную: {stats.get('trial_to_paid_conversion', 0)}% +• Продлений: {stats.get('renewals_count', 0)} +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"), + types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts") + ], + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_pricing_settings( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + text = f""" +⚙️ Настройки цен + +Периоды подписки: +- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)} +- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)} +- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)} +- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)} +- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)} +- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)} + +Трафик-пакеты: +- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)} +- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)} +- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)} +- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)} +- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)} +- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)} + +Дополнительно: +- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)} +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"), + types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices") + ], + [ + types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price") + ], + [ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions") + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_countries_management( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + try: + from app.services.remnawave_service import RemnaWaveService + remnawave_service = RemnaWaveService() + + nodes_data = await remnawave_service.get_all_nodes() + squads_data = await remnawave_service.get_all_squads() + + text = "🌍 Управление странами\n\n" + + if nodes_data: + text += "Доступные серверы:\n" + countries = {} + + for node in nodes_data: + country_code = node.get('country_code', 'XX') + country_name = country_code + + if country_name not in countries: + countries[country_name] = [] + countries[country_name].append(node) + + for country, nodes in countries.items(): + active_nodes = len([n for n in nodes if n.get('is_connected') and n.get('is_node_online')]) + total_nodes = len(nodes) + + country_flag = get_country_flag(country) + text += f"{country_flag} {country}: {active_nodes}/{total_nodes} серверов\n" + + total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes) + if total_users_online > 0: + text += f" 👥 Пользователей онлайн: {total_users_online}\n" + else: + text += "❌ Не удалось загрузить данные о серверах\n" + + if squads_data: + text += f"\nВсего сквадов: {len(squads_data)}\n" + + total_members = sum(squad.get('members_count', 0) for squad in squads_data) + text += f"Участников в сквадах: {total_members}\n" + + text += "\nСквады:\n" + for squad in squads_data[:5]: + name = squad.get('name', 'Неизвестно') + members = squad.get('members_count', 0) + inbounds = squad.get('inbounds_count', 0) + text += f"• {name}: {members} участников, {inbounds} inbound(s)\n" + + if len(squads_data) > 5: + text += f"... и еще {len(squads_data) - 5} сквадов\n" + + user_stats = await get_users_by_countries(db) + if user_stats: + text += "\nПользователи по регионам:\n" + for country, count in user_stats.items(): + country_flag = get_country_flag(country) + text += f"{country_flag} {country}: {count} пользователей\n" + + except Exception as e: + logger.error(f"Ошибка получения данных о странах: {e}") + text = f""" +🌍 Управление странами + +❌ Ошибка загрузки данных +Не удалось получить информацию о серверах. + +Проверьте подключение к RemnaWave API. + +Детали ошибки: {str(e)} +""" + + keyboard = [ + [ + types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries"), + types.InlineKeyboardButton(text="⚙️ API настройки", callback_data="admin_rw_api") + ], + [ + types.InlineKeyboardButton(text="📊 Статистика нод", callback_data="admin_rw_nodes"), + types.InlineKeyboardButton(text="🔧 Сквады", callback_data="admin_rw_squads") + ], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def send_expiry_reminders( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + await callback.message.edit_text( + "📨 Отправка напоминаний...\n\nПодождите, это может занять время.", + reply_markup=None + ) + + expiring_subs = await get_expiring_subscriptions(db, 1) + sent_count = 0 + + for subscription in expiring_subs: + if subscription.user: + try: + user = subscription.user + days_left = max(1, subscription.days_left) + + reminder_text = f""" +⚠️ Подписка истекает! + +Ваша подписка истекает через {days_left} день(а). + +Не забудьте продлить подписку, чтобы не потерять доступ к серверам. + +💎 Продлить подписку можно в главном меню. +""" + + await callback.bot.send_message( + chat_id=user.telegram_id, + text=reminder_text + ) + sent_count += 1 + + except Exception as e: + logger.error(f"Ошибка отправки напоминания пользователю {subscription.user_id}: {e}") + + await callback.message.edit_text( + f"✅ Напоминания отправлены: {sent_count} из {len(expiring_subs)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subs_expiring")] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def handle_subscriptions_pagination( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + page = int(callback.data.split('_')[-1]) + await show_subscriptions_list(callback, db_user, db, page) + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_subscriptions_menu, F.data == "admin_subscriptions") + dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list") + dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring") + dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats") + dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing") + dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries") + dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders") + + dp.callback_query.register( + handle_subscriptions_pagination, + F.data.startswith("admin_subs_list_page_") + ) \ No newline at end of file diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py new file mode 100644 index 00000000..308e7509 --- /dev/null +++ b/app/handlers/admin/users.py @@ -0,0 +1,856 @@ +import logging +from datetime import datetime +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.states import AdminStates +from app.database.models import User, UserStatus, Subscription +from app.database.crud.user import get_user_by_id +from app.keyboards.admin import ( + get_admin_users_keyboard, get_user_management_keyboard, + get_admin_pagination_keyboard, get_confirmation_keyboard +) +from app.localization.texts import get_texts +from app.services.user_service import UserService +from app.utils.decorators import admin_required, error_handler +from app.utils.formatters import format_datetime, format_time_ago + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_users_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + user_service = UserService() + stats = await user_service.get_user_statistics(db) + + text = f""" +👥 Управление пользователями + +📊 Статистика: +• Всего: {stats['total_users']} +• Активных: {stats['active_users']} +• Заблокированных: {stats['blocked_users']} + +📈 Новые пользователи: +• Сегодня: {stats['new_today']} +• За неделю: {stats['new_week']} +• За месяц: {stats['new_month']} + +Выберите действие: +""" + + await callback.message.edit_text( + text, + reply_markup=get_admin_users_keyboard(db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_users_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + page: int = 1 +): + + user_service = UserService() + users_data = await user_service.get_users_page(db, page=page, limit=10) + + if not users_data["users"]: + await callback.message.edit_text( + "👥 Пользователи не найдены", + reply_markup=get_admin_users_keyboard(db_user.language) + ) + await callback.answer() + return + + text = f"👥 Список пользователей (стр. {page}/{users_data['total_pages']})\n\n" + + for user in users_data["users"]: + status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "❌" + subscription_info = "" + + if user.subscription: + if user.subscription.is_trial: + subscription_info = "🎁" + elif user.subscription.is_active: + subscription_info = "💎" + else: + subscription_info = "⏰" + + text += f"{status_emoji} {subscription_info} {user.full_name}\n" + text += f"🆔 {user.telegram_id}\n" + text += f"💰 {settings.format_price(user.balance_kopeks)}\n" + text += f"📅 {format_time_ago(user.created_at)}\n\n" + + keyboard = [] + + if users_data["total_pages"] > 1: + pagination_row = get_admin_pagination_keyboard( + users_data["current_page"], + users_data["total_pages"], + "admin_users_list", + "admin_users", + db_user.language + ).inline_keyboard[0] + keyboard.append(pagination_row) + + keyboard.extend([ + [ + types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"), + types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats") + ], + [ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users") + ] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def handle_users_list_pagination_fixed( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + try: + callback_parts = callback.data.split('_') + page = int(callback_parts[-1]) + await show_users_list(callback, db_user, db, page) + except (ValueError, IndexError) as e: + logger.error(f"Ошибка парсинга номера страницы: {e}") + await show_users_list(callback, db_user, db, 1) + + +@admin_required +@error_handler +async def start_user_search( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + + await callback.message.edit_text( + "🔍 Поиск пользователя\n\n" + "Введите для поиска:\n" + "• Telegram ID\n" + "• Username (без @)\n" + "• Имя или фамилию\n\n" + "Или нажмите /cancel для отмены", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_users")] + ]) + ) + + await state.set_state(AdminStates.waiting_for_user_search) + await callback.answer() + +@admin_required +@error_handler +async def show_users_statistics( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + user_service = UserService() + stats = await user_service.get_user_statistics(db) + + from sqlalchemy import select, func, and_ + + with_sub_result = await db.execute( + select(func.count(User.id)) + .join(Subscription) + .where( + and_( + User.status == UserStatus.ACTIVE.value, + Subscription.is_active == True + ) + ) + ) + users_with_subscription = with_sub_result.scalar() or 0 + + trial_result = await db.execute( + select(func.count(User.id)) + .join(Subscription) + .where( + and_( + User.status == UserStatus.ACTIVE.value, + Subscription.is_trial == True, + Subscription.is_active == True + ) + ) + ) + trial_users = trial_result.scalar() or 0 + + avg_balance_result = await db.execute( + select(func.avg(User.balance_kopeks)) + .where(User.status == UserStatus.ACTIVE.value) + ) + avg_balance = avg_balance_result.scalar() or 0 + + text = f""" +📊 Детальная статистика пользователей + +👥 Общие показатели: +• Всего: {stats['total_users']} +• Активных: {stats['active_users']} +• Заблокированных: {stats['blocked_users']} + +📱 Подписки: +• С активной подпиской: {users_with_subscription} +• На триале: {trial_users} +• Без подписки: {stats['active_users'] - users_with_subscription} + +💰 Финансы: +• Средний баланс: {settings.format_price(int(avg_balance))} + +📈 Регистрации: +• Сегодня: {stats['new_today']} +• За неделю: {stats['new_week']} +• За месяц: {stats['new_month']} + +📊 Активность: +• Конверсия в подписку: {(users_with_subscription / max(stats['active_users'], 1) * 100):.1f}% +• Доля триальных: {(trial_users / max(users_with_subscription, 1) * 100):.1f}% +""" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_users_stats")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_user_subscription( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + user_id = int(callback.data.split('_')[-1]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + user = profile["user"] + subscription = profile["subscription"] + + text = f"📱 Подписка пользователя\n\n" + text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" + + if subscription: + status_emoji = "✅" if subscription.is_active else "❌" + type_emoji = "🎁" if subscription.is_trial else "💎" + + text += f"Статус: {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n" + text += f"Тип: {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n" + text += f"Начало: {format_datetime(subscription.start_date)}\n" + text += f"Окончание: {format_datetime(subscription.end_date)}\n" + text += f"Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n" + text += f"Устройства: {subscription.device_limit}\n" + text += f"Подключенных устройств: {len(subscription.connected_devices) if subscription.connected_devices else 0}\n" + + if subscription.is_active: + days_left = (subscription.end_date - datetime.utcnow()).days + text += f"Осталось дней: {days_left}\n" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="⏰ Продлить", + callback_data=f"admin_sub_extend_{user_id}" + ), + types.InlineKeyboardButton( + text="📊 Трафик", + callback_data=f"admin_sub_traffic_{user_id}" + ) + ] + ] + + if subscription.is_active: + keyboard.append([ + types.InlineKeyboardButton( + text="🚫 Деактивировать", + callback_data=f"admin_sub_deactivate_{user_id}" + ) + ]) + else: + keyboard.append([ + types.InlineKeyboardButton( + text="✅ Активировать", + callback_data=f"admin_sub_activate_{user_id}" + ) + ]) + else: + text += "❌ Подписка отсутствует\n\n" + text += "Пользователь еще не активировал подписку." + + keyboard = [ + [ + types.InlineKeyboardButton( + text="🎁 Выдать триал", + callback_data=f"admin_sub_grant_trial_{user_id}" + ), + types.InlineKeyboardButton( + text="💎 Выдать подписку", + callback_data=f"admin_sub_grant_{user_id}" + ) + ] + ] + + keyboard.append([ + types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_user_transactions( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + user_id = int(callback.data.split('_')[-1]) + + from app.database.crud.transaction import get_user_transactions + + user = await get_user_by_id(db, user_id) + if not user: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + transactions = await get_user_transactions(db, user_id, limit=10) + + text = f"💳 Транзакции пользователя\n\n" + text += f"👤 {user.full_name} (ID: {user.telegram_id})\n" + text += f"💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" + + if transactions: + text += "Последние транзакции:\n\n" + + for transaction in transactions: + type_emoji = "📈" if transaction.amount_kopeks > 0 else "📉" + text += f"{type_emoji} {settings.format_price(abs(transaction.amount_kopeks))}\n" + text += f"📋 {transaction.description}\n" + text += f"📅 {format_datetime(transaction.created_at)}\n\n" + else: + text += "📭 Транзакции отсутствуют" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def confirm_user_delete( + callback: types.CallbackQuery, + db_user: User +): + + user_id = int(callback.data.split('_')[-1]) + + await callback.message.edit_text( + "🗑️ Удаление пользователя\n\n" + "⚠️ ВНИМАНИЕ!\n" + "Вы уверены, что хотите удалить этого пользователя?\n\n" + "Это действие:\n" + "• Пометит пользователя как удаленного\n" + "• Деактивирует его подписку\n" + "• Заблокирует доступ к боту\n\n" + "Данное действие необратимо!", + reply_markup=get_confirmation_keyboard( + f"admin_user_delete_confirm_{user_id}", + f"admin_user_manage_{user_id}", + db_user.language + ) + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_user_account( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + user_id = int(callback.data.split('_')[-1]) + + user_service = UserService() + success = await user_service.delete_user_account(db, user_id, db_user.id) + + if success: + await callback.message.edit_text( + "✅ Пользователь успешно удален", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="👥 К списку пользователей", callback_data="admin_users_list")] + ]) + ) + else: + await callback.message.edit_text( + "❌ Ошибка удаления пользователя", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")] + ]) + ) + + await callback.answer() + + +@admin_required +@error_handler +async def process_user_search( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + + query = message.text.strip() + + if not query: + await message.answer("❌ Введите корректный запрос для поиска") + return + + user_service = UserService() + search_results = await user_service.search_users(db, query, page=1, limit=10) + + if not search_results["users"]: + await message.answer( + f"🔍 По запросу '{query}' ничего не найдено", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")] + ]) + ) + await state.clear() + return + + text = f"🔍 Результаты поиска: '{query}'\n\n" + keyboard = [] + + for user in search_results["users"]: + status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "❌" + subscription_info = "" + + if user.subscription: + if user.subscription.is_trial: + subscription_info = "🎁" + elif user.subscription.is_active: + subscription_info = "💎" + else: + subscription_info = "⏰" + + text += f"{status_emoji} {subscription_info} {user.full_name}\n" + text += f"🆔 {user.telegram_id}\n" + text += f"💰 {settings.format_price(user.balance_kopeks)}\n\n" + + keyboard.append([ + types.InlineKeyboardButton( + text=f"👤 {user.full_name}", + callback_data=f"admin_user_manage_{user.id}" + ) + ]) + + keyboard.append([ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users") + ]) + + await message.answer( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await state.clear() + + +@admin_required +@error_handler +async def show_user_management( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + user_id = int(callback.data.split('_')[-1]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + user = profile["user"] + subscription = profile["subscription"] + + status_text = "✅ Активен" if user.status == UserStatus.ACTIVE.value else "❌ Заблокирован" + + text = f""" +👤 Управление пользователем + +Основная информация: +• Имя: {user.full_name} +• ID: {user.telegram_id} +• Username: @{user.username or 'не указан'} +• Статус: {status_text} +• Язык: {user.language} + +Финансы: +• Баланс: {settings.format_price(user.balance_kopeks)} +• Транзакций: {profile['transactions_count']} + +Активность: +• Регистрация: {format_datetime(user.created_at)} +• Последняя активность: {format_time_ago(user.last_activity) if user.last_activity else 'Неизвестно'} +• Дней с регистрации: {profile['registration_days']} +""" + + if subscription: + text += f""" +Подписка: +• Тип: {'🎁 Триал' if subscription.is_trial else '💎 Платная'} +• Статус: {'✅ Активна' if subscription.is_active else '❌ Неактивна'} +• До: {format_datetime(subscription.end_date)} +• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ +• Устройства: {subscription.device_limit} +• Стран: {len(subscription.connected_squads)} +""" + else: + text += "\nПодписка: Отсутствует" + + await callback.message.edit_text( + text, + reply_markup=get_user_management_keyboard(user.id, db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_balance_edit( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + + user_id = int(callback.data.split('_')[-1]) + + await state.update_data(editing_user_id=user_id) + + await callback.message.edit_text( + "💰 Изменение баланса\n\n" + "Введите сумму для изменения баланса:\n" + "• Положительное число для пополнения\n" + "• Отрицательное число для списания\n" + "• Примеры: 100, -50, 25.5\n\n" + "Или нажмите /cancel для отмены", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_manage_{user_id}")] + ]) + ) + + await state.set_state(AdminStates.editing_user_balance) + await callback.answer() + + +@admin_required +@error_handler +async def process_balance_edit( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + + data = await state.get_data() + user_id = data.get("editing_user_id") + + if not user_id: + await message.answer("❌ Ошибка: пользователь не найден") + await state.clear() + return + + try: + amount_rubles = float(message.text.replace(',', '.')) + amount_kopeks = int(amount_rubles * 100) + + if abs(amount_kopeks) > 10000000: + await message.answer("❌ Слишком большая сумма (максимум 100,000 ₽)") + return + + user_service = UserService() + + description = f"Изменение баланса администратором {db_user.full_name}" + if amount_kopeks > 0: + description = f"Пополнение администратором: +{amount_rubles} ₽" + else: + description = f"Списание администратором: {amount_rubles} ₽" + + success = await user_service.update_user_balance( + db, user_id, amount_kopeks, description, db_user.id + ) + + if success: + action = "пополнен" if amount_kopeks > 0 else "списан" + await message.answer( + f"✅ Баланс пользователя {action} на {settings.format_price(abs(amount_kopeks))}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")] + ]) + ) + else: + await message.answer("❌ Ошибка изменения баланса (возможно, недостаточно средств для списания)") + + except ValueError: + await message.answer("❌ Введите корректную сумму (например: 100 или -50)") + return + + await state.clear() + + +@admin_required +@error_handler +async def confirm_user_block( + callback: types.CallbackQuery, + db_user: User +): + + user_id = int(callback.data.split('_')[-1]) + + await callback.message.edit_text( + "🚫 Блокировка пользователя\n\n" + "Вы уверены, что хотите заблокировать этого пользователя?\n" + "Пользователь потеряет доступ к боту.", + reply_markup=get_confirmation_keyboard( + f"admin_user_block_confirm_{user_id}", + f"admin_user_manage_{user_id}", + db_user.language + ) + ) + await callback.answer() + + +@admin_required +@error_handler +async def block_user( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + user_id = int(callback.data.split('_')[-1]) + + user_service = UserService() + success = await user_service.block_user( + db, user_id, db_user.id, "Заблокирован администратором" + ) + + if success: + await callback.message.edit_text( + "✅ Пользователь заблокирован", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")] + ]) + ) + else: + await callback.message.edit_text( + "❌ Ошибка блокировки пользователя", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")] + ]) + ) + + await callback.answer() + + +@admin_required +@error_handler +async def show_inactive_users( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + user_service = UserService() + + from app.database.crud.user import get_inactive_users + inactive_users = await get_inactive_users(db, settings.INACTIVE_USER_DELETE_MONTHS) + + if not inactive_users: + await callback.message.edit_text( + f"✅ Неактивных пользователей (более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев) не найдено", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")] + ]) + ) + await callback.answer() + return + + text = f"🗑️ Неактивные пользователи\n" + text += f"Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n" + + for user in inactive_users[:10]: + text += f"👤 {user.full_name}\n" + text += f"🆔 {user.telegram_id}\n" + text += f"📅 {format_time_ago(user.last_activity) if user.last_activity else 'Никогда'}\n\n" + + if len(inactive_users) > 10: + text += f"... и еще {len(inactive_users) - 10} пользователей" + + keyboard = [ + [types.InlineKeyboardButton(text="🗑️ Очистить всех", callback_data="admin_cleanup_inactive")], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def cleanup_inactive_users( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + user_service = UserService() + deleted_count = await user_service.cleanup_inactive_users(db) + + await callback.message.edit_text( + f"✅ Очистка завершена\n\n" + f"Удалено неактивных пользователей: {deleted_count}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")] + ]) + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + + dp.callback_query.register( + show_users_menu, + F.data == "admin_users" + ) + + dp.callback_query.register( + show_users_list, + F.data == "admin_users_list" + ) + + dp.callback_query.register( + show_users_statistics, + F.data == "admin_users_stats" + ) + + dp.callback_query.register( + show_user_subscription, + F.data.startswith("admin_user_sub_") + ) + + dp.callback_query.register( + show_user_transactions, + F.data.startswith("admin_user_trans_") + ) + + dp.callback_query.register( + confirm_user_delete, + F.data.startswith("admin_user_delete_") + ) + + dp.callback_query.register( + delete_user_account, + F.data.startswith("admin_user_delete_confirm_") + ) + + dp.callback_query.register( + handle_users_list_pagination_fixed, + F.data.startswith("admin_users_list_page_") + ) + + dp.callback_query.register( + start_user_search, + F.data == "admin_users_search" + ) + + dp.message.register( + process_user_search, + AdminStates.waiting_for_user_search + ) + + dp.callback_query.register( + show_user_management, + F.data.startswith("admin_user_manage_") + ) + + dp.callback_query.register( + start_balance_edit, + F.data.startswith("admin_user_balance_") + ) + + dp.message.register( + process_balance_edit, + AdminStates.editing_user_balance + ) + + dp.callback_query.register( + confirm_user_block, + F.data.startswith("admin_user_block_") + ) + + dp.callback_query.register( + block_user, + F.data.startswith("admin_user_block_confirm_") + ) + + dp.callback_query.register( + show_inactive_users, + F.data == "admin_users_inactive" + ) + + dp.callback_query.register( + cleanup_inactive_users, + F.data == "admin_cleanup_inactive" + ) \ No newline at end of file diff --git a/app/handlers/balance.py b/app/handlers/balance.py new file mode 100644 index 00000000..26bf5d38 --- /dev/null +++ b/app/handlers/balance.py @@ -0,0 +1,341 @@ +import logging +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.states import BalanceStates +from app.database.crud.user import add_user_balance +from app.database.crud.transaction import ( + get_user_transactions, get_user_transactions_count, + create_transaction +) +from app.database.models import User, TransactionType, PaymentMethod +from app.keyboards.inline import ( + get_balance_keyboard, get_payment_methods_keyboard, + get_back_keyboard, get_pagination_keyboard +) +from app.localization.texts import get_texts +from app.services.payment_service import PaymentService +from app.utils.pagination import paginate_list +from app.utils.decorators import error_handler + +logger = logging.getLogger(__name__) + +TRANSACTIONS_PER_PAGE = 10 + + +@error_handler +async def show_balance_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + + balance_text = texts.BALANCE_INFO.format( + balance=texts.format_price(db_user.balance_kopeks) + ) + + await callback.message.edit_text( + balance_text, + reply_markup=get_balance_keyboard(db_user.language) + ) + await callback.answer() + + +@error_handler +async def show_balance_history( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + page: int = 1 +): + texts = get_texts(db_user.language) + + offset = (page - 1) * TRANSACTIONS_PER_PAGE + + transactions = await get_user_transactions( + db, db_user.id, + limit=TRANSACTIONS_PER_PAGE, + offset=offset + ) + + total_count = await get_user_transactions_count(db, db_user.id) + + if not transactions: + await callback.message.edit_text( + "📊 История операций пуста", + reply_markup=get_back_keyboard(db_user.language) + ) + await callback.answer() + return + + text = "📊 История операций\n\n" + + for transaction in transactions: + emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸" + amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}" + + text += f"{emoji} {amount_text}\n" + text += f"📝 {transaction.description}\n" + text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + + keyboard = [] + total_pages = (total_count + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE + + if total_pages > 1: + pagination_row = get_pagination_keyboard( + page, total_pages, "balance_history", db_user.language + ) + keyboard.extend(pagination_row) + + keyboard.append([ + types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@error_handler +async def handle_balance_history_pagination( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + page = int(callback.data.split('_')[-1]) + await show_balance_history(callback, db_user, db, page) + + +@error_handler +async def start_balance_topup( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + """Начать пополнение - только для Telegram Stars""" + texts = get_texts(db_user.language) + + if not settings.TELEGRAM_STARS_ENABLED: + await callback.answer("❌ Пополнение временно недоступно", show_alert=True) + return + + await callback.message.edit_text( + texts.TOP_UP_AMOUNT, + reply_markup=get_back_keyboard(db_user.language) + ) + + await state.set_state(BalanceStates.waiting_for_amount) + await callback.answer() + + +@error_handler +async def process_topup_amount( + message: types.Message, + db_user: User, + state: FSMContext +): + """Обработка введенной суммы - только для Telegram Stars""" + texts = get_texts(db_user.language) + + try: + amount_rubles = float(message.text.replace(',', '.')) + + if amount_rubles < 1: + await message.answer("❌ Минимальная сумма пополнения: 1 ₽") + return + + if amount_rubles > 50000: + await message.answer("❌ Максимальная сумма пополнения: 50,000 ₽") + return + + amount_kopeks = int(amount_rubles * 100) + + await state.update_data(amount_kopeks=amount_kopeks) + + payment_text = texts.TOP_UP_METHODS.format( + amount=texts.format_price(amount_kopeks) + ) + + await message.answer( + payment_text, + reply_markup=get_payment_methods_keyboard(amount_kopeks, db_user.language) + ) + + except ValueError: + await message.answer( + texts.INVALID_AMOUNT, + reply_markup=get_back_keyboard(db_user.language) + ) + + +@error_handler +async def process_stars_payment( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + texts = get_texts(db_user.language) + + if not settings.TELEGRAM_STARS_ENABLED: + await callback.answer("❌ Оплата Stars временно недоступна", show_alert=True) + return + + amount_kopeks = int(callback.data.split('_')[-1]) + + try: + payment_service = PaymentService(callback.bot) + invoice_link = await payment_service.create_stars_invoice( + amount_kopeks=amount_kopeks, + description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", + payload=f"balance_{db_user.id}_{amount_kopeks}" + ) + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] + ]) + + await callback.message.edit_text( + f"⭐ Оплата через Telegram Stars\n\n" + f"Сумма: {texts.format_price(amount_kopeks)}\n\n" + f"Нажмите кнопку ниже для оплаты:", + reply_markup=keyboard + ) + + except Exception as e: + logger.error(f"Ошибка создания Stars invoice: {e}") + await callback.answer("❌ Ошибка создания платежа", show_alert=True) + + await callback.answer() + + +@error_handler +async def process_tribute_quick_payment( + callback: types.CallbackQuery, + db_user: User +): + """Быстрое пополнение через Tribute - без выбора суммы""" + texts = get_texts(db_user.language) + + if not settings.TRIBUTE_ENABLED: + await callback.answer("❌ Оплата картой временно недоступна", show_alert=True) + return + + try: + from app.services.tribute_service import TributeService + + tribute_service = TributeService(callback.bot) + payment_url = await tribute_service.create_payment_link( + user_id=db_user.telegram_id, + amount_kopeks=0, + description="Пополнение баланса VPN" + ) + + if not payment_url: + await callback.answer("❌ Ошибка создания платежа", show_alert=True) + return + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")] + ]) + + await callback.message.edit_text( + f"💳 Пополнение банковской картой\n\n" + f"• Введите любую сумму от 50 ₽\n" + f"• Безопасная оплата через Tribute\n" + f"• Мгновенное зачисление на баланс\n" + f"• Принимаем карты Visa, MasterCard, МИР\n\n" + f"Нажмите кнопку для перехода к оплате:", + reply_markup=keyboard + ) + + except Exception as e: + logger.error(f"Ошибка создания Tribute платежа: {e}") + await callback.answer("❌ Ошибка создания платежа", show_alert=True) + + await callback.answer() + + +@error_handler +async def request_support_topup( + callback: types.CallbackQuery, + db_user: User +): + texts = get_texts(db_user.language) + + support_text = f""" +🛠️ Пополнение через поддержку + +Для пополнения баланса обратитесь в техподдержку: +{settings.SUPPORT_USERNAME} + +Укажите: +• ID: {db_user.telegram_id} +• Сумму пополнения +• Способ оплаты + +⏰ Время обработки: 1-24 часа +""" + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text="💬 Написать в поддержку", + url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}" + )], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")] + ]) + + await callback.message.edit_text( + support_text, + reply_markup=keyboard + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + + dp.callback_query.register( + show_balance_menu, + F.data == "menu_balance" + ) + + dp.callback_query.register( + show_balance_history, + F.data == "balance_history" + ) + + dp.callback_query.register( + handle_balance_history_pagination, + F.data.startswith("balance_history_page_") + ) + + dp.callback_query.register( + start_balance_topup, + F.data == "balance_topup" + ) + + dp.callback_query.register( + process_stars_payment, + F.data.startswith("pay_stars_") + ) + + dp.callback_query.register( + process_tribute_quick_payment, + F.data == "tribute_quick_pay" + ) + + dp.callback_query.register( + request_support_topup, + F.data == "balance_support" + ) + + dp.message.register( + process_topup_amount, + BalanceStates.waiting_for_amount + ) \ No newline at end of file diff --git a/app/handlers/common.py b/app/handlers/common.py new file mode 100644 index 00000000..daa32376 --- /dev/null +++ b/app/handlers/common.py @@ -0,0 +1,85 @@ +import logging +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.localization.texts import get_texts +from app.keyboards.inline import get_back_keyboard + +logger = logging.getLogger(__name__) + + +async def handle_unknown_callback( + callback: types.CallbackQuery, + db_user: User +): + + texts = get_texts(db_user.language if db_user else "ru") + + await callback.answer( + "❓ Неизвестная команда. Попробуйте ещё раз.", + show_alert=True + ) + + logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}") + + +async def handle_cancel( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User +): + + texts = get_texts(db_user.language) + + await state.clear() + await callback.message.edit_text( + texts.OPERATION_CANCELLED, + reply_markup=get_back_keyboard(db_user.language) + ) + await callback.answer() + + +async def handle_unknown_message( + message: types.Message, + db_user: User +): + + texts = get_texts(db_user.language if db_user else "ru") + + await message.answer( + "❓ Не понимаю эту команду. Используйте кнопки меню.", + reply_markup=get_back_keyboard(db_user.language if db_user else "ru") + ) + + +async def show_rules( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + + rules_text = texts.RULES_TEXT + + await callback.message.edit_text( + rules_text, + reply_markup=get_back_keyboard(db_user.language) + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + + dp.callback_query.register( + show_rules, + F.data == "menu_rules" + ) + + dp.callback_query.register( + handle_cancel, + F.data.in_(["cancel", "subscription_cancel"]) + ) + \ No newline at end of file diff --git a/app/handlers/menu.py b/app/handlers/menu.py new file mode 100644 index 00000000..9966bea3 --- /dev/null +++ b/app/handlers/menu.py @@ -0,0 +1,132 @@ +import logging +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.user import get_user_by_telegram_id, update_user +from app.keyboards.inline import get_main_menu_keyboard +from app.localization.texts import get_texts +from app.database.models import User +from app.utils.user_utils import mark_user_as_had_paid_subscription + +logger = logging.getLogger(__name__) + + +async def show_main_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + + from datetime import datetime + db_user.last_activity = datetime.utcnow() + await db.commit() + + has_active_subscription = bool(db_user.subscription) + subscription_is_active = False + + if db_user.subscription: + subscription_is_active = db_user.subscription.is_active + + menu_text = texts.MAIN_MENU.format( + user_name=db_user.full_name, + balance=texts.format_price(db_user.balance_kopeks), + subscription_status=_get_subscription_status(db_user, texts) + ) + + await callback.message.edit_text( + menu_text, + reply_markup=get_main_menu_keyboard( + language=db_user.language, + is_admin=settings.is_admin(db_user.telegram_id), + has_had_paid_subscription=db_user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active + ) + ) + await callback.answer() + +async def mark_user_as_had_paid_subscription( + db: AsyncSession, + user: User +) -> None: + if not user.has_had_paid_subscription: + user.has_had_paid_subscription = True + user.updated_at = datetime.utcnow() + await db.commit() + logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку") + + +async def show_service_rules( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + from app.database.crud.rules import get_current_rules_content + + rules_text = await get_current_rules_content(db, db_user.language) + + if not rules_text: + texts = get_texts(db_user.language) + rules_text = texts._get_default_rules(db_user.language) if hasattr(texts, '_get_default_rules') else """ +📋 Правила использования сервиса + +1. Запрещается использование сервиса для незаконной деятельности +2. Запрещается нарушение авторских прав +3. Запрещается спам и рассылка вредоносного ПО +4. Запрещается использование сервиса для DDoS атак +5. Один аккаунт - один пользователь +6. Возврат средств производится только в исключительных случаях +7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил + +Принимая правила, вы соглашаетесь соблюдать их. +""" + + await callback.message.edit_text( + f"📋 Правила сервиса\n\n{rules_text}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")] + ]) + ) + await callback.answer() + + +async def handle_back_to_menu( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + await state.clear() + + await show_main_menu(callback, db_user, db) + + +def _get_subscription_status(user: User, texts) -> str: + if not user.subscription: + return texts.SUBSCRIPTION_NONE + + if user.subscription.is_trial: + return f"{texts.SUBSCRIPTION_TRIAL} (до {user.subscription.end_date.strftime('%d.%m.%Y')})" + elif user.subscription.is_active: + days_left = user.subscription.days_left + return f"{texts.SUBSCRIPTION_ACTIVE} ({days_left} дн.)" + else: + return texts.SUBSCRIPTION_EXPIRED + + +def register_handlers(dp: Dispatcher): + + dp.callback_query.register( + handle_back_to_menu, + F.data == "back_to_menu" + ) + + dp.callback_query.register( + show_service_rules, + F.data == "menu_rules" + ) \ No newline at end of file diff --git a/app/handlers/promocode.py b/app/handlers/promocode.py new file mode 100644 index 00000000..93447780 --- /dev/null +++ b/app/handlers/promocode.py @@ -0,0 +1,89 @@ +import logging +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.states import PromoCodeStates +from app.database.models import User +from app.keyboards.inline import get_back_keyboard +from app.localization.texts import get_texts +from app.services.promocode_service import PromoCodeService +from app.utils.decorators import error_handler + +logger = logging.getLogger(__name__) + + +@error_handler +async def show_promocode_menu( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + texts = get_texts(db_user.language) + + await callback.message.edit_text( + texts.PROMOCODE_ENTER, + reply_markup=get_back_keyboard(db_user.language) + ) + + await state.set_state(PromoCodeStates.waiting_for_code) + await callback.answer() + + +@error_handler +async def process_promocode( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + texts = get_texts(db_user.language) + + code = message.text.strip() + + if not code: + await message.answer( + "❌ Введите корректный промокод", + reply_markup=get_back_keyboard(db_user.language) + ) + return + + promocode_service = PromoCodeService() + result = await promocode_service.activate_promocode(db, db_user.id, code) + + if result["success"]: + await message.answer( + texts.PROMOCODE_SUCCESS.format(description=result["description"]), + reply_markup=get_back_keyboard(db_user.language) + ) + logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}") + else: + error_messages = { + "not_found": texts.PROMOCODE_INVALID, + "expired": texts.PROMOCODE_EXPIRED, + "used": texts.PROMOCODE_USED, + "already_used_by_user": texts.PROMOCODE_USED, + "server_error": texts.ERROR + } + + error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID) + await message.answer( + error_text, + reply_markup=get_back_keyboard(db_user.language) + ) + + await state.clear() + + +def register_handlers(dp: Dispatcher): + + dp.callback_query.register( + show_promocode_menu, + F.data == "menu_promocode" + ) + + dp.message.register( + process_promocode, + PromoCodeStates.waiting_for_code + ) \ No newline at end of file diff --git a/app/handlers/referral.py b/app/handlers/referral.py new file mode 100644 index 00000000..4c340d06 --- /dev/null +++ b/app/handlers/referral.py @@ -0,0 +1,144 @@ +import logging +from aiogram import Dispatcher, types, F +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.referral import get_referral_earnings_sum +from app.database.models import User +from app.keyboards.inline import get_referral_keyboard, get_back_keyboard +from app.localization.texts import get_texts +from app.utils.user_utils import get_user_referral_summary + +logger = logging.getLogger(__name__) + + +async def show_referral_info( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + + summary = await get_user_referral_summary(db, db_user.id) + + bot_username = (await callback.bot.get_me()).username + referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}" + + referral_text = f"👥 Реферальная программа\n\n" + + referral_text += f"📊 Ваша статистика:\n" + referral_text += f"• Приглашено пользователей: {summary['invited_count']}\n" + referral_text += f"• Купили подписку: {summary['paid_referrals_count']}\n" + referral_text += f"• Заработано всего: {texts.format_price(summary['total_earned_kopeks'])}\n" + referral_text += f"• За последний месяц: {texts.format_price(summary['month_earned_kopeks'])}\n\n" + + referral_text += f"🎁 Как работают награды:\n" + referral_text += f"• Новый пользователь получает: {texts.format_price(settings.REFERRED_USER_REWARD)}\n" + referral_text += f"• Вы получаете при первой покупке реферала: {texts.format_price(settings.REFERRAL_REGISTRATION_REWARD)}\n" + referral_text += f"• Комиссия с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%\n\n" + + referral_text += f"🔗 Ваша реферальная ссылка:\n" + referral_text += f"{referral_link}\n\n" + referral_text += f"🆔 Ваш код: {db_user.referral_code}\n\n" + + if summary['recent_earnings']: + referral_text += f"💰 Последние начисления:\n" + for earning in summary['recent_earnings'][:3]: + reason_text = { + "referral_first_purchase": "🎉 Первая покупка", + "referral_commission": "💰 Комиссия", + "referral_registration_pending": "⏳ Ожидание покупки" + }.get(earning['reason'], earning['reason']) + + referral_text += f"• {reason_text}: {texts.format_price(earning['amount_kopeks'])} от {earning['referral_name']}\n" + referral_text += "\n" + + referral_text += "📢 Приглашайте друзей и зарабатывайте!" + + await callback.message.edit_text( + referral_text, + reply_markup=get_referral_keyboard(db_user.language), + parse_mode="HTML" + ) + await callback.answer() + + +async def create_invite_message( + callback: types.CallbackQuery, + db_user: User +): + + texts = get_texts(db_user.language) + + bot_username = (await callback.bot.get_me()).username + referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}" + + invite_text = f"🎉 Присоединяйся к VPN сервису!\n\n" + invite_text += f"💎 При регистрации по моей ссылке ты получишь {texts.format_price(settings.REFERRED_USER_REWARD)} на баланс!\n\n" + invite_text += f"🚀 Быстрое подключение\n" + invite_text += f"🌍 Серверы по всему миру\n" + invite_text += f"🔒 Надежная защита\n\n" + invite_text += f"👇 Переходи по ссылке:\n{referral_link}" + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="📤 Поделиться", + switch_inline_query=invite_text + ) + ], + [ + types.InlineKeyboardButton( + text="📋 Скопировать ссылку", + callback_data="copy_referral_link" + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="menu_referrals" + ) + ] + ]) + + await callback.message.edit_text( + f"📝 Приглашение создано!\n\n" + f"Используйте кнопку ниже для отправки приглашения или скопируйте текст:\n\n" + f"{invite_text}", + reply_markup=keyboard, + parse_mode="HTML" + ) + await callback.answer() + + +async def copy_referral_link( + callback: types.CallbackQuery, + db_user: User +): + + bot_username = (await callback.bot.get_me()).username + referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}" + + await callback.answer( + f"Ссылка скопирована: {referral_link}", + show_alert=True + ) + + +def register_handlers(dp: Dispatcher): + + dp.callback_query.register( + show_referral_info, + F.data == "menu_referrals" + ) + + dp.callback_query.register( + create_invite_message, + F.data == "referral_create_invite" + ) + + dp.callback_query.register( + copy_referral_link, + F.data == "copy_referral_link" + ) \ No newline at end of file diff --git a/app/handlers/start.py b/app/handlers/start.py new file mode 100644 index 00000000..b6973181 --- /dev/null +++ b/app/handlers/start.py @@ -0,0 +1,507 @@ +import logging +from aiogram import Dispatcher, types, F +from aiogram.filters import Command, StateFilter +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.states import RegistrationStates +from app.database.crud.user import ( + get_user_by_telegram_id, create_user, get_user_by_referral_code +) +from app.keyboards.inline import ( + get_rules_keyboard, get_main_menu_keyboard +) +from app.localization.texts import get_texts +from app.services.referral_service import process_referral_registration +from app.utils.user_utils import generate_unique_referral_code + +logger = logging.getLogger(__name__) + + +async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession): + logger.info(f"🚀 START: Обработка /start от {message.from_user.id}") + + referral_code = None + if len(message.text.split()) > 1: + potential_code = message.text.split()[1] + referral_code = potential_code + logger.info(f"🔎 Найден реферальный код: {referral_code}") + + if referral_code: + await state.set_data({'referral_code': referral_code}) + + user = await get_user_by_telegram_id(db, message.from_user.id) + + if user: + logger.info(f"✅ Пользователь найден: {user.telegram_id}") + texts = get_texts(user.language) + + if referral_code and not user.referred_by_id: + await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.") + + has_active_subscription = user.subscription is not None + subscription_is_active = False + + if user.subscription: + subscription_is_active = user.subscription.is_active + + await message.answer( + texts.MAIN_MENU.format( + user_name=user.full_name, + balance=texts.format_price(user.balance_kopeks), + subscription_status=_get_subscription_status(user, texts) + ), + reply_markup=get_main_menu_keyboard( + language=user.language, + is_admin=settings.is_admin(user.telegram_id), + has_had_paid_subscription=user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active + ) + ) + else: + logger.info(f"🆕 Новый пользователь, начинаем регистрацию") + + language = 'ru' + texts = get_texts(language) + + data = await state.get_data() or {} + data['language'] = language + await state.set_data(data) + logger.info(f"💾 Установлен русский язык по умолчанию") + + await message.answer( + texts.RULES_TEXT, + reply_markup=get_rules_keyboard(language) + ) + logger.info(f"📋 Правила отправлены") + + await state.set_state(RegistrationStates.waiting_for_rules_accept) + current_state = await state.get_state() + logger.info(f"📊 Установлено состояние: {current_state}") + + +async def process_rules_accept( + callback: types.CallbackQuery, + state: FSMContext, + db: AsyncSession +): + + logger.info(f"📋 RULES: Начало обработки правил") + logger.info(f"📊 Callback data: {callback.data}") + logger.info(f"👤 User: {callback.from_user.id}") + + current_state = await state.get_state() + logger.info(f"📊 Текущее состояние: {current_state}") + + try: + await callback.answer() + + data = await state.get_data() + language = data.get('language', 'ru') + texts = get_texts(language) + + if callback.data == 'rules_accept': + logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}") + + try: + await callback.message.delete() + logger.info(f"🗑️ Сообщение с правилами удалено") + except Exception as e: + logger.warning(f"⚠️ Не удалось удалить сообщение с правилами: {e}") + try: + await callback.message.edit_text( + "✅ Правила приняты! Завершаем регистрацию...", + reply_markup=None + ) + except: + pass + + if data.get('referral_code'): + logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}") + + referrer = await get_user_by_referral_code(db, data['referral_code']) + if referrer: + data['referrer_id'] = referrer.id + await state.set_data(data) + logger.info(f"✅ Референс найден: {referrer.id}") + + await complete_registration_from_callback(callback, state, db) + else: + try: + await callback.message.answer( + "У вас есть реферальный код? Введите его или нажмите 'Пропустить'", + reply_markup=get_referral_code_keyboard(language) + ) + await state.set_state(RegistrationStates.waiting_for_referral_code) + logger.info(f"🔍 Ожидание ввода реферального кода") + except Exception as e: + logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}") + await complete_registration_from_callback(callback, state, db) + + else: + logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}") + + try: + rules_required_text = getattr(texts, 'RULES_REQUIRED', + "Для использования бота необходимо принять правила сервиса.") + await callback.message.edit_text( + rules_required_text, + reply_markup=get_rules_keyboard(language) + ) + except Exception as e: + logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}") + await callback.message.edit_text( + "Для использования бота необходимо принять правила сервиса.", + reply_markup=get_rules_keyboard(language) + ) + + logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}") + + except Exception as e: + logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True) + await callback.answer("❌ Произошла ошибка. Попробуйте еще раз.", show_alert=True) + + try: + data = await state.get_data() + language = data.get('language', 'ru') + await callback.message.answer( + "Произошла ошибка. Попробуйте принять правила еще раз:", + reply_markup=get_rules_keyboard(language) + ) + await state.set_state(RegistrationStates.waiting_for_rules_accept) + except: + pass + + +async def process_referral_code_input( + message: types.Message, + state: FSMContext, + db: AsyncSession +): + + logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}") + + data = await state.get_data() + language = data.get('language', 'ru') + texts = get_texts(language) + + referral_code = message.text.strip() + + referrer = await get_user_by_referral_code(db, referral_code) + if referrer: + data['referrer_id'] = referrer.id + await state.set_data(data) + await message.answer("✅ Реферальный код применен!") + logger.info(f"✅ Реферальный код применен") + else: + await message.answer("❌ Неверный реферальный код") + logger.info(f"❌ Неверный реферальный код") + return + + await complete_registration(message, state, db) + + +async def process_referral_code_skip( + callback: types.CallbackQuery, + state: FSMContext, + db: AsyncSession +): + + logger.info(f"⭐️ SKIP: Пропуск реферального кода от пользователя {callback.from_user.id}") + await callback.answer() + + try: + await callback.message.delete() + logger.info(f"🗑️ Сообщение с вопросом о реферальном коде удалено") + except Exception as e: + logger.warning(f"⚠️ Не удалось удалить сообщение с вопросом о реферальном коде: {e}") + try: + await callback.message.edit_text( + "✅ Завершаем регистрацию...", + reply_markup=None + ) + except: + pass + + await complete_registration_from_callback(callback, state, db) + + +async def complete_registration_from_callback( + callback: types.CallbackQuery, + state: FSMContext, + db: AsyncSession +): + + logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}") + + existing_user = await get_user_by_telegram_id(db, callback.from_user.id) + if existing_user: + logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже существует! Показываем главное меню.") + texts = get_texts(existing_user.language) + + has_active_subscription = existing_user.subscription is not None + subscription_is_active = False + + if existing_user.subscription: + subscription_is_active = existing_user.subscription.is_active + + user_name = existing_user.full_name + balance_kopeks = existing_user.balance_kopeks + + try: + await callback.message.answer( + texts.MAIN_MENU.format( + user_name=user_name, + balance=texts.format_price(balance_kopeks), + subscription_status=_get_subscription_status(existing_user, texts) + ), + reply_markup=get_main_menu_keyboard( + language=existing_user.language, + is_admin=settings.is_admin(existing_user.telegram_id), + has_had_paid_subscription=existing_user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active + ) + ) + except Exception as e: + logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}") + await callback.message.answer(f"Добро пожаловать, {user_name}!") + + await state.clear() + return + + data = await state.get_data() + language = data.get('language', 'ru') + texts = get_texts(language) + + referrer_id = data.get('referrer_id') + if not referrer_id and data.get('referral_code'): + referrer = await get_user_by_referral_code(db, data['referral_code']) + if referrer: + referrer_id = referrer.id + + referral_code = await generate_unique_referral_code(db, callback.from_user.id) + + user = await create_user( + db=db, + telegram_id=callback.from_user.id, + username=callback.from_user.username, + first_name=callback.from_user.first_name, + last_name=callback.from_user.last_name, + language=language, + referred_by_id=referrer_id, + referral_code=referral_code + ) + + if referrer_id: + try: + await process_referral_registration(db, user.id, referrer_id) + bonus_message = f"🎉 Вы получили {settings.REFERRED_USER_REWARD/100}₽ за регистрацию по реферальной ссылке!" + await callback.message.answer(bonus_message) + except Exception as e: + logger.error(f"Ошибка при обработке реферальной регистрации: {e}") + + await state.clear() + + has_active_subscription = False + subscription_is_active = False + + user_name = user.full_name + balance_kopeks = user.balance_kopeks + user_telegram_id = user.telegram_id + user_language = user.language + has_had_paid_subscription = user.has_had_paid_subscription + + try: + await callback.message.answer( + texts.MAIN_MENU.format( + user_name=user_name, + balance=texts.format_price(balance_kopeks), + subscription_status=_get_subscription_status_simple(texts) + ), + reply_markup=get_main_menu_keyboard( + language=user_language, + is_admin=settings.is_admin(user_telegram_id), + has_had_paid_subscription=has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active + ) + ) + logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}") + except Exception as e: + logger.error(f"Ошибка при отправке главного меню: {e}") + try: + balance_rubles = balance_kopeks / 100 + await callback.message.answer( + f"Добро пожаловать, {user_name}!\n" + f"Баланс: {balance_rubles} ₽\n" + f"Подписка: Нет активной подписки", + reply_markup=get_main_menu_keyboard( + language=user_language, + is_admin=settings.is_admin(user_telegram_id), + has_had_paid_subscription=has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active + ) + ) + logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}") + except Exception as fallback_error: + logger.error(f"❌ Критическая ошибка при отправке fallback меню: {fallback_error}") + try: + await callback.message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.") + logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}") + except Exception as final_error: + logger.error(f"❌ Критическая ошибка при отправке простого сообщения: {final_error}") + + logger.info(f"✅ Зарегистрирован новый пользователь: {user_telegram_id}") + + +async def complete_registration( + message: types.Message, + state: FSMContext, + db: AsyncSession +): + + logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}") + + data = await state.get_data() + language = data.get('language', 'ru') + texts = get_texts(language) + + referrer_id = data.get('referrer_id') + if not referrer_id and data.get('referral_code'): + referrer = await get_user_by_referral_code(db, data['referral_code']) + if referrer: + referrer_id = referrer.id + + referral_code = await generate_unique_referral_code(db, message.from_user.id) + + user = await create_user( + db=db, + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + language=language, + referred_by_id=referrer_id, + referral_code=referral_code + ) + + if referrer_id: + try: + await process_referral_registration(db, user.id, referrer_id) + bonus_message = f"🎉 Вы получили {settings.REFERRED_USER_REWARD/100}₽ за регистрацию по реферальной ссылке!" + await message.answer(bonus_message) + except Exception as e: + logger.error(f"Ошибка при обработке реферальной регистрации: {e}") + + await state.clear() + + has_active_subscription = False + subscription_is_active = False + + user_name = user.full_name + balance_kopeks = user.balance_kopeks + user_telegram_id = user.telegram_id + user_language = user.language + has_had_paid_subscription = user.has_had_paid_subscription + + try: + await message.answer( + texts.MAIN_MENU.format( + user_name=user_name, + balance=texts.format_price(balance_kopeks), + subscription_status=_get_subscription_status_simple(texts) + ), + reply_markup=get_main_menu_keyboard( + language=user_language, + is_admin=settings.is_admin(user_telegram_id), + has_had_paid_subscription=has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active + ) + ) + logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}") + except Exception as e: + logger.error(f"Ошибка при отправке главного меню: {e}") + try: + balance_rubles = balance_kopeks / 100 + await message.answer( + f"Добро пожаловать, {user_name}!\n" + f"Баланс: {balance_rubles} ₽\n" + f"Подписка: Нет активной подписки", + reply_markup=get_main_menu_keyboard( + language=user_language, + is_admin=settings.is_admin(user_telegram_id), + has_had_paid_subscription=has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active + ) + ) + logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}") + except Exception as fallback_error: + logger.error(f"❌ Критическая ошибка при отправке fallback меню: {fallback_error}") + try: + await message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.") + logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}") + except: + pass + + logger.info(f"✅ Зарегистрирован новый пользователь: {user_telegram_id}") + + +def _get_subscription_status(user, texts): + if user.subscription and user.subscription.is_active: + return getattr(texts, 'SUBSCRIPTION_ACTIVE', 'Активна') + return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки') + + +def _get_subscription_status_simple(texts): + return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки') + + +def get_referral_code_keyboard(language: str): + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="⭐️ Пропустить", + callback_data="referral_skip" + )] + ]) + + +def register_handlers(dp: Dispatcher): + + logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===") + + dp.message.register( + cmd_start, + Command("start") + ) + logger.info("✅ Зарегистрирован cmd_start") + + dp.callback_query.register( + process_rules_accept, + F.data.in_(["rules_accept", "rules_decline"]), + StateFilter(RegistrationStates.waiting_for_rules_accept) + ) + logger.info("✅ Зарегистрирован process_rules_accept") + + dp.callback_query.register( + process_referral_code_skip, + F.data == "referral_skip", + StateFilter(RegistrationStates.waiting_for_referral_code) + ) + logger.info("✅ Зарегистрирован process_referral_code_skip") + + dp.message.register( + process_referral_code_input, + StateFilter(RegistrationStates.waiting_for_referral_code) + ) + logger.info("✅ Зарегистрирован process_referral_code_input") + + logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===") \ No newline at end of file diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py new file mode 100644 index 00000000..318a5e0a --- /dev/null +++ b/app/handlers/subscription.py @@ -0,0 +1,1979 @@ +import logging +from datetime import datetime +from aiogram import Dispatcher, types, F +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES +from app.states import SubscriptionStates +from app.database.crud.subscription import ( + get_subscription_by_user_id, create_trial_subscription, + create_paid_subscription, extend_subscription, + add_subscription_traffic, add_subscription_devices, + add_subscription_squad, update_subscription_autopay, + add_subscription_servers +) +from app.database.crud.user import subtract_user_balance +from app.database.crud.transaction import create_transaction, get_user_transactions +from app.database.models import ( + User, TransactionType, SubscriptionStatus, + SubscriptionServer +) +from app.keyboards.inline import ( + get_subscription_keyboard, get_trial_keyboard, + get_subscription_period_keyboard, get_traffic_packages_keyboard, + get_countries_keyboard, get_devices_keyboard, + get_subscription_confirm_keyboard, get_autopay_keyboard, + get_autopay_days_keyboard, get_back_keyboard, + get_extend_subscription_keyboard, get_add_traffic_keyboard, + get_add_devices_keyboard, get_reset_traffic_confirm_keyboard, + get_manage_countries_keyboard +) +from app.localization.texts import get_texts +from app.services.remnawave_service import RemnaWaveService +from app.services.subscription_service import SubscriptionService +from app.services.referral_service import process_referral_purchase + +logger = logging.getLogger(__name__) + + +async def show_subscription_info( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + await db.refresh(db_user) + + texts = get_texts(db_user.language) + subscription = db_user.subscription + + if not subscription: + await callback.message.edit_text( + texts.SUBSCRIPTION_NONE, + reply_markup=get_back_keyboard(db_user.language) + ) + await callback.answer() + return + + subscription_service = SubscriptionService() + await subscription_service.sync_subscription_usage(db, subscription) + + await db.refresh(subscription) + + devices_used = await get_current_devices_count(db_user) + + countries_info = await _get_countries_info(subscription.connected_squads) + countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет" + + subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." + + if subscription.is_trial: + status_text = "🎁 Тестовая" + type_text = "Триал" + else: + if subscription.is_active: + status_text = "✅ Оплачена" + else: + status_text = "❌ Истекла" + type_text = "Платная подписка" + + if subscription.traffic_limit_gb == 0: + traffic_text = "∞ (безлимит)" + else: + traffic_text = texts.format_traffic(subscription.traffic_limit_gb) + + subscription_cost = await get_subscription_cost(subscription, db) + + info_text = texts.SUBSCRIPTION_INFO.format( + status=status_text, + type=type_text, + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + days_left=max(0, subscription.days_left), + traffic_used=texts.format_traffic(subscription.traffic_used_gb), + traffic_limit=traffic_text, + countries_count=len(subscription.connected_squads), + devices_used=devices_used, + devices_limit=subscription.device_limit, + autopay_status="✅ Включен" if subscription.autopay_enabled else "❌ Выключен" + ) + + if subscription_cost > 0: + info_text += f"\n💰 Стоимость подписки: {texts.format_price(subscription_cost)}" + + if subscription_url and subscription_url != "Генерируется...": + info_text += f"\n\n🔗 Ссылка для подключения:\n{subscription_url}" + info_text += f"\n\n📱 Скопируйте ссылку и добавьте в ваше VPN приложение" + + await callback.message.edit_text( + info_text, + reply_markup=get_subscription_keyboard( + db_user.language, + has_subscription=True, + is_trial=subscription.is_trial, + subscription=subscription + ), + parse_mode="HTML" + ) + await callback.answer() + +async def get_current_devices_count(db_user: User) -> str: + try: + if not db_user.remnawave_uuid: + return "—" + + from app.services.remnawave_service import RemnaWaveService + service = RemnaWaveService() + + async with service.api as api: + response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') + + if response and 'response' in response: + total_devices = response['response'].get('total', 0) + return str(total_devices) + else: + return "—" + + except Exception as e: + logger.error(f"Ошибка получения количества устройств: {e}") + return "—" + + +async def get_subscription_cost(subscription, db: AsyncSession) -> int: + try: + if subscription.is_trial: + return 0 + + from app.database.crud.transaction import get_user_transactions + from app.database.models import TransactionType + + transactions = await get_user_transactions(db, subscription.user_id, limit=100) + + logger.info(f"🔍 Всего транзакций у пользователя {subscription.user_id}: {len(transactions)}") + + total_subscription_cost = 0 + base_subscription_cost = 0 + additions_cost = 0 + + for transaction in transactions: + if transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value: + description = transaction.description.lower() + amount = transaction.amount_kopeks + + logger.info(f"📝 Транзакция: {amount/100}₽ - '{transaction.description}'") + + if 'сброс' in description: + logger.info(f" ⏭️ Пропускаем сброс") + continue + + + is_base_subscription = ( + ('подписка' in description and 'дней' in description) or + ('покупка' in description and 'подписка' in description) or + ('subscription' in description and 'days' in description) + ) + + if is_base_subscription: + base_subscription_cost += amount + logger.info(f" 💎 Основная подписка: +{amount/100}₽") + continue + + is_addition = any(keyword in description for keyword in [ + 'добавление', 'добавить', 'продление', 'продлить' + ]) + + if is_addition: + additions_cost += amount + logger.info(f" 🔧 Дополнение: +{amount/100}₽") + continue + + if amount >= 50000: + base_subscription_cost += amount + logger.info(f" 💰 Крупная транзакция подписки: +{amount/100}₽") + continue + + logger.info(f" ❓ Неопознанная транзакция, пропускаем") + + total_subscription_cost = base_subscription_cost + additions_cost + + if total_subscription_cost > 0: + logger.info(f"💰 Итого стоимость подписки:") + logger.info(f" 📦 Базовая подписка: {base_subscription_cost/100}₽") + logger.info(f" 🔧 Дополнения: {additions_cost/100}₽") + logger.info(f" 💎 ОБЩАЯ СТОИМОСТЬ: {total_subscription_cost/100}₽") + return total_subscription_cost + else: + logger.warning(f"⚠️ Стоимость подписки не найдена для пользователя {subscription.user_id}") + return 0 + + except Exception as e: + logger.error(f"❌ Ошибка расчета стоимости подписки: {e}") + return 0 + + +async def show_trial_offer( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + + if db_user.subscription or db_user.has_had_paid_subscription: + await callback.message.edit_text( + texts.TRIAL_ALREADY_USED, + reply_markup=get_back_keyboard(db_user.language) + ) + await callback.answer() + return + + trial_text = texts.TRIAL_AVAILABLE.format( + days=settings.TRIAL_DURATION_DAYS, + traffic=settings.TRIAL_TRAFFIC_LIMIT_GB, + devices=settings.TRIAL_DEVICE_LIMIT + ) + + await callback.message.edit_text( + trial_text, + reply_markup=get_trial_keyboard(db_user.language) + ) + await callback.answer() + + +async def activate_trial( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + + if db_user.subscription or db_user.has_had_paid_subscription: + await callback.message.edit_text( + texts.TRIAL_ALREADY_USED, + reply_markup=get_back_keyboard(db_user.language) + ) + await callback.answer() + return + + try: + subscription = await create_trial_subscription(db, db_user.id) + + await db.refresh(db_user) + + subscription_service = SubscriptionService() + remnawave_user = await subscription_service.create_remnawave_user( + db, subscription + ) + + await db.refresh(db_user) + + if remnawave_user and hasattr(subscription, 'subscription_url') and subscription.subscription_url: + trial_success_text = f"{texts.TRIAL_ACTIVATED}\n\n" + trial_success_text += f"🔗 Ваша ссылка для подключения:\n" + trial_success_text += f"{subscription.subscription_url}\n\n" + trial_success_text += f"📱 Скопируйте эту ссылку и добавьте её в ваш VPN-клиент" + + await callback.message.edit_text( + trial_success_text, + reply_markup=get_back_keyboard(db_user.language), + parse_mode="HTML" + ) + else: + await callback.message.edit_text( + f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд.", + reply_markup=get_back_keyboard(db_user.language) + ) + + logger.info(f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}") + + except Exception as e: + logger.error(f"Ошибка активации триала: {e}") + await callback.message.edit_text( + texts.ERROR, + reply_markup=get_back_keyboard(db_user.language) + ) + + await callback.answer() + + +async def start_subscription_purchase( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User +): + + texts = get_texts(db_user.language) + + await callback.message.edit_text( + texts.BUY_SUBSCRIPTION_START, + reply_markup=get_subscription_period_keyboard(db_user.language) + ) + + await state.set_data({ + 'period_days': None, + 'traffic_gb': None, + 'countries': [], + 'devices': 1, + 'total_price': 0 + }) + + await state.set_state(SubscriptionStates.selecting_period) + await callback.answer() + + + +async def handle_add_countries( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + + texts = get_texts(db_user.language) + subscription = db_user.subscription + + if not subscription or subscription.is_trial: + await callback.answer("❌ Эта функция доступна только для платных подписок", show_alert=True) + return + + countries = await _get_available_countries() + current_countries = subscription.connected_squads + + current_countries_names = [] + for country in countries: + if country['uuid'] in current_countries: + current_countries_names.append(country['name']) + + text = "🌍 Управление странами подписки\n\n" + text += f"📍 Текущие страны ({len(current_countries)}):\n" + if current_countries_names: + text += "\n".join(f"• {name}" for name in current_countries_names) + else: + text += "Нет подключенных стран" + + text += "\n\n💡 Инструкция:\n" + text += "✅ - страна подключена\n" + text += "➕ - будет добавлена (платно)\n" + text += "➖ - будет отключена (бесплатно)\n" + text += "⚪ - не выбрана\n\n" + text += "⚠️ Важно: Повторное подключение отключенных стран будет платным!" + + await state.update_data(countries=current_countries.copy()) + + await callback.message.edit_text( + text, + reply_markup=get_manage_countries_keyboard( + countries, + current_countries.copy(), + current_countries, + db_user.language + ), + parse_mode="HTML" + ) + + await callback.answer() + +async def handle_manage_country( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + logger.info(f"🔍 Управление страной: {callback.data}") + + country_uuid = callback.data.split('_')[2] + + subscription = db_user.subscription + if not subscription or subscription.is_trial: + await callback.answer("❌ Только для платных подписок", show_alert=True) + return + + data = await state.get_data() + current_selected = data.get('countries', subscription.connected_squads.copy()) + + if country_uuid in current_selected: + current_selected.remove(country_uuid) + action = "removed" + else: + current_selected.append(country_uuid) + action = "added" + + logger.info(f"🔍 Страна {country_uuid} {action}") + + await state.update_data(countries=current_selected) + + countries = await _get_available_countries() + + try: + await callback.message.edit_reply_markup( + reply_markup=get_manage_countries_keyboard( + countries, + current_selected, + subscription.connected_squads, + db_user.language + ) + ) + logger.info(f"✅ Клавиатура обновлена") + + except Exception as e: + logger.error(f"❌ Ошибка обновления клавиатуры: {e}") + + await callback.answer() + +async def apply_countries_changes( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + logger.info(f"🔍 Применение изменений стран") + + data = await state.get_data() + new_countries = data.get('countries', []) + + subscription = db_user.subscription + old_countries = subscription.connected_squads + + added = [c for c in new_countries if c not in old_countries] + removed = [c for c in old_countries if c not in new_countries] + + if not added and not removed: + await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) + return + + logger.info(f"🔍 Добавлено: {added}, Удалено: {removed}") + + countries = await _get_available_countries() + cost = 0 + added_names = [] + removed_names = [] + + for country in countries: + if country['uuid'] in added: + cost += country['price_kopeks'] + added_names.append(country['name']) + if country['uuid'] in removed: + removed_names.append(country['name']) + + texts = get_texts(db_user.language) + + if cost > 0 and db_user.balance_kopeks < cost: + await callback.answer( + f"❌ Недостаточно средств!\nТребуется: {texts.format_price(cost)}\nУ вас: {texts.format_price(db_user.balance_kopeks)}", + show_alert=True + ) + return + + try: + if cost > 0: + success = await subtract_user_balance( + db, db_user, cost, + f"Добавление стран: {', '.join(added_names)}" + ) + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + await create_transaction( + db=db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=cost, + description=f"Добавление стран к подписке: {', '.join(added_names)}" + ) + + subscription.connected_squads = new_countries + subscription.updated_at = datetime.utcnow() + await db.commit() + + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, subscription) + + if cost > 0: + try: + await process_referral_purchase( + db=db, + user_id=db_user.id, + purchase_amount_kopeks=cost, + transaction_id=None + ) + except Exception as e: + logger.error(f"Ошибка обработки реферальной покупки: {e}") + + await db.refresh(subscription) + + success_text = "✅ Страны успешно обновлены!\n\n" + + if added_names: + success_text += f"➕ Добавлены страны:\n" + success_text += "\n".join(f"• {name}" for name in added_names) + if cost > 0: + success_text += f"\n💰 Списано: {texts.format_price(cost)}" + success_text += "\n" + + if removed_names: + success_text += f"\n➖ Отключены страны:\n" + success_text += "\n".join(f"• {name}" for name in removed_names) + success_text += "\nℹ️ Повторное подключение будет платным\n" + + success_text += f"\n🌍 Активных стран: {len(new_countries)}" + + await callback.message.edit_text( + success_text, + reply_markup=get_back_keyboard(db_user.language), + parse_mode="HTML" + ) + + await state.clear() + logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}") + + except Exception as e: + logger.error(f"❌ Ошибка применения изменений: {e}") + await callback.message.edit_text( + texts.ERROR, + reply_markup=get_back_keyboard(db_user.language) + ) + + await callback.answer() + + +async def handle_add_traffic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + subscription = db_user.subscription + + if not subscription or subscription.is_trial: + await callback.answer("❌ Эта функция доступна только для платных подписок", show_alert=True) + return + + if subscription.traffic_limit_gb == 0: + await callback.answer("❌ У вас уже безлимитный трафик", show_alert=True) + return + + current_traffic = subscription.traffic_limit_gb + + await callback.message.edit_text( + f"📈 Добавить трафик к подписке\n\n" + f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" + f"Выберите дополнительный трафик:", + reply_markup=get_add_traffic_keyboard(db_user.language) + ) + + await callback.answer() + + +async def handle_add_devices( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + subscription = db_user.subscription + + if not subscription or subscription.is_trial: + await callback.answer("❌ Эта функция доступна только для платных подписок", show_alert=True) + return + + current_devices = subscription.device_limit + + await callback.message.edit_text( + f"📱 Добавить устройства к подписке\n\n" + f"Текущий лимит: {current_devices} устройств\n" + f"Выберите количество дополнительных устройств:", + reply_markup=get_add_devices_keyboard(current_devices, db_user.language) + ) + + await callback.answer() + + +async def handle_extend_subscription( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + subscription = db_user.subscription + + if not subscription or subscription.is_trial: + await callback.answer("❌ Продление доступно только для платных подписок", show_alert=True) + return + + if subscription.days_left > 3: + await callback.answer("❌ Продление доступно за 3 дня до окончания подписки", show_alert=True) + return + + await callback.message.edit_text( + f"⏰ Продление подписки\n\n" + f"Осталось дней: {subscription.days_left}\n" + f"Выберите период продления:", + reply_markup=get_extend_subscription_keyboard(db_user.language) + ) + + await callback.answer() + + +async def handle_reset_traffic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + subscription = db_user.subscription + + if not subscription or subscription.is_trial: + await callback.answer("❌ Эта функция доступна только для платных подписок", show_alert=True) + return + + if subscription.traffic_limit_gb == 0: + await callback.answer("❌ У вас безлимитный трафик", show_alert=True) + return + + reset_price = PERIOD_PRICES[30] + + if db_user.balance_kopeks < reset_price: + await callback.answer("❌ Недостаточно средств на балансе", show_alert=True) + return + + await callback.message.edit_text( + f"🔄 Сброс трафика\n\n" + f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n" + f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n" + f"Стоимость сброса: {texts.format_price(reset_price)}\n\n" + "После сброса счетчик использованного трафика станет равным 0.", + reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language) + ) + + await callback.answer() + + + +async def confirm_add_traffic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + traffic_gb = int(callback.data.split('_')[2]) + texts = get_texts(db_user.language) + subscription = db_user.subscription + + price = TRAFFIC_PRICES[traffic_gb] + + if db_user.balance_kopeks < price: + await callback.answer("❌ Недостаточно средств на балансе", show_alert=True) + return + + try: + success = await subtract_user_balance( + db, db_user, price, + f"Добавление {traffic_gb} ГБ трафика" + ) + + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + if traffic_gb == 0: + subscription.traffic_limit_gb = 0 + else: + await add_subscription_traffic(db, subscription, traffic_gb) + + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, subscription) + + await create_transaction( + db=db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=price, + description=f"Добавление {traffic_gb} ГБ трафика" + ) + + try: + await process_referral_purchase( + db=db, + user_id=db_user.id, + purchase_amount_kopeks=price, + transaction_id=None + ) + except Exception as e: + logger.error(f"Ошибка обработки реферальной покупки: {e}") + + await db.refresh(db_user) + await db.refresh(subscription) + + success_text = f"✅ Трафик успешно добавлен!\n\n" + if traffic_gb == 0: + success_text += "🎉 Теперь у вас безлимитный трафик!" + else: + success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" + success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" + + await callback.message.edit_text( + success_text, + reply_markup=get_back_keyboard(db_user.language) + ) + + logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика") + + except Exception as e: + logger.error(f"Ошибка добавления трафика: {e}") + await callback.message.edit_text( + texts.ERROR, + reply_markup=get_back_keyboard(db_user.language) + ) + + await callback.answer() + + +async def confirm_add_devices( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + devices_count = int(callback.data.split('_')[2]) + texts = get_texts(db_user.language) + subscription = db_user.subscription + + price = devices_count * settings.PRICE_PER_DEVICE + + if db_user.balance_kopeks < price: + await callback.answer("❌ Недостаточно средств на балансе", show_alert=True) + return + + try: + success = await subtract_user_balance( + db, db_user, price, + f"Добавление {devices_count} устройств" + ) + + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + await add_subscription_devices(db, subscription, devices_count) + + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, subscription) + + await create_transaction( + db=db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=price, + description=f"Добавление {devices_count} устройств" + ) + + try: + await process_referral_purchase( + db=db, + user_id=db_user.id, + purchase_amount_kopeks=price, + transaction_id=None + ) + except Exception as e: + logger.error(f"Ошибка обработки реферальной покупки: {e}") + + await db.refresh(db_user) + await db.refresh(subscription) + + await callback.message.edit_text( + f"✅ Устройства успешно добавлены!\n\n" + f"📱 Добавлено: {devices_count} устройств\n" + f"Новый лимит: {subscription.device_limit} устройств", + reply_markup=get_back_keyboard(db_user.language) + ) + + logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств") + + except Exception as e: + logger.error(f"Ошибка добавления устройств: {e}") + await callback.message.edit_text( + texts.ERROR, + reply_markup=get_back_keyboard(db_user.language) + ) + + await callback.answer() + + +async def confirm_extend_subscription( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + days = int(callback.data.split('_')[2]) + texts = get_texts(db_user.language) + subscription = db_user.subscription + + price = PERIOD_PRICES[days] + + if db_user.balance_kopeks < price: + await callback.answer("❌ Недостаточно средств на балансе", show_alert=True) + return + + try: + success = await subtract_user_balance( + db, db_user, price, + f"Продление подписки на {days} дней" + ) + + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + await extend_subscription(db, subscription, days) + + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, subscription) + + await create_transaction( + db=db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=price, + description=f"Продление подписки на {days} дней" + ) + + try: + await process_referral_purchase( + db=db, + user_id=db_user.id, + purchase_amount_kopeks=price, + transaction_id=None + ) + except Exception as e: + logger.error(f"Ошибка обработки реферальной покупки: {e}") + + await db.refresh(db_user) + await db.refresh(subscription) + + await callback.message.edit_text( + f"✅ Подписка успешно продлена!\n\n" + f"⏰ Добавлено: {days} дней\n" + f"Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}", + reply_markup=get_back_keyboard(db_user.language) + ) + + logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней") + + except Exception as e: + logger.error(f"Ошибка продления подписки: {e}") + await callback.message.edit_text( + texts.ERROR, + reply_markup=get_back_keyboard(db_user.language) + ) + + await callback.answer() + + +async def confirm_reset_traffic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + subscription = db_user.subscription + + reset_price = PERIOD_PRICES[30] + + if db_user.balance_kopeks < reset_price: + await callback.answer("❌ Недостаточно средств на балансе", show_alert=True) + return + + try: + success = await subtract_user_balance( + db, db_user, reset_price, + "Сброс трафика" + ) + + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + subscription.traffic_used_gb = 0.0 + subscription.updated_at = datetime.utcnow() + await db.commit() + + subscription_service = SubscriptionService() + remnawave_service = RemnaWaveService() + + user = db_user + if user.remnawave_uuid: + async with remnawave_service.api as api: + await api.reset_user_traffic(user.remnawave_uuid) + + await create_transaction( + db=db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=reset_price, + description="Сброс трафика" + ) + + await db.refresh(db_user) + await db.refresh(subscription) + + await callback.message.edit_text( + f"✅ Трафик успешно сброшен!\n\n" + f"🔄 Использованный трафик обнулен\n" + f"📊 Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}", + reply_markup=get_back_keyboard(db_user.language) + ) + + logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил трафик") + + except Exception as e: + logger.error(f"Ошибка сброса трафика: {e}") + await callback.message.edit_text( + texts.ERROR, + reply_markup=get_back_keyboard(db_user.language) + ) + + await callback.answer() + + + +async def select_period( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User +): + + period_days = int(callback.data.split('_')[1]) + texts = get_texts(db_user.language) + + data = await state.get_data() + data['period_days'] = period_days + data['total_price'] = PERIOD_PRICES[period_days] + await state.set_data(data) + + await callback.message.edit_text( + texts.SELECT_TRAFFIC, + reply_markup=get_traffic_packages_keyboard(db_user.language) + ) + + await state.set_state(SubscriptionStates.selecting_traffic) + await callback.answer() + + +async def select_traffic( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User +): + + traffic_gb = int(callback.data.split('_')[1]) + texts = get_texts(db_user.language) + + data = await state.get_data() + data['traffic_gb'] = traffic_gb + data['total_price'] += TRAFFIC_PRICES[traffic_gb] + await state.set_data(data) + + countries = await _get_available_countries() + + await callback.message.edit_text( + texts.SELECT_COUNTRIES, + reply_markup=get_countries_keyboard(countries, [], db_user.language) + ) + + await state.set_state(SubscriptionStates.selecting_countries) + await callback.answer() + + +async def select_country( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User +): + + country_uuid = callback.data.split('_')[1] + data = await state.get_data() + + selected_countries = data.get('countries', []) + if country_uuid in selected_countries: + selected_countries.remove(country_uuid) + else: + selected_countries.append(country_uuid) + + countries = await _get_available_countries() + + base_price = PERIOD_PRICES[data['period_days']] + TRAFFIC_PRICES[data['traffic_gb']] + + countries_price = 0 + for country in countries: + if country['uuid'] in selected_countries: + countries_price += country['price_kopeks'] + + data['countries'] = selected_countries + data['total_price'] = base_price + countries_price + await state.set_data(data) + + await callback.message.edit_reply_markup( + reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language) + ) + await callback.answer() + + +async def countries_continue( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User +): + + data = await state.get_data() + texts = get_texts(db_user.language) + + if not data.get('countries'): + await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True) + return + + await callback.message.edit_text( + texts.SELECT_DEVICES, + reply_markup=get_devices_keyboard(1, db_user.language) + ) + + await state.set_state(SubscriptionStates.selecting_devices) + await callback.answer() + + +async def select_devices( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User +): + if not callback.data.startswith("devices_") or callback.data == "devices_continue": + await callback.answer("❌ Некорректный запрос", show_alert=True) + return + + try: + devices = int(callback.data.split('_')[1]) + except (ValueError, IndexError): + await callback.answer("❌ Некорректное количество устройств", show_alert=True) + return + + data = await state.get_data() + + base_price = ( + PERIOD_PRICES[data['period_days']] + + TRAFFIC_PRICES[data['traffic_gb']] + ) + + countries = await _get_available_countries() + countries_price = sum( + c['price_kopeks'] for c in countries + if c['uuid'] in data['countries'] + ) + + devices_price = (devices - 1) * settings.PRICE_PER_DEVICE + + data['devices'] = devices + data['total_price'] = base_price + countries_price + devices_price + await state.set_data(data) + + await callback.message.edit_reply_markup( + reply_markup=get_devices_keyboard(devices, db_user.language) + ) + await callback.answer() + + +async def devices_continue( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User +): + + if not callback.data == "devices_continue": + await callback.answer("❌ Некорректный запрос", show_alert=True) + return + + data = await state.get_data() + texts = get_texts(db_user.language) + + countries = await _get_available_countries() + selected_countries_names = [ + c['name'] for c in countries + if c['uuid'] in data['countries'] + ] + + summary_text = texts.SUBSCRIPTION_SUMMARY.format( + period=data['period_days'], + traffic=texts.format_traffic(data['traffic_gb']), + countries=", ".join(selected_countries_names), + devices=data['devices'], + total_price=texts.format_price(data['total_price']) + ) + + await callback.message.edit_text( + summary_text, + reply_markup=get_subscription_confirm_keyboard(db_user.language) + ) + + await state.set_state(SubscriptionStates.confirming_purchase) + await callback.answer() + + +async def confirm_purchase( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + data = await state.get_data() + texts = get_texts(db_user.language) + + countries = await _get_available_countries() + + base_price = PERIOD_PRICES[data['period_days']] + TRAFFIC_PRICES[data['traffic_gb']] + + countries_price = 0 + for country in countries: + if country['uuid'] in data['countries']: + countries_price += country['price_kopeks'] + + devices_price = (data['devices'] - 1) * settings.PRICE_PER_DEVICE + final_price = base_price + countries_price + devices_price + + if db_user.balance_kopeks < final_price: + await callback.message.edit_text( + texts.INSUFFICIENT_BALANCE, + reply_markup=get_back_keyboard(db_user.language) + ) + await callback.answer() + return + + try: + success = await subtract_user_balance( + db, db_user, final_price, + f"Покупка подписки на {data['period_days']} дней" + ) + + if not success: + await callback.message.edit_text( + texts.INSUFFICIENT_BALANCE, + reply_markup=get_back_keyboard(db_user.language) + ) + await callback.answer() + return + + existing_subscription = db_user.subscription + + if existing_subscription and existing_subscription.is_trial: + logger.info(f"🔄 Обновляем триальную подписку пользователя {db_user.telegram_id}") + + existing_subscription.is_trial = False + existing_subscription.status = SubscriptionStatus.ACTIVE.value + + existing_subscription.traffic_limit_gb = data['traffic_gb'] + existing_subscription.device_limit = data['devices'] + existing_subscription.connected_squads = data['countries'] + + existing_subscription.extend_subscription(data['period_days']) + existing_subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(existing_subscription) + subscription = existing_subscription + + logger.info(f"✅ Триальная подписка обновлена до платной. Новая дата окончания: {subscription.end_date}") + + else: + logger.info(f"🆕 Создаем новую платную подписку для пользователя {db_user.telegram_id}") + subscription = await create_paid_subscription( + db=db, + user_id=db_user.id, + duration_days=data['period_days'], + traffic_limit_gb=data['traffic_gb'], + device_limit=data['devices'], + connected_squads=data['countries'] + ) + + from app.utils.user_utils import mark_user_as_had_paid_subscription + await mark_user_as_had_paid_subscription(db, db_user) + + from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers + from app.database.crud.subscription import add_subscription_servers + + server_ids = await get_server_ids_by_uuids(db, data['countries']) + + if server_ids: + countries = await _get_available_countries() + server_prices = [] + for country_uuid in data['countries']: + for country in countries: + if country['uuid'] == country_uuid: + server_prices.append(country['price_kopeks']) + break + else: + server_prices.append(0) + + await add_subscription_servers(db, subscription, server_ids, server_prices) + + await add_user_to_servers(db, server_ids) + + logger.info(f"📊 Обновлены счетчики пользователей для серверов: {server_ids}") + + await db.refresh(db_user) + + subscription_service = SubscriptionService() + + if existing_subscription and existing_subscription.is_trial == False: + remnawave_user = await subscription_service.update_remnawave_user(db, subscription) + else: + remnawave_user = await subscription_service.create_remnawave_user(db, subscription) + + await create_transaction( + db=db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=final_price, + description=f"Подписка на {data['period_days']} дней" + ) + + try: + await process_referral_purchase( + db=db, + user_id=db_user.id, + purchase_amount_kopeks=final_price, + transaction_id=None + ) + except Exception as e: + logger.error(f"Ошибка обработки реферальной покупки: {e}") + + await db.refresh(db_user) + await db.refresh(subscription) + + if remnawave_user and hasattr(subscription, 'subscription_url') and subscription.subscription_url: + success_text = f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + success_text += f"🔗 Ваша ссылка для подключения:\n" + success_text += f"{subscription.subscription_url}\n\n" + success_text += f"📱 Скопируйте эту ссылку и добавьте её в ваш VPN-клиент" + + await callback.message.edit_text( + success_text, + reply_markup=get_back_keyboard(db_user.language), + parse_mode="HTML" + ) + else: + await callback.message.edit_text( + f"{texts.SUBSCRIPTION_PURCHASED}\n\n⚠️ Ссылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", + reply_markup=get_back_keyboard(db_user.language) + ) + + logger.info(f"✅ Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней") + + except Exception as e: + logger.error(f"Ошибка покупки подписки: {e}") + await callback.message.edit_text( + texts.ERROR, + reply_markup=get_back_keyboard(db_user.language) + ) + + await state.clear() + await callback.answer() + + +async def handle_autopay_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + subscription = db_user.subscription + if not subscription: + await callback.answer("⚠️ У вас нет активной подписки!", show_alert=True) + return + + status = "включен" if subscription.autopay_enabled else "выключен" + days = subscription.autopay_days_before + + text = f"💳 Автоплатеж\n\n" + text += f"📊 Статус: {status}\n" + text += f"⏰ Списание за: {days} дн. до окончания\n\n" + text += "Выберите действие:" + + await callback.message.edit_text( + text, + reply_markup=get_autopay_keyboard(db_user.language) + ) + await callback.answer() + + +async def toggle_autopay( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + subscription = db_user.subscription + enable = callback.data == "autopay_enable" + + await update_subscription_autopay(db, subscription, enable) + + status = "включен" if enable else "выключен" + await callback.answer(f"✅ Автоплатеж {status}!") + + await handle_autopay_menu(callback, db_user, db) + + +async def show_autopay_days( + callback: types.CallbackQuery, + db_user: User +): + + await callback.message.edit_text( + "⏰ Выберите за сколько дней до окончания списывать средства:", + reply_markup=get_autopay_days_keyboard(db_user.language) + ) + await callback.answer() + + +async def set_autopay_days( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + days = int(callback.data.split('_')[2]) + subscription = db_user.subscription + + await update_subscription_autopay( + db, subscription, subscription.autopay_enabled, days + ) + + await callback.answer(f"✅ Установлено {days} дней!") + + await handle_autopay_menu(callback, db_user, db) + +async def handle_subscription_config_back( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + current_state = await state.get_state() + texts = get_texts(db_user.language) + + if current_state == SubscriptionStates.selecting_traffic.state: + await callback.message.edit_text( + texts.BUY_SUBSCRIPTION_START, + reply_markup=get_subscription_period_keyboard(db_user.language) + ) + await state.set_state(SubscriptionStates.selecting_period) + + elif current_state == SubscriptionStates.selecting_countries.state: + await callback.message.edit_text( + texts.SELECT_TRAFFIC, + reply_markup=get_traffic_packages_keyboard(db_user.language) + ) + await state.set_state(SubscriptionStates.selecting_traffic) + + elif current_state == SubscriptionStates.selecting_devices.state: + countries = await _get_available_countries() + data = await state.get_data() + selected_countries = data.get('countries', []) + + await callback.message.edit_text( + texts.SELECT_COUNTRIES, + reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language) + ) + await state.set_state(SubscriptionStates.selecting_countries) + + else: + from app.handlers.menu import show_main_menu + await show_main_menu(callback, db_user, db) + await state.clear() + + await callback.answer() + +async def handle_subscription_cancel( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + + await state.clear() + + from app.handlers.menu import show_main_menu + await show_main_menu(callback, db_user, db) + + await callback.answer("❌ Покупка отменена") + +async def _get_available_countries(): + from app.utils.cache import cache + from app.database.database import AsyncSessionLocal + from app.database.crud.server_squad import get_available_server_squads + + cached_countries = await cache.get("available_countries") + if cached_countries: + return cached_countries + + try: + async with AsyncSessionLocal() as db: + available_servers = await get_available_server_squads(db) + + countries = [] + for server in available_servers: + countries.append({ + "uuid": server.squad_uuid, + "name": server.display_name, + "price_kopeks": server.price_kopeks, + "country_code": server.country_code, + "is_available": server.is_available and not server.is_full + }) + + if not countries: + logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...") + from app.services.remnawave_service import RemnaWaveService + + service = RemnaWaveService() + squads = await service.get_all_squads() + + for squad in squads: + squad_name = squad["name"] + + if not any(flag in squad_name for flag in ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]): + name_lower = squad_name.lower() + if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower: + squad_name = f"🇳🇱 {squad_name}" + elif "germany" in name_lower or "германия" in name_lower or "de" in name_lower: + squad_name = f"🇩🇪 {squad_name}" + elif "usa" in name_lower or "сша" in name_lower or "america" in name_lower or "us" in name_lower: + squad_name = f"🇺🇸 {squad_name}" + else: + squad_name = f"🌐 {squad_name}" + + countries.append({ + "uuid": squad["uuid"], + "name": squad_name, + "price_kopeks": 1000, + "is_available": True + }) + + await cache.set("available_countries", countries, 300) + return countries + + except Exception as e: + logger.error(f"Ошибка получения списка стран: {e}") + fallback_countries = [ + {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True}, + ] + + await cache.set("available_countries", fallback_countries, 60) + return fallback_countries + +async def _get_countries_info(squad_uuids): + countries = await _get_available_countries() + return [c for c in countries if c['uuid'] in squad_uuids] + +async def handle_reset_devices( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + subscription = db_user.subscription + + if not subscription or subscription.is_trial: + await callback.answer("❌ Эта функция доступна только для платных подписок", show_alert=True) + return + + if not db_user.remnawave_uuid: + await callback.answer("❌ UUID пользователя не найден", show_alert=True) + return + + try: + from app.services.remnawave_service import RemnaWaveService + service = RemnaWaveService() + + async with service.api as api: + response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') + + if response and 'response' in response: + devices_info = response['response'] + total_devices = devices_info.get('total', 0) + devices_list = devices_info.get('devices', []) + + if total_devices == 0: + await callback.answer("ℹ️ У вас нет подключенных устройств", show_alert=True) + return + + devices_text = "\n".join([ + f"• {device.get('platform', 'Unknown')} - {device.get('deviceModel', 'Unknown')}" + for device in devices_list[:5] + ]) + + if len(devices_list) > 5: + devices_text += f"\n... и еще {len(devices_list) - 5}" + + confirm_text = f"🔄 Сброс устройств\n\n" + confirm_text += f"📊 Всего подключено: {total_devices} устройств\n\n" + confirm_text += f"Подключенные устройства:\n{devices_text}\n\n" + confirm_text += "⚠️ Внимание! Все устройства будут отключены и вам потребуется заново настроить VPN на каждом устройстве.\n\n" + confirm_text += "Продолжить?" + + await callback.message.edit_text( + confirm_text, + reply_markup=get_reset_devices_confirm_keyboard(db_user.language), + parse_mode="HTML" + ) + else: + await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка получения списка устройств: {e}") + await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) + + await callback.answer() + +async def handle_add_country_to_subscription( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + + logger.info(f"🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}") + logger.info(f"🔍 Callback data: {callback.data}") + + current_state = await state.get_state() + logger.info(f"🔍 Текущее состояние: {current_state}") + + country_uuid = callback.data.split('_')[1] + data = await state.get_data() + logger.info(f"🔍 Данные состояния: {data}") + + selected_countries = data.get('countries', []) + countries = await _get_available_countries() + + if country_uuid in selected_countries: + selected_countries.remove(country_uuid) + logger.info(f"🔍 Удалена страна: {country_uuid}") + else: + selected_countries.append(country_uuid) + logger.info(f"🔍 Добавлена страна: {country_uuid}") + + total_price = 0 + for country in countries: + if country['uuid'] in selected_countries and country['uuid'] not in db_user.subscription.connected_squads: + total_price += country['price_kopeks'] + + data['countries'] = selected_countries + data['total_price'] = total_price + await state.set_data(data) + + logger.info(f"🔍 Новые выбранные страны: {selected_countries}") + logger.info(f"🔍 Общая стоимость: {total_price}") + + try: + from app.keyboards.inline import get_manage_countries_keyboard + await callback.message.edit_reply_markup( + reply_markup=get_manage_countries_keyboard(countries, selected_countries, db_user.subscription.connected_squads, db_user.language) + ) + logger.info(f"✅ Клавиатура обновлена") + except Exception as e: + logger.error(f"❌ Ошибка обновления клавиатуры: {e}") + + await callback.answer() + + +async def confirm_add_countries_to_subscription( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + + data = await state.get_data() + texts = get_texts(db_user.language) + subscription = db_user.subscription + + selected_countries = data.get('countries', []) + current_countries = subscription.connected_squads + + new_countries = [c for c in selected_countries if c not in current_countries] + removed_countries = [c for c in current_countries if c not in selected_countries] + + if not new_countries and not removed_countries: + await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) + return + + countries = await _get_available_countries() + total_price = 0 + new_countries_names = [] + removed_countries_names = [] + + for country in countries: + if country['uuid'] in new_countries: + total_price += country['price_kopeks'] + new_countries_names.append(country['name']) + if country['uuid'] in removed_countries: + removed_countries_names.append(country['name']) + + if new_countries and db_user.balance_kopeks < total_price: + await callback.message.edit_text( + f"❌ Недостаточно средств на балансе!\n\n" + f"💰 Требуется: {texts.format_price(total_price)}\n" + f"💳 У вас: {texts.format_price(db_user.balance_kopeks)}", + reply_markup=get_back_keyboard(db_user.language) + ) + await state.clear() + await callback.answer() + return + + try: + if new_countries and total_price > 0: + success = await subtract_user_balance( + db, db_user, total_price, + f"Добавление стран к подписке: {', '.join(new_countries_names)}" + ) + + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + await create_transaction( + db=db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=total_price, + description=f"Добавление стран к подписке: {', '.join(new_countries_names)}" + ) + + subscription.connected_squads = selected_countries + subscription.updated_at = datetime.utcnow() + await db.commit() + + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, subscription) + + if new_countries and total_price > 0: + try: + await process_referral_purchase( + db=db, + user_id=db_user.id, + purchase_amount_kopeks=total_price, + transaction_id=None + ) + except Exception as e: + logger.error(f"Ошибка обработки реферальной покупки: {e}") + + await db.refresh(db_user) + await db.refresh(subscription) + + success_text = "✅ Страны успешно обновлены!\n\n" + + if new_countries_names: + success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n" + if total_price > 0: + success_text += f"💰 Списано: {texts.format_price(total_price)}\n" + + if removed_countries_names: + success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n" + success_text += "ℹ️ Повторное подключение будет платным\n" + + success_text += f"\n🌍 Активных стран: {len(selected_countries)}" + + await callback.message.edit_text( + success_text, + reply_markup=get_back_keyboard(db_user.language) + ) + + logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}") + + except Exception as e: + logger.error(f"Ошибка обновления стран подписки: {e}") + await callback.message.edit_text( + texts.ERROR, + reply_markup=get_back_keyboard(db_user.language) + ) + + await state.clear() + await callback.answer() + +async def confirm_reset_devices( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + texts = get_texts(db_user.language) + + if not db_user.remnawave_uuid: + await callback.answer("❌ UUID пользователя не найден", show_alert=True) + return + + try: + from app.services.remnawave_service import RemnaWaveService + service = RemnaWaveService() + + async with service.api as api: + devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') + + if not devices_response or 'response' not in devices_response: + await callback.answer("❌ Ошибка получения списка устройств", show_alert=True) + return + + devices_list = devices_response['response'].get('devices', []) + + if not devices_list: + await callback.answer("ℹ️ У вас нет подключенных устройств", show_alert=True) + return + + logger.info(f"🔍 Найдено {len(devices_list)} устройств для сброса") + + success_count = 0 + failed_count = 0 + + for device in devices_list: + device_hwid = device.get('hwid') + if device_hwid: + try: + delete_data = { + "userUuid": db_user.remnawave_uuid, + "hwid": device_hwid + } + + await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) + success_count += 1 + logger.info(f"✅ Устройство {device_hwid} удалено") + + except Exception as device_error: + failed_count += 1 + logger.error(f"❌ Ошибка удаления устройства {device_hwid}: {device_error}") + else: + failed_count += 1 + logger.warning(f"⚠️ У устройства нет HWID: {device}") + + if success_count > 0: + if failed_count == 0: + await callback.message.edit_text( + f"✅ Устройства успешно сброшены!\n\n" + f"🔄 Сброшено: {success_count} устройств\n" + f"📱 Теперь вы можете заново подключить свои устройства\n\n" + f"💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения", + reply_markup=get_back_keyboard(db_user.language), + parse_mode="HTML" + ) + logger.info(f"✅ Пользователь {db_user.telegram_id} успешно сбросил {success_count} устройств") + else: + await callback.message.edit_text( + f"⚠️ Частичный сброс устройств\n\n" + f"✅ Удалено: {success_count} устройств\n" + f"❌ Не удалось удалить: {failed_count} устройств\n\n" + f"Попробуйте еще раз или обратитесь в поддержку.", + reply_markup=get_back_keyboard(db_user.language), + parse_mode="HTML" + ) + logger.warning(f"⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}") + else: + await callback.message.edit_text( + f"❌ Не удалось сбросить устройства\n\n" + f"Попробуйте еще раз позже или обратитесь в техподдержку.\n\n" + f"Всего устройств: {len(devices_list)}", + reply_markup=get_back_keyboard(db_user.language), + parse_mode="HTML" + ) + logger.error(f"❌ Не удалось сбросить ни одного устройства у пользователя {db_user.telegram_id}") + + except Exception as e: + logger.error(f"Ошибка сброса устройств: {e}") + await callback.message.edit_text( + texts.ERROR, + reply_markup=get_back_keyboard(db_user.language) + ) + + await callback.answer() + + +def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Да, сбросить все устройства", + callback_data="confirm_reset_devices" + ) + ], + [ + InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription") + ] + ]) + + +def register_handlers(dp: Dispatcher): + + dp.callback_query.register( + show_subscription_info, + F.data == "menu_subscription" + ) + + dp.callback_query.register( + show_trial_offer, + F.data == "menu_trial" + ) + + dp.callback_query.register( + activate_trial, + F.data == "trial_activate" + ) + + dp.callback_query.register( + start_subscription_purchase, + F.data.in_(["menu_buy", "subscription_upgrade"]) + ) + + + dp.callback_query.register( + handle_add_countries, + F.data == "subscription_add_countries" + ) + + dp.callback_query.register( + handle_add_traffic, + F.data == "subscription_add_traffic" + ) + + dp.callback_query.register( + handle_add_devices, + F.data == "subscription_add_devices" + ) + + dp.callback_query.register( + handle_extend_subscription, + F.data == "subscription_extend" + ) + + dp.callback_query.register( + handle_reset_traffic, + F.data == "subscription_reset_traffic" + ) + + dp.callback_query.register( + confirm_add_traffic, + F.data.startswith("add_traffic_") + ) + + dp.callback_query.register( + confirm_add_devices, + F.data.startswith("add_devices_") + ) + + dp.callback_query.register( + confirm_extend_subscription, + F.data.startswith("extend_period_") + ) + + dp.callback_query.register( + confirm_reset_traffic, + F.data == "confirm_reset_traffic" + ) + + dp.callback_query.register( + handle_reset_devices, + F.data == "subscription_reset_devices" + ) + + dp.callback_query.register( + confirm_reset_devices, + F.data == "confirm_reset_devices" + ) + + + dp.callback_query.register( + select_period, + F.data.startswith("period_"), + SubscriptionStates.selecting_period + ) + + dp.callback_query.register( + select_traffic, + F.data.startswith("traffic_"), + SubscriptionStates.selecting_traffic + ) + + dp.callback_query.register( + select_devices, + F.data.startswith("devices_") & ~F.data.in_(["devices_continue"]), + SubscriptionStates.selecting_devices + ) + + dp.callback_query.register( + devices_continue, + F.data == "devices_continue", + SubscriptionStates.selecting_devices + ) + + dp.callback_query.register( + confirm_purchase, + F.data == "subscription_confirm", + SubscriptionStates.confirming_purchase + ) + + + dp.callback_query.register( + handle_autopay_menu, + F.data == "subscription_autopay" + ) + + dp.callback_query.register( + toggle_autopay, + F.data.in_(["autopay_enable", "autopay_disable"]) + ) + + dp.callback_query.register( + show_autopay_days, + F.data == "autopay_set_days" + ) + + dp.callback_query.register( + handle_subscription_config_back, + F.data == "subscription_config_back" + ) + + dp.callback_query.register( + handle_subscription_cancel, + F.data == "subscription_cancel" + ) + + dp.callback_query.register( + set_autopay_days, + F.data.startswith("autopay_days_") + ) + + dp.callback_query.register( + select_country, + F.data.startswith("country_"), + SubscriptionStates.selecting_countries + ) + + dp.callback_query.register( + countries_continue, + F.data == "countries_continue", + SubscriptionStates.selecting_countries + ) + + dp.callback_query.register( + handle_manage_country, + F.data.startswith("country_manage_") + ) + + dp.callback_query.register( + apply_countries_changes, + F.data == "countries_apply" + ) \ No newline at end of file diff --git a/app/handlers/support.py b/app/handlers/support.py new file mode 100644 index 00000000..e2f0e130 --- /dev/null +++ b/app/handlers/support.py @@ -0,0 +1,32 @@ +import logging +from aiogram import Dispatcher, types, F +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.inline import get_support_keyboard +from app.localization.texts import get_texts + +logger = logging.getLogger(__name__) + + +async def show_support_info( + callback: types.CallbackQuery, + db_user: User +): + + texts = get_texts(db_user.language) + + await callback.message.edit_text( + texts.SUPPORT_INFO, + reply_markup=get_support_keyboard(db_user.language) + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + + dp.callback_query.register( + show_support_info, + F.data == "menu_support" + ) \ No newline at end of file diff --git a/app/handlers/webhooks.py b/app/handlers/webhooks.py new file mode 100644 index 00000000..b8d0f3a7 --- /dev/null +++ b/app/handlers/webhooks.py @@ -0,0 +1,139 @@ +import logging +from aiogram import types +from aiohttp import web +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.database import get_db +from app.database.crud.user import get_user_by_id, add_user_balance +from app.database.crud.transaction import create_transaction, get_transaction_by_external_id +from app.database.models import TransactionType, PaymentMethod +from app.external.tribute import TributeService + +logger = logging.getLogger(__name__) + + +async def tribute_webhook(request): + try: + signature = request.headers.get('X-Signature', '') + payload = await request.text() + + tribute_service = TributeService() + + if not tribute_service.verify_webhook_signature(payload, signature): + logger.warning("Неверная подпись Tribute webhook") + return web.Response(status=400, text="Invalid signature") + + webhook_data = await request.json() + processed_data = await tribute_service.process_webhook(webhook_data) + + if not processed_data: + logger.error("Ошибка обработки Tribute webhook") + return web.Response(status=400, text="Invalid webhook data") + + async for db in get_db(): + try: + existing_transaction = await get_transaction_by_external_id( + db, processed_data['payment_id'], PaymentMethod.TRIBUTE + ) + + if existing_transaction: + logger.info(f"Платеж {processed_data['payment_id']} уже обработан") + return web.Response(status=200, text="Already processed") + + if processed_data['status'] == 'completed': + user = await get_user_by_id(db, processed_data['user_id']) + + if user: + await add_user_balance( + db, user, processed_data['amount_kopeks'], + f"Пополнение через Tribute: {processed_data['payment_id']}" + ) + + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.DEPOSIT, + amount_kopeks=processed_data['amount_kopeks'], + description=f"Пополнение через Tribute", + payment_method=PaymentMethod.TRIBUTE, + external_id=processed_data['payment_id'] + ) + + logger.info(f"✅ Обработан Tribute платеж: {processed_data['payment_id']}") + + return web.Response(status=200, text="OK") + + except Exception as e: + logger.error(f"Ошибка обработки Tribute webhook: {e}") + await db.rollback() + return web.Response(status=500, text="Internal error") + finally: + break + + except Exception as e: + logger.error(f"Ошибка в Tribute webhook: {e}") + return web.Response(status=500, text="Internal error") + + +async def handle_successful_payment(message: types.Message): + try: + payment = message.successful_payment + + payload_parts = payment.invoice_payload.split('_') + if len(payload_parts) >= 3 and payload_parts[0] == 'balance': + user_id = int(payload_parts[1]) + amount_kopeks = int(payload_parts[2]) + + async for db in get_db(): + try: + existing_transaction = await get_transaction_by_external_id( + db, payment.telegram_payment_charge_id, PaymentMethod.TELEGRAM_STARS + ) + + if existing_transaction: + logger.info(f"Stars платеж {payment.telegram_payment_charge_id} уже обработан") + return + + user = await get_user_by_id(db, user_id) + + if user: + await add_user_balance( + db, user, amount_kopeks, + f"Пополнение через Telegram Stars" + ) + + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.DEPOSIT, + amount_kopeks=amount_kopeks, + description=f"Пополнение через Telegram Stars", + payment_method=PaymentMethod.TELEGRAM_STARS, + external_id=payment.telegram_payment_charge_id + ) + + await message.answer( + f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!" + ) + + logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}") + + except Exception as e: + logger.error(f"Ошибка обработки Stars платежа: {e}") + await db.rollback() + finally: + break + + except Exception as e: + logger.error(f"Ошибка в обработчике Stars платежа: {e}") + + +async def handle_pre_checkout_query(pre_checkout_query: types.PreCheckoutQuery): + try: + await pre_checkout_query.answer(ok=True) + logger.info(f"Pre-checkout query принят: {pre_checkout_query.id}") + + except Exception as e: + logger.error(f"Ошибка в pre-checkout query: {e}") + await pre_checkout_query.answer(ok=False, error_message="Ошибка обработки платежа") \ No newline at end of file diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py new file mode 100644 index 00000000..3004eee5 --- /dev/null +++ b/app/keyboards/admin.py @@ -0,0 +1,529 @@ +from typing import List, Optional +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + +from app.localization.texts import get_texts + + +def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.ADMIN_USERS, callback_data="admin_users"), + InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions") + ], + [ + InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"), + InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") + ], + [ + InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring"), + InlineKeyboardButton(text=texts.ADMIN_REFERRALS, callback_data="admin_referrals") + ], + [ + InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"), + InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave") + ], + [ + InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") + ] + ]) + + +def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="👥 Все пользователи", callback_data="admin_users_list"), + InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search") + ], + [ + InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"), + InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + ] + ]) + + +def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📱 Все подписки", callback_data="admin_subs_list"), + InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring") + ], + [ + InlineKeyboardButton(text="⚙️ Настройки цен", callback_data="admin_subs_pricing"), + InlineKeyboardButton(text="🌍 Управление странами", callback_data="admin_subs_countries") + ], + [ + InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + ] + ]) + + +def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="🎫 Все промокоды", callback_data="admin_promo_list"), + InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create") + ], + [ + InlineKeyboardButton(text="📊 Статистика", callback_data="admin_promo_stats") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + ] + ]) + + +def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"promo_edit_{promo_id}"), + InlineKeyboardButton(text="🔄 Статус", callback_data=f"promo_toggle_{promo_id}") + ], + [ + InlineKeyboardButton(text="📊 Статистика", callback_data=f"promo_stats_{promo_id}"), + InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"promo_delete_{promo_id}") + ], + [ + InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_promo_list") + ] + ]) + + +def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📨 Всем пользователям", callback_data="admin_msg_all"), + InlineKeyboardButton(text="🎯 По подпискам", callback_data="admin_msg_by_sub") + ], + [ + InlineKeyboardButton(text="🔍 По критериям", callback_data="admin_msg_custom"), + InlineKeyboardButton(text="📋 История", callback_data="admin_msg_history") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + ] + ]) + + +def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"), + InlineKeyboardButton(text="⏸️ Остановить", callback_data="admin_mon_stop") + ], + [ + InlineKeyboardButton(text="📊 Статус", callback_data="admin_mon_status"), + InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs") + ], + [ + InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_mon_settings") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + ] + ]) + + +def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📊 Системная статистика", callback_data="admin_rw_system"), + InlineKeyboardButton(text="🖥️ Управление нодами", callback_data="admin_rw_nodes") + ], + [ + InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_rw_sync"), + InlineKeyboardButton(text="🌍 Управление сквадами", callback_data="admin_rw_squads") + ], + [ + InlineKeyboardButton(text="📈 Трафик", callback_data="admin_rw_traffic") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + ] + ]) + + +def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="👥 Пользователи", callback_data="admin_stats_users"), + InlineKeyboardButton(text="📱 Подписки", callback_data="admin_stats_subs") + ], + [ + InlineKeyboardButton(text="💰 Доходы", callback_data="admin_stats_revenue"), + InlineKeyboardButton(text="🤝 Рефералы", callback_data="admin_stats_referrals") + ], + [ + InlineKeyboardButton(text="📊 Общая сводка", callback_data="admin_stats_summary") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + ] + ]) + + +def get_user_management_keyboard(user_id: int, language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"), + InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_sub_{user_id}") + ], + [ + InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_stats_{user_id}"), + InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_trans_{user_id}") + ], + [ + InlineKeyboardButton(text="🚫 Заблокировать", callback_data=f"admin_user_block_{user_id}"), + InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list") + ] + ]) + + +def get_confirmation_keyboard( + confirm_action: str, + cancel_action: str = "admin_panel", + language: str = "ru" +) -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.YES, callback_data=confirm_action), + InlineKeyboardButton(text=texts.NO, callback_data=cancel_action) + ] + ]) + + +def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="💰 Баланс", callback_data="promo_type_balance"), + InlineKeyboardButton(text="📅 Дни подписки", callback_data="promo_type_days") + ], + [ + InlineKeyboardButton(text="🎁 Триал", callback_data="promo_type_trial") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes") + ] + ]) + + +def get_promocode_list_keyboard(promocodes: list, page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup: + keyboard = [] + + for promo in promocodes: + status_emoji = "✅" if promo.is_active else "❌" + type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫") + + keyboard.append([ + InlineKeyboardButton( + text=f"{status_emoji} {type_emoji} {promo.code}", + callback_data=f"promo_manage_{promo.id}" + ) + ]) + + if total_pages > 1: + pagination_row = [] + + if page > 1: + pagination_row.append( + InlineKeyboardButton(text="⬅️", callback_data=f"admin_promo_list_page_{page - 1}") + ) + + pagination_row.append( + InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page") + ) + + if page < total_pages: + pagination_row.append( + InlineKeyboardButton(text="➡️", callback_data=f"admin_promo_list_page_{page + 1}") + ) + + keyboard.append(pagination_row) + + keyboard.extend([ + [InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")] + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="👥 Всем", callback_data="broadcast_all"), + InlineKeyboardButton(text="📱 С подпиской", callback_data="broadcast_active") + ], + [ + InlineKeyboardButton(text="🎁 Триал", callback_data="broadcast_trial"), + InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub") + ], + [ + InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages") + ] + ]) + + +def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📅 Сегодня", callback_data="criteria_today"), + InlineKeyboardButton(text="📅 За неделю", callback_data="criteria_week") + ], + [ + InlineKeyboardButton(text="📅 За месяц", callback_data="criteria_month"), + InlineKeyboardButton(text="⚡ Активные сегодня", callback_data="criteria_active_today") + ], + [ + InlineKeyboardButton(text="💤 Неактивные 7+ дней", callback_data="criteria_inactive_week"), + InlineKeyboardButton(text="💤 Неактивные 30+ дней", callback_data="criteria_inactive_month") + ], + [ + InlineKeyboardButton(text="🤝 Через рефералов", callback_data="criteria_referrals"), + InlineKeyboardButton(text="🎫 Использовали промокоды", callback_data="criteria_promocodes") + ], + [ + InlineKeyboardButton(text="🎯 Прямая регистрация", callback_data="criteria_direct") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages") + ] + ]) + + +def get_broadcast_history_keyboard(page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup: + keyboard = [] + + if total_pages > 1: + pagination_row = [] + + if page > 1: + pagination_row.append( + InlineKeyboardButton(text="⬅️", callback_data=f"admin_msg_history_page_{page - 1}") + ) + + pagination_row.append( + InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page") + ) + + if page < total_pages: + pagination_row.append( + InlineKeyboardButton(text="➡️", callback_data=f"admin_msg_history_page_{page + 1}") + ) + + keyboard.append(pagination_row) + + keyboard.extend([ + [InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_msg_history")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")] + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + +def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="🔄 Синхронизировать всех", callback_data="sync_all_users"), + InlineKeyboardButton(text="➕ Только новых", callback_data="sync_new_users") + ], + [ + InlineKeyboardButton(text="📊 Обновить данные", callback_data="sync_update_data") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave") + ] + ]) + + +def get_period_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📅 Сегодня", callback_data="period_today"), + InlineKeyboardButton(text="📅 Вчера", callback_data="period_yesterday") + ], + [ + InlineKeyboardButton(text="📅 Неделя", callback_data="period_week"), + InlineKeyboardButton(text="📅 Месяц", callback_data="period_month") + ], + [ + InlineKeyboardButton(text="📅 Все время", callback_data="period_all") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics") + ] + ]) + + +def get_node_management_keyboard(node_uuid: str, language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="▶️ Включить", callback_data=f"node_enable_{node_uuid}"), + InlineKeyboardButton(text="⏸️ Отключить", callback_data=f"node_disable_{node_uuid}") + ], + [ + InlineKeyboardButton(text="🔄 Перезагрузить", callback_data=f"node_restart_{node_uuid}"), + InlineKeyboardButton(text="📊 Статистика", callback_data=f"node_stats_{node_uuid}") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_nodes") + ] + ]) + +def get_squad_management_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="👥 Добавить всех пользователей", callback_data=f"squad_add_users_{squad_uuid}"), + ], + [ + InlineKeyboardButton(text="❌ Удалить всех пользователей", callback_data=f"squad_remove_users_{squad_uuid}"), + ], + [ + InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"squad_edit_{squad_uuid}"), + InlineKeyboardButton(text="🗑️ Удалить сквад", callback_data=f"squad_delete_{squad_uuid}") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_squads") + ] + ]) + +def get_squad_edit_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="🔧 Изменить инбаунды", callback_data=f"squad_edit_inbounds_{squad_uuid}"), + ], + [ + InlineKeyboardButton(text="✏️ Переименовать", callback_data=f"squad_rename_{squad_uuid}"), + ], + [ + InlineKeyboardButton(text="⬅️ Назад к сквадам", callback_data=f"admin_squad_manage_{squad_uuid}") + ] + ]) + +def get_monitoring_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"), + InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop") + ], + [ + InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"), + InlineKeyboardButton(text="📝 Логи", callback_data="admin_mon_logs") + ], + [ + InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"), + InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics") + ], + [ + InlineKeyboardButton(text="⬅️ Назад в админку", callback_data="admin_panel") + ] + ]) + +def get_monitoring_logs_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"), + InlineKeyboardButton(text="🗑️ Очистить старые", callback_data="admin_mon_clear_logs") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring") + ] + ]) + +def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"), + InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync") + ], + [ + InlineKeyboardButton(text="➕ Добавить сервер", callback_data="admin_servers_add"), + InlineKeyboardButton(text="📊 Статистика", callback_data="admin_servers_stats") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions") + ] + ]) + + +def get_server_edit_keyboard(server_id: int, is_available: bool, language: str = "ru") -> InlineKeyboardMarkup: + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server_id}"), + InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server_id}") + ], + [ + InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server_id}"), + InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server_id}") + ], + [ + InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server_id}") + ], + [ + InlineKeyboardButton( + text="❌ Отключить" if is_available else "✅ Включить", + callback_data=f"admin_server_toggle_{server_id}" + ) + ], + [ + InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server_id}"), + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") + ] + ]) + + +def get_admin_pagination_keyboard( + current_page: int, + total_pages: int, + callback_prefix: str, + back_callback: str = "admin_panel", + language: str = "ru" +) -> InlineKeyboardMarkup: + keyboard = [] + + if total_pages > 1: + row = [] + + if current_page > 1: + row.append(InlineKeyboardButton( + text="⬅️", + callback_data=f"{callback_prefix}_page_{current_page - 1}" + )) + + row.append(InlineKeyboardButton( + text=f"{current_page}/{total_pages}", + callback_data="current_page" + )) + + if current_page < total_pages: + row.append(InlineKeyboardButton( + text="➡️", + callback_data=f"{callback_prefix}_page_{current_page + 1}" + )) + + keyboard.append(row) + + keyboard.append([ + InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback) + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) \ No newline at end of file diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py new file mode 100644 index 00000000..9361e42c --- /dev/null +++ b/app/keyboards/inline.py @@ -0,0 +1,594 @@ +from typing import List, Optional +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + +from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES +from app.localization.texts import get_texts + + +def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.RULES_ACCEPT, callback_data="rules_accept"), + InlineKeyboardButton(text=texts.RULES_DECLINE, callback_data="rules_decline") + ] + ]) + + +def get_main_menu_keyboard( + language: str = "ru", + is_admin: bool = False, + has_had_paid_subscription: bool = False, + has_active_subscription: bool = False, + subscription_is_active: bool = False +) -> InlineKeyboardMarkup: + texts = get_texts(language) + + if settings.DEBUG: + print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}") + + keyboard = [ + [ + InlineKeyboardButton(text=texts.MENU_BALANCE, callback_data="menu_balance"), + InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription") + ] + ] + + + show_trial = not has_had_paid_subscription and not has_active_subscription + + show_buy = not has_active_subscription or not subscription_is_active + + subscription_buttons = [] + + if show_trial: + subscription_buttons.append( + InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial") + ) + + if show_buy: + subscription_buttons.append( + InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy") + ) + + if subscription_buttons: + if len(subscription_buttons) == 2: + keyboard.append(subscription_buttons) + else: + keyboard.append([subscription_buttons[0]]) + + keyboard.extend([ + [ + InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"), + InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals") + ], + [ + InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"), + InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules") + ] + ]) + + if settings.DEBUG: + print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}") + + if is_admin: + if settings.DEBUG: + print("DEBUG KEYBOARD: Админ кнопка ДОБАВЛЕНА!") + keyboard.append([ + InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data="admin_panel") + ]) + else: + if settings.DEBUG: + print("DEBUG KEYBOARD: Админ кнопка НЕ добавлена") + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_back_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]) + + +def get_subscription_keyboard( + language: str = "ru", + has_subscription: bool = False, + is_trial: bool = False, + subscription=None +) -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + if has_subscription: + if is_trial: + keyboard.append([ + InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade") + ]) + else: + row1 = [] + row2 = [] + row3 = [] + row4 = [] + + row1.append(InlineKeyboardButton(text="🌍 Добавить страны", callback_data="subscription_add_countries")) + + if subscription and subscription.traffic_limit_gb > 0: + row1.append(InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic")) + + row2.append(InlineKeyboardButton(text="📱 Добавить устройства", callback_data="subscription_add_devices")) + + if subscription and subscription.days_left <= 3: + row2.append(InlineKeyboardButton(text="⏰ Продлить", callback_data="subscription_extend")) + + row3.append(InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")) + + row3.append(InlineKeyboardButton(text="💳 Автоплатеж", callback_data="subscription_autopay")) + + row4.append(InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data="subscription_reset_devices")) + + if row1: + keyboard.append(row1) + if row2: + keyboard.append(row2) + if row3: + keyboard.append(row3) + if row4: + keyboard.append(row4) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="🎁 Активировать", callback_data="trial_activate"), + InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") + ] + ]) + + +def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + periods = [ + (14, texts.PERIOD_14_DAYS), + (30, texts.PERIOD_30_DAYS), + (60, texts.PERIOD_60_DAYS), + (90, texts.PERIOD_90_DAYS), + (180, texts.PERIOD_180_DAYS), + (360, texts.PERIOD_360_DAYS) + ] + + for days, text in periods: + keyboard.append([ + InlineKeyboardButton(text=text, callback_data=f"period_{days}") + ]) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + packages = [ + (5, texts.TRAFFIC_5GB), + (10, texts.TRAFFIC_10GB), + (25, texts.TRAFFIC_25GB), + (50, texts.TRAFFIC_50GB), + (100, texts.TRAFFIC_100GB), + (250, texts.TRAFFIC_250GB), + (0, texts.TRAFFIC_UNLIMITED) # 0 = безлимит + ] + + for gb, text in packages: + keyboard.append([ + InlineKeyboardButton(text=text, callback_data=f"traffic_{gb}") + ]) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_countries_keyboard(countries: List[dict], selected: List[str], language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + for country in countries: + if not country.get('is_available', True): + continue + + emoji = "✅" if country['uuid'] in selected else "⚪" + + if country['price_kopeks'] > 0: + price_text = f" (+{texts.format_price(country['price_kopeks'])})" + else: + price_text = " (Бесплатно)" + + keyboard.append([ + InlineKeyboardButton( + text=f"{emoji} {country['name']}{price_text}", + callback_data=f"country_{country['uuid']}" + ) + ]) + + if not keyboard: + keyboard.append([ + InlineKeyboardButton( + text="❌ Нет доступных серверов", + callback_data="no_servers" + ) + ]) + + keyboard.extend([ + [InlineKeyboardButton(text="✅ Продолжить", callback_data="countries_continue")], + [InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")] + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + for devices in range(1, 6): + price = (devices - 1) * settings.PRICE_PER_DEVICE + price_text = f" (+{texts.format_price(price)})" if price > 0 else "" + emoji = "✅" if devices == current else "⚪" + + keyboard.append([ + InlineKeyboardButton( + text=f"{emoji} {devices} устройство{_get_device_suffix(devices)}{price_text}", + callback_data=f"devices_{devices}" + ) + ]) + + keyboard.extend([ + [InlineKeyboardButton(text="✅ Продолжить", callback_data="devices_continue")], + [InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")] + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_subscription_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.CONFIRM, callback_data="subscription_confirm"), + InlineKeyboardButton(text=texts.CANCEL, callback_data="subscription_cancel") + ] + ]) + + +def get_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + keyboard.append([ + InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data="balance_history"), + InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data="balance_topup") + ]) + + if settings.TRIBUTE_ENABLED: + keyboard.append([ + InlineKeyboardButton(text="💳 Быстрое пополнение", callback_data="tribute_quick_pay") + ]) + + keyboard.append([ + InlineKeyboardButton(text=texts.BALANCE_SUPPORT_REQUEST, callback_data="balance_support") + ]) + + # Назад + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + if settings.TELEGRAM_STARS_ENABLED: + keyboard.append([ + InlineKeyboardButton(text=texts.TOP_UP_STARS, callback_data=f"pay_stars_{amount_kopeks}") + ]) + + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.CREATE_INVITE, callback_data="referral_create_invite") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") + ] + ]) + + +def get_support_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.CONTACT_SUPPORT, url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") + ] + ]) + + +def get_pagination_keyboard( + current_page: int, + total_pages: int, + callback_prefix: str, + language: str = "ru" +) -> List[List[InlineKeyboardButton]]: + keyboard = [] + + if total_pages > 1: + row = [] + + if current_page > 1: + row.append(InlineKeyboardButton( + text="⬅️", + callback_data=f"{callback_prefix}_page_{current_page - 1}" + )) + + row.append(InlineKeyboardButton( + text=f"{current_page}/{total_pages}", + callback_data="current_page" + )) + + if current_page < total_pages: + row.append(InlineKeyboardButton( + text="➡️", + callback_data=f"{callback_prefix}_page_{current_page + 1}" + )) + + keyboard.append(row) + + return keyboard + + +def _get_device_suffix(count: int) -> str: + if count == 1: + return "" + elif 2 <= count <= 4: + return "а" + else: + return "" + + +def get_confirmation_keyboard( + confirm_data: str, + cancel_data: str = "cancel", + language: str = "ru" +) -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.YES, callback_data=confirm_data), + InlineKeyboardButton(text=texts.NO, callback_data=cancel_data) + ] + ]) + + +def get_autopay_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="✅ Включить", callback_data="autopay_enable"), + InlineKeyboardButton(text="❌ Выключить", callback_data="autopay_disable") + ], + [ + InlineKeyboardButton(text="⚙️ Настроить дни", callback_data="autopay_set_days") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription") + ] + ]) + + +def get_autopay_days_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + keyboard = [] + + for days in [1, 3, 7, 14]: + keyboard.append([ + InlineKeyboardButton( + text=f"{days} дн{_get_days_suffix(days)}", + callback_data=f"autopay_days_{days}" + ) + ]) + + keyboard.append([ + InlineKeyboardButton(text="⬅️ Назад", callback_data="subscription_autopay") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def _get_days_suffix(days: int) -> str: + if days == 1: + return "ь" + elif 2 <= days <= 4: + return "я" + else: + return "ей" + + + +def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + periods = [ + (14, f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"), + (30, f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"), + (60, f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"), + (90, f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}") + ] + + for days, text in periods: + keyboard.append([ + InlineKeyboardButton(text=text, callback_data=f"extend_period_{days}") + ]) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_add_traffic_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + packages = [ + (5, f"📊 +5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"), + (10, f"📊 +10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"), + (25, f"📊 +25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"), + (50, f"📊 +50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"), + (100, f"📊 +100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"), + (0, f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}") + ] + + for gb, text in packages: + keyboard.append([ + InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}") + ]) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_add_devices_keyboard(current_devices: int, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [] + + max_devices = 10 + + for add_count in range(1, min(6, max_devices - current_devices + 1)): + price = add_count * settings.PRICE_PER_DEVICE + total_devices = current_devices + add_count + + keyboard.append([ + InlineKeyboardButton( + text=f"📱 +{add_count} устройство{_get_device_suffix(add_count)} (итого: {total_devices}) - {settings.format_price(price)}", + callback_data=f"add_devices_{add_count}" + ) + ]) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=f"✅ Сбросить за {settings.format_price(price_kopeks)}", + callback_data="confirm_reset_traffic" + ) + ], + [ + InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription") + ] + ]) + +def get_manage_countries_keyboard( + countries: List[dict], + selected: List[str], + current_subscription_countries: List[str], + language: str = "ru" +) -> InlineKeyboardMarkup: + """Клавиатура для управления странами подписки БЕЗ состояний FSM""" + texts = get_texts(language) + keyboard = [] + + for country in countries: + if not country.get('is_available', True): + continue + + is_currently_connected = country['uuid'] in current_subscription_countries + is_selected = country['uuid'] in selected + + if is_currently_connected: + if is_selected: + emoji = "✅" + status = "" + else: + emoji = "➖" + status = " (отключить БЕСПЛАТНО)" + else: + if is_selected: + emoji = "➕" + price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)" + status = price_text + else: + emoji = "⚪" + price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)" + status = price_text + + keyboard.append([ + InlineKeyboardButton( + text=f"{emoji} {country['name']}{status}", + callback_data=f"country_manage_{country['uuid']}" + ) + ]) + + if not keyboard: + keyboard.append([ + InlineKeyboardButton( + text="❌ Нет доступных серверов", + callback_data="no_servers" + ) + ]) + + added = [c for c in selected if c not in current_subscription_countries] + removed = [c for c in current_subscription_countries if c not in selected] + + apply_text = "✅ Применить изменения" + if added or removed: + changes_count = len(added) + len(removed) + apply_text += f" ({changes_count})" + + keyboard.extend([ + [InlineKeyboardButton(text=apply_text, callback_data="countries_apply")], + [InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")] + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + diff --git a/app/keyboards/reply.py b/app/keyboards/reply.py new file mode 100644 index 00000000..d7f74dfc --- /dev/null +++ b/app/keyboards/reply.py @@ -0,0 +1,116 @@ +from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove +from typing import List + +from app.localization.texts import get_texts + + +def get_main_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: + texts = get_texts(language) + + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton(text=texts.MENU_BALANCE), + KeyboardButton(text=texts.MENU_SUBSCRIPTION) + ], + [ + KeyboardButton(text=texts.MENU_PROMOCODE), + KeyboardButton(text=texts.MENU_REFERRALS) + ], + [ + KeyboardButton(text=texts.MENU_SUPPORT), + KeyboardButton(text=texts.MENU_RULES) + ] + ], + resize_keyboard=True, + one_time_keyboard=False + ) + + +def get_admin_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: + texts = get_texts(language) + + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton(text="👥 Пользователи"), + KeyboardButton(text="📱 Подписки") + ], + [ + KeyboardButton(text="🎫 Промокоды"), + KeyboardButton(text="📨 Рассылки") + ], + [ + KeyboardButton(text="📊 Статистика"), + KeyboardButton(text="🔧 Мониторинг") + ], + [ + KeyboardButton(text="🏠 Главное меню") + ] + ], + resize_keyboard=True, + one_time_keyboard=False + ) + + +def get_cancel_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: + texts = get_texts(language) + + return ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text=texts.CANCEL)] + ], + resize_keyboard=True, + one_time_keyboard=True + ) + + +def get_confirmation_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: + texts = get_texts(language) + + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton(text=texts.YES), + KeyboardButton(text=texts.NO) + ] + ], + resize_keyboard=True, + one_time_keyboard=True + ) + + +def get_skip_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="⏭️ Пропустить")] + ], + resize_keyboard=True, + one_time_keyboard=True + ) + + +def remove_keyboard() -> ReplyKeyboardRemove: + return ReplyKeyboardRemove() + + +def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="📱 Отправить контакт", request_contact=True)], + [KeyboardButton(text="❌ Отмена")] + ], + resize_keyboard=True, + one_time_keyboard=True + ) + + +def get_location_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="📍 Отправить геолокацию", request_location=True)], + [KeyboardButton(text="❌ Отмена")] + ], + resize_keyboard=True, + one_time_keyboard=True + ) \ No newline at end of file diff --git a/app/localization/texts.py b/app/localization/texts.py new file mode 100644 index 00000000..d8e57a5a --- /dev/null +++ b/app/localization/texts.py @@ -0,0 +1,488 @@ +import asyncio +from typing import Dict, Any +from app.config import settings + +_cached_rules = {} + +def _get_default_rules(language: str = "ru") -> str: + if language == "en": + return """ +🔒 Service Usage Rules + +1. It is forbidden to use the service for illegal activities +2. Copyright infringement is prohibited +3. Spam and malware distribution are prohibited +4. Using the service for DDoS attacks is prohibited +5. One account - one user +6. Refunds are made only in exceptional cases +7. Administration reserves the right to block an account for violating the rules + +By accepting the rules, you agree to comply with them. +""" + else: + return """ +📋 Правила использования сервиса + +1. Запрещается использование сервиса для незаконной деятельности +2. Запрещается нарушение авторских прав +3. Запрещается спам и рассылка вредоносного ПО +4. Запрещается использование сервиса для DDoS атак +5. Один аккаунт - один пользователь +6. Возврат средств производится только в исключительных случаях +7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил + +Принимая правила, вы соглашаетесь соблюдать их. +""" + +class Texts: + def __init__(self, language: str = "ru"): + self.language = language + + @property + def RULES_TEXT(self) -> str: + if self.language in _cached_rules: + return _cached_rules[self.language] + + return _get_default_rules(self.language) + + BACK = "⬅️ Назад" + CANCEL = "❌ Отмена" + CONFIRM = "✅ Подтвердить" + CONTINUE = "➡️ Продолжить" + YES = "✅ Да" + NO = "❌ Нет" + LOADING = "⏳ Загрузка..." + ERROR = "❌ Произошла ошибка" + SUCCESS = "✅ Успешно" + + @staticmethod + def format_price(kopeks: int) -> str: + return f"{kopeks / 100:.2f} ₽" + + @staticmethod + def format_traffic(gb: float) -> str: + if gb == 0: + return "∞ (безлимит)" + elif gb >= 1024: + return f"{gb/1024:.1f} ТБ" + else: + return f"{gb:.0f} ГБ" + + +class RussianTexts(Texts): + + def __init__(self): + super().__init__("ru") + + WELCOME = """ +🎉 Добро пожаловать в VPN сервис! + +Наш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений. + +🔐 Преимущества: +• Высокая скорость подключения +• Серверы в разных странах +• Надежная защита данных +• Круглосуточная поддержка + +Для начала работы выберите язык интерфейса: +""" + + LANGUAGE_SELECTED = "🌐 Язык интерфейса установлен: Русский" + + RULES_ACCEPT = "✅ Принимаю правила" + RULES_DECLINE = "❌ Не принимаю" + RULES_REQUIRED = "❗️ Для использования сервиса необходимо принять правила!" + + REFERRAL_CODE_QUESTION = """ +🤝 У вас есть реферальный код от друга? + +Если у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус! + +Введите код или нажмите "Пропустить": +""" + + REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки." + REFERRAL_CODE_INVALID = "❌ Неверный реферальный код" + REFERRAL_CODE_SKIP = "⏭️ Пропустить" + + MAIN_MENU = """ +👤 {user_name} + +💰 Баланс: {balance} +📱 Подписка: {subscription_status} + +Выберите действие: +""" + + MENU_BALANCE = "💰 Баланс" + MENU_SUBSCRIPTION = "📱 Подписка" + MENU_TRIAL = "🎁 Тестовая подписка" + MENU_BUY_SUBSCRIPTION = "💎 Купить подписку" + MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку" + MENU_PROMOCODE = "🎫 Промокод" + MENU_REFERRALS = "🤝 Рефералы" + MENU_SUPPORT = "🛠️ Техподдержка" + MENU_RULES = "📋 Правила сервиса" + MENU_LANGUAGE = "🌐 Язык" + MENU_ADMIN = "⚙️ Админ-панель" + + SUBSCRIPTION_NONE = "❌ Нет активной подписки" + SUBSCRIPTION_TRIAL = "🎁 Тестовая подписка" + SUBSCRIPTION_ACTIVE = "✅ Активна" + SUBSCRIPTION_EXPIRED = "⏰ Истекла" + + SUBSCRIPTION_INFO = """ +📱 Информация о подписке + +📊 Статус: {status} +🎭 Тип: {type} +📅 Действует до: {end_date} +⏰ Осталось дней: {days_left} + +📈 Трафик: {traffic_used} / {traffic_limit} +🌍 Серверы: {countries_count} стран +📱 Устройства: {devices_used} / {devices_limit} + +💳 Автоплатеж: {autopay_status} +""" + + TRIAL_AVAILABLE = """ +🎁 Тестовая подписка + +Вы можете получить бесплатную тестовую подписку: + +⏰ Период: {days} дней +📈 Трафик: {traffic} ГБ +📱 Устройства: {devices} шт. +🌍 Сервер: 1 страна + +Активировать тестовую подписку? +""" + + TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!" + TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована" + + BUY_SUBSCRIPTION_START = """ +💎 Настройка подписки + +Давайте настроим вашу подписку под ваши потребности. + +Сначала выберите период подписки: +""" + + SELECT_PERIOD = "Выберите период:" + SELECT_TRAFFIC = "Выберите пакет трафика:" + SELECT_COUNTRIES = "Выберите страны:" + SELECT_DEVICES = "Количество устройств:" + + PERIOD_14_DAYS = f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}" + PERIOD_30_DAYS = f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}" + PERIOD_60_DAYS = f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}" + PERIOD_90_DAYS = f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}" + PERIOD_180_DAYS = f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}" + PERIOD_360_DAYS = f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}" + + TRAFFIC_5GB = f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}" + TRAFFIC_10GB = f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}" + TRAFFIC_25GB = f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}" + TRAFFIC_50GB = f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}" + TRAFFIC_100GB = f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}" + TRAFFIC_250GB = f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}" + TRAFFIC_UNLIMITED = f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}" + + SUBSCRIPTION_SUMMARY = """ +📋 Итоговая конфигурация + +📅 Период: {period} дней +📈 Трафик: {traffic} +🌍 Страны: {countries} +📱 Устройства: {devices} + +💰 Итого к оплате: {total_price} + +Подтвердить покупку? +""" + + INSUFFICIENT_BALANCE = "❌ Недостаточно средств на балансе. Пополните баланс и попробуйте снова." + SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!" + + BALANCE_INFO = """ +💰 Баланс: {balance} + +Выберите действие: +""" + + BALANCE_HISTORY = "📊 История операций" + BALANCE_TOP_UP = "💳 Пополнить" + BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку" + + TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):" + TOP_UP_METHODS = """ +💳 Выберите способ оплаты + +Сумма: {amount} +""" + + TOP_UP_STARS = "⭐ Telegram Stars" + TOP_UP_TRIBUTE = "💎 Банковская карта" + + PROMOCODE_ENTER = "🎫 Введите промокод:" + PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}" + PROMOCODE_INVALID = "❌ Неверный промокод" + PROMOCODE_EXPIRED = "❌ Промокод истек" + PROMOCODE_USED = "❌ Промокод уже использован" + + REFERRAL_INFO = """ +🤝 Реферальная программа + +👥 Приглашено: {referrals_count} друзей +💰 Заработано: {earned_amount} + +🔗 Ваша реферальная ссылка: +{referral_link} + +🎫 Ваш промокод: +{referral_code} + +💰 Условия: +• За каждого друга: {registration_bonus} +• Процент с пополнений: {commission_percent}% +""" + + REFERRAL_INVITE_MESSAGE = """ +🎯 Приглашение в VPN сервис + +Привет! Приглашаю тебя в отличный VPN сервис! + +🎁 По моей ссылке ты получишь бонус: {bonus} + +🔗 Переходи: {link} +🎫 Или используй промокод: {code} + +💪 Быстро, надежно, недорого! +""" + + CREATE_INVITE = "📝 Создать приглашение" + + TRIAL_ENDING_SOON = """ +🎁 Тестовая подписка скоро закончится! + +Ваша тестовая подписка истекает через 2 часа. + +💎 Не хотите остаться без VPN? +Переходите на полную подписку! + +🔥 Специальное предложение: +• 30 дней всего за {price} +• Безлимитный трафик +• Все серверы доступны +• Поддержка до 3 устройств + +⚡️ Успейте оформить до окончания тестового периода! +""" + + SUBSCRIPTION_EXPIRING_PAID = """ +⚠️ Подписка истекает через {days} дней! + +Ваша платная подписка истекает {end_date}. + +💳 Автоплатеж: {autopay_status} + +{action_text} +""" + + AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически" + AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!" + + SUBSCRIPTION_EXPIRED = """ +❌ Подписка истекла + +Ваша подписка истекла. Для восстановления доступа продлите подписку. + +🔧 Доступ к серверам заблокирован до продления. +""" + + AUTOPAY_SUCCESS = """ +✅ Автоплатеж выполнен + +Ваша подписка автоматически продлена на {days} дней. +Списано с баланса: {amount} + +Новая дата окончания: {new_end_date} +""" + + AUTOPAY_FAILED = """ +❌ Ошибка автоплатежа + +Не удалось списать средства для продления подписки. + +💰 Ваш баланс: {balance} +💳 Требуется: {required} + +Пополните баланс и продлите подписку вручную. +""" + + SUPPORT_INFO = f""" +🛠️ Техническая поддержка + +По всем вопросам обращайтесь к нашей поддержке: + +👤 {settings.SUPPORT_USERNAME} + +Мы поможем с: +• Настройкой подключения +• Решением технических проблем +• Вопросами по оплате +• Другими вопросами + +⏰ Время ответа: обычно в течение 1-2 часов +""" + + CONTACT_SUPPORT = "💬 Написать в поддержку" + + ADMIN_PANEL = """ +⚙️ Административная панель + +Выберите раздел для управления: +""" + + ADMIN_USERS = "👥 Пользователи" + ADMIN_SUBSCRIPTIONS = "📱 Подписки" + ADMIN_PROMOCODES = "🎫 Промокоды" + ADMIN_MESSAGES = "📨 Рассылки" + ADMIN_MONITORING = "🔍 Мониторинг" + ADMIN_REFERRALS = "🤝 Рефералы" + ADMIN_RULES = "📋 Правила" + ADMIN_REMNAWAVE = "🖥️ RemnaWave" + ADMIN_STATISTICS = "📊 Статистика" + + ACCESS_DENIED = "❌ Доступ запрещен" + USER_NOT_FOUND = "❌ Пользователь не найден" + SUBSCRIPTION_NOT_FOUND = "❌ Подписка не найдена" + INVALID_AMOUNT = "❌ Неверная сумма" + OPERATION_CANCELLED = "❌ Операция отменена" + + SUBSCRIPTION_EXPIRING = """ +⚠️ Подписка истекает! + +Ваша подписка истекает через {days} дней. + +Не забудьте продлить подписку, чтобы не потерять доступ к серверам. +""" + + SUBSCRIPTION_EXPIRED = """ +❌ Подписка истекла + +Ваша подписка истекла. Для восстановления доступа продлите подписку. +""" + + AUTOPAY_SUCCESS = """ +✅ Автоплатеж выполнен + +Ваша подписка автоматически продлена на {days} дней. +Списано с баланса: {amount} +""" + + AUTOPAY_FAILED = """ +❌ Ошибка автоплатежа + +Не удалось списать средства для продления подписки. +Недостаточно средств на балансе: {balance} +Требуется: {required} + +Пополните баланс и продлите подписку вручную. +""" + + +class EnglishTexts(Texts): + + def __init__(self): + super().__init__("en") + + WELCOME = """ +🎉 Welcome to VPN Service! + +Our service provides fast and secure internet access without restrictions. + +🔐 Advantages: +• High connection speed +• Servers in different countries +• Reliable data protection +• 24/7 support + +To get started, select interface language: +""" + + LANGUAGE_SELECTED = "🌐 Interface language set: English" + + BACK = "⬅️ Back" + CANCEL = "❌ Cancel" + CONFIRM = "✅ Confirm" + CONTINUE = "➡️ Continue" + YES = "✅ Yes" + NO = "❌ No" + + MENU_BALANCE = "💰 Balance" + MENU_SUBSCRIPTION = "📱 Subscription" + MENU_TRIAL = "🎁 Trial subscription" + + +LANGUAGES = { + "ru": RussianTexts, + "en": EnglishTexts +} + + +def get_texts(language: str = "ru") -> Texts: + return LANGUAGES.get(language, RussianTexts)() + +async def get_rules_from_db(language: str = "ru") -> str: + try: + from app.database.database import get_db + from app.database.crud.rules import get_current_rules_content + + async for db in get_db(): + rules = await get_current_rules_content(db, language) + if rules: + _cached_rules[language] = rules + return rules + break + + except Exception as e: + print(f"Ошибка получения правил из БД: {e}") + + default_rules = _get_default_rules(language) + _cached_rules[language] = default_rules + return default_rules + +def get_rules_sync(language: str = "ru") -> str: + try: + if language in _cached_rules: + return _cached_rules[language] + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + rules = loop.run_until_complete(get_rules_from_db(language)) + loop.close() + return rules + + except Exception as e: + print(f"Ошибка получения правил: {e}") + return _get_default_rules(language) + +async def refresh_rules_cache(language: str = "ru"): + try: + if language in _cached_rules: + del _cached_rules[language] + + await get_rules_from_db(language) + print(f"✅ Кеш правил для языка {language} обновлен") + + except Exception as e: + print(f"Ошибка обновления кеша правил: {e}") + +def clear_rules_cache(): + global _cached_rules + _cached_rules.clear() + print("✅ Кеш правил очищен") \ No newline at end of file diff --git a/app/middlewares/auth.py b/app/middlewares/auth.py new file mode 100644 index 00000000..dd1b00c6 --- /dev/null +++ b/app/middlewares/auth.py @@ -0,0 +1,93 @@ +import logging +from typing import Callable, Dict, Any, Awaitable +from aiogram import BaseMiddleware +from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser +from aiogram.fsm.context import FSMContext + +from app.config import settings +from app.database.database import get_db +from app.database.crud.user import get_user_by_telegram_id, create_user +from app.states import RegistrationStates + +logger = logging.getLogger(__name__) + + +class AuthMiddleware(BaseMiddleware): + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + + user: TgUser = None + if isinstance(event, (Message, CallbackQuery)): + user = event.from_user + + if not user: + return await handler(event, data) + + if user.is_bot: + return await handler(event, data) + + async for db in get_db(): + try: + db_user = await get_user_by_telegram_id(db, user.id) + + if not db_user: + state: FSMContext = data.get('state') + current_state = None + + if state: + current_state = await state.get_state() + + registration_states = [ + RegistrationStates.waiting_for_rules_accept, + RegistrationStates.waiting_for_referral_code + ] + + is_registration_process = ( + (isinstance(event, Message) and event.text and event.text.startswith('/start')) + or (isinstance(event, CallbackQuery) and current_state and + any(str(state) in str(current_state) for state in registration_states)) + or (isinstance(event, CallbackQuery) and event.data and + (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])) + ) + + if is_registration_process: + logger.info(f"🔓 Пропускаем пользователя {user.id} в процессе регистрации") + data['db'] = db + data['db_user'] = None + data['is_admin'] = False + return await handler(event, data) + else: + if isinstance(event, Message): + await event.answer( + "◀️ Для начала работы необходимо выполнить команду /start" + ) + elif isinstance(event, CallbackQuery): + await event.answer( + "◀️ Необходимо начать с команды /start", + show_alert=True + ) + logger.info(f"🚫 Заблокирован незарегистрированный пользователь {user.id}") + return + else: + from datetime import datetime + db_user.last_activity = datetime.utcnow() + await db.commit() + + data['db'] = db + data['db_user'] = db_user + data['is_admin'] = settings.is_admin(user.id) + + return await handler(event, data) + + except Exception as e: + logger.error(f"Ошибка в AuthMiddleware: {e}") + logger.error(f"Event type: {type(event)}") + if hasattr(event, 'data'): + logger.error(f"Callback data: {event.data}") + await db.rollback() + raise \ No newline at end of file diff --git a/app/middlewares/logging.py b/app/middlewares/logging.py new file mode 100644 index 00000000..d7cd75b5 --- /dev/null +++ b/app/middlewares/logging.py @@ -0,0 +1,42 @@ +import logging +import time +from typing import Callable, Dict, Any, Awaitable +from aiogram import BaseMiddleware +from aiogram.types import Message, CallbackQuery, TelegramObject + +logger = logging.getLogger(__name__) + + +class LoggingMiddleware(BaseMiddleware): + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + + start_time = time.time() + + try: + if isinstance(event, Message): + user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}" + text = event.text or event.caption or "[медиа]" + logger.info(f"📩 Сообщение от {user_info}: {text}") + + elif isinstance(event, CallbackQuery): + user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}" + logger.info(f"🔘 Callback от {user_info}: {event.data}") + + result = await handler(event, data) + + execution_time = time.time() - start_time + if execution_time > 1.0: + logger.warning(f"⏱️ Медленная операция: {execution_time:.2f}s") + + return result + + except Exception as e: + execution_time = time.time() - start_time + logger.error(f"❌ Ошибка при обработке события за {execution_time:.2f}s: {e}") + raise \ No newline at end of file diff --git a/app/middlewares/throttling.py b/app/middlewares/throttling.py new file mode 100644 index 00000000..a515c24a --- /dev/null +++ b/app/middlewares/throttling.py @@ -0,0 +1,53 @@ +import asyncio +import logging +import time +from typing import Callable, Dict, Any, Awaitable +from aiogram import BaseMiddleware +from aiogram.types import Message, CallbackQuery, TelegramObject + +logger = logging.getLogger(__name__) + + +class ThrottlingMiddleware(BaseMiddleware): + + def __init__(self, rate_limit: float = 0.5): + self.rate_limit = rate_limit + self.user_buckets: Dict[int, float] = {} + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + + user_id = None + if isinstance(event, (Message, CallbackQuery)): + user_id = event.from_user.id + + if not user_id: + return await handler(event, data) + + now = time.time() + last_call = self.user_buckets.get(user_id, 0) + + if now - last_call < self.rate_limit: + logger.warning(f"🚫 Throttling для пользователя {user_id}") + + if isinstance(event, Message): + await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!") + elif isinstance(event, CallbackQuery): + await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True) + + return + + self.user_buckets[user_id] = now + + cleanup_threshold = now - 60 + self.user_buckets = { + uid: timestamp + for uid, timestamp in self.user_buckets.items() + if timestamp > cleanup_threshold + } + + return await handler(event, data) \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 00000000..6d83dabc --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,3 @@ +""" +Сервисы бизнес-логики +""" \ No newline at end of file diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py new file mode 100644 index 00000000..8f564841 --- /dev/null +++ b/app/services/monitoring_service.py @@ -0,0 +1,621 @@ +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional, Set +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from sqlalchemy.orm import selectinload + +from app.config import settings +from app.database.database import get_db +from app.database.crud.subscription import ( + get_expired_subscriptions, get_expiring_subscriptions, + get_subscriptions_for_autopay, deactivate_subscription, + extend_subscription +) +from app.database.crud.user import ( + get_user_by_id, get_inactive_users, delete_user, + subtract_user_balance +) +from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User +from app.services.subscription_service import SubscriptionService +from app.services.payment_service import PaymentService +from app.localization.texts import get_texts + +logger = logging.getLogger(__name__) + + +class MonitoringService: + + def __init__(self, bot=None): + self.is_running = False + self.subscription_service = SubscriptionService() + self.payment_service = PaymentService() + self.bot = bot + self._notified_users: Set[str] = set() # Защита от дублирования уведомлений + + async def start_monitoring(self): + if self.is_running: + logger.warning("Мониторинг уже запущен") + return + + self.is_running = True + logger.info("🔄 Запуск службы мониторинга") + + while self.is_running: + try: + await self._monitoring_cycle() + await asyncio.sleep(settings.MONITORING_INTERVAL * 60) + + except Exception as e: + logger.error(f"Ошибка в цикле мониторинга: {e}") + await asyncio.sleep(60) + + def stop_monitoring(self): + self.is_running = False + logger.info("ℹ️ Мониторинг остановлен") + + async def _monitoring_cycle(self): + async for db in get_db(): + try: + await self._check_expired_subscriptions(db) + await self._check_expiring_subscriptions(db) + await self._check_trial_expiring_soon(db) # Новый метод! + await self._process_autopayments(db) + await self._cleanup_inactive_users(db) + await self._sync_with_remnawave(db) + + # Очищаем кеш уведомлений каждые 24 часа + current_hour = datetime.utcnow().hour + if current_hour == 0: + self._notified_users.clear() + + await self._log_monitoring_event( + db, "monitoring_cycle_completed", + "Цикл мониторинга успешно завершен", + {"timestamp": datetime.utcnow().isoformat()} + ) + + except Exception as e: + logger.error(f"Ошибка в цикле мониторинга: {e}") + await self._log_monitoring_event( + db, "monitoring_cycle_error", + f"Ошибка в цикле мониторинга: {str(e)}", + {"error": str(e)}, + is_success=False + ) + finally: + break + + async def _check_expired_subscriptions(self, db: AsyncSession): + """Проверка истекших подписок""" + try: + expired_subscriptions = await get_expired_subscriptions(db) + + for subscription in expired_subscriptions: + await deactivate_subscription(db, subscription) + + user = await get_user_by_id(db, subscription.user_id) + if user and user.remnawave_uuid: + await self.subscription_service.disable_remnawave_user(user.remnawave_uuid) + + # Отправляем уведомление об истечении + if user and self.bot: + await self._send_subscription_expired_notification(user) + + logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и деактивирована") + + if expired_subscriptions: + await self._log_monitoring_event( + db, "expired_subscriptions_processed", + f"Обработано {len(expired_subscriptions)} истекших подписок", + {"count": len(expired_subscriptions)} + ) + + except Exception as e: + logger.error(f"Ошибка проверки истекших подписок: {e}") + + async def _check_expiring_subscriptions(self, db: AsyncSession): + """Проверка подписок, истекающих через 2-3 дня (только платные)""" + try: + warning_days = settings.get_autopay_warning_days() + + for days in warning_days: + # Получаем только платные подписки + expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days) + + for subscription in expiring_subscriptions: + user = await get_user_by_id(db, subscription.user_id) + if not user: + continue + + notification_key = f"expiring_{user.telegram_id}_{days}d" + if notification_key in self._notified_users: + continue # Уже уведомляли сегодня + + if self.bot: + await self._send_subscription_expiring_notification(user, subscription, days) + self._notified_users.add(notification_key) + + logger.info(f"⚠️ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней") + + if expiring_subscriptions: + await self._log_monitoring_event( + db, "expiring_notifications_sent", + f"Отправлено {len(expiring_subscriptions)} уведомлений об истечении через {days} дней", + {"days": days, "count": len(expiring_subscriptions)} + ) + + except Exception as e: + logger.error(f"Ошибка проверки истекающих подписок: {e}") + + async def _check_trial_expiring_soon(self, db: AsyncSession): + """Проверка тестовых подписок, истекающих через 2 часа""" + try: + # Получаем тестовые подписки, истекающие через 2 часа + threshold_time = datetime.utcnow() + timedelta(hours=2) + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_trial == True, + Subscription.end_date <= threshold_time, + Subscription.end_date > datetime.utcnow() + ) + ) + ) + trial_expiring = result.scalars().all() + + for subscription in trial_expiring: + user = subscription.user + if not user: + continue + + notification_key = f"trial_2h_{user.telegram_id}" + if notification_key in self._notified_users: + continue # Уже уведомляли + + if self.bot: + await self._send_trial_ending_notification(user, subscription) + self._notified_users.add(notification_key) + + logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа") + + if trial_expiring: + await self._log_monitoring_event( + db, "trial_expiring_notifications_sent", + f"Отправлено {len(trial_expiring)} уведомлений об окончании тестовых подписок", + {"count": len(trial_expiring)} + ) + + except Exception as e: + logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") + + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: + """Получение платных подписок, истекающих через указанное количество дней""" + threshold_date = datetime.utcnow() + timedelta(days=days_before) + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_trial == False, # Только платные + Subscription.end_date <= threshold_date, + Subscription.end_date > datetime.utcnow() + ) + ) + ) + return result.scalars().all() + + async def _process_autopayments(self, db: AsyncSession): + """Обработка автоплатежей""" + try: + # Исправленный запрос с использованием индивидуальных настроек + current_time = datetime.utcnow() + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.autopay_enabled == True, + Subscription.is_trial == False # Автооплата только для платных + ) + ) + ) + all_autopay_subscriptions = result.scalars().all() + + # Фильтруем по времени с учетом индивидуальных настроек + autopay_subscriptions = [] + for sub in all_autopay_subscriptions: + days_before_expiry = (sub.end_date - current_time).days + if days_before_expiry <= sub.autopay_days_before: + autopay_subscriptions.append(sub) + + processed_count = 0 + failed_count = 0 + + for subscription in autopay_subscriptions: + user = subscription.user + if not user: + continue + + renewal_cost = settings.PRICE_30_DAYS + + # Проверяем, не списывали ли уже сегодня + autopay_key = f"autopay_{user.telegram_id}_{subscription.id}" + if autopay_key in self._notified_users: + continue + + if user.balance_kopeks >= renewal_cost: + # Списываем средства + success = await subtract_user_balance( + db, user, renewal_cost, + "Автопродление подписки" + ) + + if success: + # Продлеваем подписку + await extend_subscription(db, subscription, 30) + await self.subscription_service.update_remnawave_user(db, subscription) + + # Уведомляем об успешном автоплатеже + if self.bot: + await self._send_autopay_success_notification(user, renewal_cost, 30) + + processed_count += 1 + self._notified_users.add(autopay_key) + logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно") + else: + failed_count += 1 + if self.bot: + await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost) + logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}") + else: + failed_count += 1 + # Уведомляем о недостатке средств + if self.bot: + await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost) + logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}") + + if processed_count > 0 or failed_count > 0: + await self._log_monitoring_event( + db, "autopayments_processed", + f"Автоплатежи: успешно {processed_count}, неудачно {failed_count}", + {"processed": processed_count, "failed": failed_count} + ) + + except Exception as e: + logger.error(f"Ошибка обработки автоплатежей: {e}") + + # Методы отправки уведомлений + async def _send_subscription_expired_notification(self, user: User): + """Уведомление об истечении подписки""" + try: + texts = get_texts(user.language) + message = texts.SUBSCRIPTION_EXPIRED + await self.bot.send_message(user.telegram_id, message, parse_mode="HTML") + except Exception as e: + logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") + + async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int): + """Уведомление об истечении подписки через N дней""" + try: + texts = get_texts(user.language) + message = texts.SUBSCRIPTION_EXPIRING.format(days=days) + await self.bot.send_message(user.telegram_id, message, parse_mode="HTML") + except Exception as e: + logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") + + async def _send_trial_ending_notification(self, user: User, subscription: Subscription): + """Уведомление об окончании тестовой подписки через 2 часа""" + try: + texts = get_texts(user.language) + + # Создаем специальное сообщение для тестовой подписки + message = f""" +🎁 Тестовая подписка скоро закончится! + +Ваша тестовая подписка истекает через 2 часа. + +💎 Не хотите остаться без VPN? +Переходите на полную подписку со скидкой! + +🔥 Специальное предложение: +• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)} +• Безлимитный трафик +• Все серверы доступны +• Поддержка до 3 устройств + +⚡️ Успейте оформить до окончания тестового периода! +""" + + # Добавляем inline клавиатуру с кнопкой покупки + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="💎 Купить подписку", callback_data="buy_subscription")], + [InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_top_up")] + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard + ) + + except Exception as e: + logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") + + async def _send_autopay_success_notification(self, user: User, amount: int, days: int): + """Уведомление об успешном автоплатеже""" + try: + texts = get_texts(user.language) + message = texts.AUTOPAY_SUCCESS.format( + days=days, + amount=settings.format_price(amount) + ) + await self.bot.send_message(user.telegram_id, message, parse_mode="HTML") + except Exception as e: + logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}") + + async def _send_autopay_failed_notification(self, user: User, balance: int, required: int): + """Уведомление о неудачном автоплатеже""" + try: + texts = get_texts(user.language) + message = texts.AUTOPAY_FAILED.format( + balance=settings.format_price(balance), + required=settings.format_price(required) + ) + + # Добавляем кнопку пополнения баланса + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_top_up")] + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard + ) + + except Exception as e: + logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}") + + # Остальные методы остаются без изменений... + async def _cleanup_inactive_users(self, db: AsyncSession): + try: + now = datetime.utcnow() + if now.hour != 3: + return + + inactive_users = await get_inactive_users(db, settings.INACTIVE_USER_DELETE_MONTHS) + deleted_count = 0 + + for user in inactive_users: + if not user.subscription or not user.subscription.is_active: + success = await delete_user(db, user) + if success: + deleted_count += 1 + + if deleted_count > 0: + await self._log_monitoring_event( + db, "inactive_users_cleanup", + f"Удалено {deleted_count} неактивных пользователей", + {"deleted_count": deleted_count} + ) + logger.info(f"🗑️ Удалено {deleted_count} неактивных пользователей") + + except Exception as e: + logger.error(f"Ошибка очистки неактивных пользователей: {e}") + + async def _sync_with_remnawave(self, db: AsyncSession): + try: + now = datetime.utcnow() + if now.minute != 0: + return + + async with self.subscription_service.api as api: + system_stats = await api.get_system_stats() + + await self._log_monitoring_event( + db, "remnawave_sync", + "Синхронизация с RemnaWave завершена", + {"stats": system_stats} + ) + + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave: {e}") + await self._log_monitoring_event( + db, "remnawave_sync_error", + f"Ошибка синхронизации с RemnaWave: {str(e)}", + {"error": str(e)}, + is_success=False + ) + + async def _log_monitoring_event( + self, + db: AsyncSession, + event_type: str, + message: str, + data: Dict[str, Any] = None, + is_success: bool = True + ): + try: + log_entry = MonitoringLog( + event_type=event_type, + message=message, + data=data or {}, + is_success=is_success + ) + + db.add(log_entry) + await db.commit() + + except Exception as e: + logger.error(f"Ошибка логирования события мониторинга: {e}") + + async def get_monitoring_status(self, db: AsyncSession) -> Dict[str, Any]: + try: + from sqlalchemy import select, desc + + recent_events_result = await db.execute( + select(MonitoringLog) + .order_by(desc(MonitoringLog.created_at)) + .limit(10) + ) + recent_events = recent_events_result.scalars().all() + + yesterday = datetime.utcnow() - timedelta(days=1) + + events_24h_result = await db.execute( + select(MonitoringLog) + .where(MonitoringLog.created_at >= yesterday) + ) + events_24h = events_24h_result.scalars().all() + + successful_events = sum(1 for event in events_24h if event.is_success) + failed_events = sum(1 for event in events_24h if not event.is_success) + + return { + "is_running": self.is_running, + "last_update": datetime.utcnow(), + "recent_events": [ + { + "type": event.event_type, + "message": event.message, + "success": event.is_success, + "created_at": event.created_at + } + for event in recent_events + ], + "stats_24h": { + "total_events": len(events_24h), + "successful": successful_events, + "failed": failed_events, + "success_rate": round(successful_events / len(events_24h) * 100, 1) if events_24h else 0 + } + } + + except Exception as e: + logger.error(f"Ошибка получения статуса мониторинга: {e}") + return { + "is_running": self.is_running, + "last_update": datetime.utcnow(), + "recent_events": [], + "stats_24h": { + "total_events": 0, + "successful": 0, + "failed": 0, + "success_rate": 0 + } + } + + async def force_check_subscriptions(self, db: AsyncSession) -> Dict[str, int]: + try: + # Проверяем истекшие + expired_subscriptions = await get_expired_subscriptions(db) + expired_count = 0 + + for subscription in expired_subscriptions: + await deactivate_subscription(db, subscription) + expired_count += 1 + + expiring_subscriptions = await get_expiring_subscriptions(db, 1) + expiring_count = len(expiring_subscriptions) + + autopay_subscriptions = await get_subscriptions_for_autopay(db) + autopay_processed = 0 + + for subscription in autopay_subscriptions: + user = await get_user_by_id(db, subscription.user_id) + if user and user.balance_kopeks >= settings.PRICE_30_DAYS: + autopay_processed += 1 + + await self._log_monitoring_event( + db, "manual_check_subscriptions", + f"Принудительная проверка: истекло {expired_count}, истекает {expiring_count}, автоплатежей {autopay_processed}", + { + "expired": expired_count, + "expiring": expiring_count, + "autopay_ready": autopay_processed + } + ) + + return { + "expired": expired_count, + "expiring": expiring_count, + "autopay_ready": autopay_processed + } + + except Exception as e: + logger.error(f"Ошибка принудительной проверки подписок: {e}") + return {"expired": 0, "expiring": 0, "autopay_ready": 0} + + async def get_monitoring_logs( + self, + db: AsyncSession, + limit: int = 50, + event_type: Optional[str] = None + ) -> List[Dict[str, Any]]: + try: + from sqlalchemy import select, desc + + query = select(MonitoringLog).order_by(desc(MonitoringLog.created_at)) + + if event_type: + query = query.where(MonitoringLog.event_type == event_type) + + query = query.limit(limit) + + result = await db.execute(query) + logs = result.scalars().all() + + return [ + { + "id": log.id, + "event_type": log.event_type, + "message": log.message, + "data": log.data, + "is_success": log.is_success, + "created_at": log.created_at + } + for log in logs + ] + + except Exception as e: + logger.error(f"Ошибка получения логов мониторинга: {e}") + return [] + + async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int: + try: + from sqlalchemy import delete + + cutoff_date = datetime.utcnow() - timedelta(days=days) + + result = await db.execute( + delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date) + ) + + deleted_count = result.rowcount + await db.commit() + + logger.info(f"Удалено {deleted_count} старых записей логов") + return deleted_count + + except Exception as e: + logger.error(f"Ошибка очистки логов: {e}") + return 0 + + +monitoring_service = MonitoringService() \ No newline at end of file diff --git a/app/services/payment_service.py b/app/services/payment_service.py new file mode 100644 index 00000000..172c9752 --- /dev/null +++ b/app/services/payment_service.py @@ -0,0 +1,115 @@ +import logging +import hashlib +import hmac +from typing import Optional +from aiogram import Bot +from aiogram.types import LabeledPrice + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class PaymentService: + + def __init__(self, bot: Optional[Bot] = None): + self.bot = bot + + async def create_stars_invoice( + self, + amount_kopeks: int, + description: str, + payload: Optional[str] = None + ) -> str: + + if not self.bot: + raise ValueError("Bot instance required for Stars payments") + + try: + stars_amount = max(1, amount_kopeks // 100) + + invoice_link = await self.bot.create_invoice_link( + title="Пополнение баланса VPN", + description=description, + payload=payload or f"balance_topup_{amount_kopeks}", + provider_token="", + currency="XTR", + prices=[LabeledPrice(label="Пополнение", amount=stars_amount)] + ) + + logger.info(f"Создан Stars invoice на {stars_amount} звезд") + return invoice_link + + except Exception as e: + logger.error(f"Ошибка создания Stars invoice: {e}") + raise + + async def create_tribute_payment( + self, + amount_kopeks: int, + user_id: int, + description: str + ) -> str: + + if not settings.TRIBUTE_ENABLED: + raise ValueError("Tribute payments are disabled") + + try: + payment_data = { + "amount": amount_kopeks, + "currency": "RUB", + "description": description, + "user_id": user_id, + "callback_url": f"{settings.WEBHOOK_URL}/tribute/callback" + } + + payment_url = f"https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}" + + logger.info(f"Создан Tribute платеж на {amount_kopeks/100}₽ для пользователя {user_id}") + return payment_url + + except Exception as e: + logger.error(f"Ошибка создания Tribute платежа: {e}") + raise + + def verify_tribute_webhook( + self, + data: dict, + signature: str + ) -> bool: + + if not settings.TRIBUTE_WEBHOOK_SECRET: + return False + + try: + message = str(data).encode() + expected_signature = hmac.new( + settings.TRIBUTE_WEBHOOK_SECRET.encode(), + message, + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(signature, expected_signature) + + except Exception as e: + logger.error(f"Ошибка проверки Tribute webhook: {e}") + return False + + async def process_successful_payment( + self, + payment_id: str, + amount_kopeks: int, + user_id: int, + payment_method: str + ) -> bool: + + try: + # Здесь должна быть логика обработки платежа + # Например, пополнение баланса пользователя + + logger.info(f"Обработан успешный платеж: {payment_id}, {amount_kopeks/100}₽, {user_id}") + return True + + except Exception as e: + logger.error(f"Ошибка обработки платежа: {e}") + return False \ No newline at end of file diff --git a/app/services/promocode_service.py b/app/services/promocode_service.py new file mode 100644 index 00000000..1357296c --- /dev/null +++ b/app/services/promocode_service.py @@ -0,0 +1,117 @@ +import logging +from datetime import datetime +from typing import Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.promocode import ( + get_promocode_by_code, use_promocode, check_user_promocode_usage, + create_promocode_use, get_promocode_use_by_user_and_code +) +from app.database.crud.user import add_user_balance, get_user_by_id +from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id +from app.database.models import PromoCodeType, SubscriptionStatus, User, PromoCode +from app.services.remnawave_service import RemnaWaveService +from app.services.subscription_service import SubscriptionService + +logger = logging.getLogger(__name__) + + +class PromoCodeService: + + def __init__(self): + self.remnawave_service = RemnaWaveService() + self.subscription_service = SubscriptionService() + + async def activate_promocode( + self, + db: AsyncSession, + user_id: int, + code: str + ) -> Dict[str, Any]: + + try: + user = await get_user_by_id(db, user_id) + if not user: + return {"success": False, "error": "user_not_found"} + + promocode = await get_promocode_by_code(db, code) + if not promocode: + return {"success": False, "error": "not_found"} + + if not promocode.is_valid: + if promocode.current_uses >= promocode.max_uses: + return {"success": False, "error": "used"} + else: + return {"success": False, "error": "expired"} + + existing_use = await check_user_promocode_usage(db, user_id, promocode.id) + if existing_use: + return {"success": False, "error": "already_used_by_user"} + + result_description = await self._apply_promocode_effects(db, user, promocode) + + if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0: + from app.utils.user_utils import mark_user_as_had_paid_subscription + await mark_user_as_had_paid_subscription(db, user) + + logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}") + + await create_promocode_use(db, promocode.id, user_id) + + promocode.current_uses += 1 + await db.commit() + + logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}") + + return { + "success": True, + "description": result_description + } + + except Exception as e: + logger.error(f"Ошибка активации промокода {code} для пользователя {user_id}: {e}") + await db.rollback() + return {"success": False, "error": "server_error"} + + async def _apply_promocode_effects(self, db: AsyncSession, user: User, promocode: PromoCode) -> str: + effects = [] + + if promocode.balance_bonus_kopeks > 0: + await add_user_balance( + db, user, promocode.balance_bonus_kopeks, + f"Бонус по промокоду {promocode.code}" + ) + + balance_bonus_rubles = promocode.balance_bonus_kopeks / 100 + effects.append(f"💰 Баланс пополнен на {balance_bonus_rubles}₽") + + if promocode.subscription_days > 0: + from app.database.crud.subscription import create_paid_subscription + + subscription = await get_subscription_by_user_id(db, user.id) + + if subscription: + await extend_subscription(db, subscription, promocode.subscription_days) + effects.append(f"⏰ Подписка продлена на {promocode.subscription_days} дней") + else: + await create_paid_subscription( + db=db, + user_id=user.id, + duration_days=promocode.subscription_days, + traffic_limit_gb=0, + device_limit=1, + connected_squads=[] + ) + effects.append(f"🎉 Получена подписка на {promocode.subscription_days} дней") + + if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value: + from app.database.crud.subscription import create_trial_subscription + + subscription = await get_subscription_by_user_id(db, user.id) + if not subscription and not user.has_had_paid_subscription: + await create_trial_subscription(db, user.id) + effects.append("🎁 Активирована тестовая подписка") + else: + effects.append("ℹ️ Тестовая подписка уже недоступна") + + return "\n".join(effects) if effects else "✅ Промокод активирован" \ No newline at end of file diff --git a/app/services/referral_service.py b/app/services/referral_service.py new file mode 100644 index 00000000..1b22f4f3 --- /dev/null +++ b/app/services/referral_service.py @@ -0,0 +1,174 @@ +import logging +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.user import add_user_balance, get_user_by_id +from app.database.crud.referral import create_referral_earning +from app.database.models import TransactionType + +logger = logging.getLogger(__name__) + + +async def process_referral_registration( + db: AsyncSession, + new_user_id: int, + referrer_id: int +): + + try: + new_user = await get_user_by_id(db, new_user_id) + referrer = await get_user_by_id(db, referrer_id) + + if not new_user or not referrer: + logger.error(f"Пользователи не найдены: {new_user_id}, {referrer_id}") + return False + + if new_user.referred_by_id != referrer_id: + logger.error(f"Пользователь {new_user_id} не привязан к рефереру {referrer_id}") + return False + + if settings.REFERRED_USER_REWARD > 0: + await add_user_balance( + db, new_user, settings.REFERRED_USER_REWARD, + f"Бонус за регистрацию по реферальной ссылке" + ) + + logger.info(f"💰 Новый пользователь {new_user_id} получил бонус {settings.REFERRED_USER_REWARD/100}₽") + + await create_referral_earning( + db=db, + user_id=referrer_id, + referral_id=new_user_id, + amount_kopeks=0, + reason="referral_registration_pending" + ) + + logger.info(f"✅ Обработана реферальная регистрация: {new_user_id} -> {referrer_id}") + return True + + except Exception as e: + logger.error(f"Ошибка обработки реферальной регистрации: {e}") + return False + + +async def process_referral_purchase( + db: AsyncSession, + user_id: int, + purchase_amount_kopeks: int, + transaction_id: int = None +): + + try: + user = await get_user_by_id(db, user_id) + if not user or not user.referred_by_id: + logger.info(f"Пользователь {user_id} не является рефералом") + return False + + referrer = await get_user_by_id(db, user.referred_by_id) + if not referrer: + logger.error(f"Реферер {user.referred_by_id} не найден") + return False + + from app.database.crud.referral import get_referral_earnings_by_referral + existing_earnings = await get_referral_earnings_by_referral(db, user_id) + + purchase_earnings = [ + earning for earning in existing_earnings + if earning.reason in ["referral_first_purchase", "referral_commission"] + ] + + is_first_purchase = len(purchase_earnings) == 0 + + logger.info(f"🔍 Покупка реферала {user_id}: первая = {is_first_purchase}, сумма = {purchase_amount_kopeks/100}₽") + + if is_first_purchase: + reward_amount = settings.REFERRAL_REGISTRATION_REWARD + + await add_user_balance( + db, referrer, reward_amount, + f"Реферальная награда за первую покупку {user.full_name}" + ) + + await create_referral_earning( + db=db, + user_id=referrer.id, + referral_id=user_id, + amount_kopeks=reward_amount, + reason="referral_first_purchase", + referral_transaction_id=transaction_id + ) + + logger.info(f"🎉 Первая покупка реферала: {referrer.telegram_id} получил {reward_amount/100}₽") + + commission_amount = int(purchase_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100) + + if commission_amount > 0: + await add_user_balance( + db, referrer, commission_amount, + f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с покупки {user.full_name}" + ) + + await create_referral_earning( + db=db, + user_id=referrer.id, + referral_id=user_id, + amount_kopeks=commission_amount, + reason="referral_commission", + referral_transaction_id=transaction_id + ) + + logger.info(f"💰 Комиссия с покупки: {referrer.telegram_id} получил {commission_amount/100}₽") + + if not user.has_had_paid_subscription: + user.has_had_paid_subscription = True + await db.commit() + logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку") + + return True + + except Exception as e: + logger.error(f"Ошибка обработки покупки реферала: {e}") + return False + + +async def get_referral_stats_for_user(db: AsyncSession, user_id: int) -> dict: + + try: + from app.database.crud.referral import get_referral_earnings_sum + from sqlalchemy import select, func + from app.database.models import User + + invited_count_result = await db.execute( + select(func.count(User.id)).where(User.referred_by_id == user_id) + ) + invited_count = invited_count_result.scalar() + + paid_referrals_result = await db.execute( + select(func.count(User.id)).where( + User.referred_by_id == user_id, + User.has_had_paid_subscription == True + ) + ) + paid_referrals_count = paid_referrals_result.scalar() + + total_earned = await get_referral_earnings_sum(db, user_id) + + from datetime import datetime, timedelta + month_ago = datetime.utcnow() - timedelta(days=30) + month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago) + + return { + "invited_count": invited_count, + "paid_referrals_count": paid_referrals_count, + "total_earned_kopeks": total_earned, + "month_earned_kopeks": month_earned + } + + except Exception as e: + logger.error(f"Ошибка получения статистики рефералов: {e}") + return { + "invited_count": 0, + "paid_referrals_count": 0, + "total_earned_kopeks": 0, + "month_earned_kopeks": 0 + } \ No newline at end of file diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py new file mode 100644 index 00000000..52c37b41 --- /dev/null +++ b/app/services/remnawave_service.py @@ -0,0 +1,868 @@ +import logging +from typing import Dict, List, Any, Optional +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.external.remnawave_api import ( + RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad, + RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError +) +from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user +from app.database.crud.subscription import get_subscription_by_user_id, update_subscription_usage +from app.database.models import User + +logger = logging.getLogger(__name__) + + +class RemnaWaveService: + + def __init__(self): + self.api = RemnaWaveAPI( + base_url=settings.REMNAWAVE_API_URL, + api_key=settings.REMNAWAVE_API_KEY + ) + + async def get_system_statistics(self) -> Dict[str, Any]: + try: + async with self.api as api: + logger.info("Получение системной статистики RemnaWave...") + + try: + system_stats = await api.get_system_stats() + logger.info(f"Системная статистика получена") + except Exception as e: + logger.error(f"Ошибка получения системной статистики: {e}") + system_stats = {} + + try: + bandwidth_stats = await api.get_bandwidth_stats() + logger.info(f"Статистика трафика получена") + except Exception as e: + logger.error(f"Ошибка получения статистики трафика: {e}") + bandwidth_stats = {} + + try: + realtime_usage = await api.get_nodes_realtime_usage() + logger.info(f"Реалтайм статистика получена") + except Exception as e: + logger.error(f"Ошибка получения реалтайм статистики: {e}") + realtime_usage = [] + + try: + nodes_stats = await api.get_nodes_statistics() + except Exception as e: + logger.error(f"Ошибка получения статистики нод: {e}") + nodes_stats = {} + + from datetime import datetime + + total_download = sum(node.get('downloadBytes', 0) for node in realtime_usage) + total_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage) + total_realtime_traffic = total_download + total_upload + + total_user_traffic = int(system_stats.get('users', {}).get('totalTrafficBytes', '0')) + + nodes_weekly_data = [] + if nodes_stats.get('lastSevenDays'): + nodes_by_name = {} + for day_data in nodes_stats['lastSevenDays']: + node_name = day_data['nodeName'] + if node_name not in nodes_by_name: + nodes_by_name[node_name] = { + 'name': node_name, + 'total_bytes': 0, + 'days_data': [] + } + + daily_bytes = int(day_data['totalBytes']) + nodes_by_name[node_name]['total_bytes'] += daily_bytes + nodes_by_name[node_name]['days_data'].append({ + 'date': day_data['date'], + 'bytes': daily_bytes + }) + + nodes_weekly_data = list(nodes_by_name.values()) + nodes_weekly_data.sort(key=lambda x: x['total_bytes'], reverse=True) + + result = { + "system": { + "users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0), + "total_users": system_stats.get('users', {}).get('totalUsers', 0), + "active_connections": system_stats.get('onlineStats', {}).get('onlineNow', 0), + "nodes_online": system_stats.get('nodes', {}).get('totalOnline', 0), + "users_last_day": system_stats.get('onlineStats', {}).get('lastDay', 0), + "users_last_week": system_stats.get('onlineStats', {}).get('lastWeek', 0), + "users_never_online": system_stats.get('onlineStats', {}).get('neverOnline', 0), + "total_user_traffic": total_user_traffic + }, + "users_by_status": system_stats.get('users', {}).get('statusCounts', {}), + "server_info": { + "cpu_cores": system_stats.get('cpu', {}).get('cores', 0), + "cpu_physical_cores": system_stats.get('cpu', {}).get('physicalCores', 0), + "memory_total": system_stats.get('memory', {}).get('total', 0), + "memory_used": system_stats.get('memory', {}).get('used', 0), + "memory_free": system_stats.get('memory', {}).get('free', 0), + "memory_available": system_stats.get('memory', {}).get('available', 0), + "uptime_seconds": system_stats.get('uptime', 0) + }, + "bandwidth": { + "realtime_download": total_download, + "realtime_upload": total_upload, + "realtime_total": total_realtime_traffic + }, + "traffic_periods": { + "last_2_days": { + "current": self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLastTwoDays', {}).get('current', '0 B') + ), + "previous": self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLastTwoDays', {}).get('previous', '0 B') + ), + "difference": bandwidth_stats.get('bandwidthLastTwoDays', {}).get('difference', '0 B') + }, + "last_7_days": { + "current": self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLastSevenDays', {}).get('current', '0 B') + ), + "previous": self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLastSevenDays', {}).get('previous', '0 B') + ), + "difference": bandwidth_stats.get('bandwidthLastSevenDays', {}).get('difference', '0 B') + }, + "last_30_days": { + "current": self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLast30Days', {}).get('current', '0 B') + ), + "previous": self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLast30Days', {}).get('previous', '0 B') + ), + "difference": bandwidth_stats.get('bandwidthLast30Days', {}).get('difference', '0 B') + }, + "current_month": { + "current": self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthCalendarMonth', {}).get('current', '0 B') + ), + "previous": self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthCalendarMonth', {}).get('previous', '0 B') + ), + "difference": bandwidth_stats.get('bandwidthCalendarMonth', {}).get('difference', '0 B') + }, + "current_year": { + "current": self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthCurrentYear', {}).get('current', '0 B') + ), + "previous": self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthCurrentYear', {}).get('previous', '0 B') + ), + "difference": bandwidth_stats.get('bandwidthCurrentYear', {}).get('difference', '0 B') + } + }, + "nodes_realtime": realtime_usage, + "nodes_weekly": nodes_weekly_data, + "last_updated": datetime.now() + } + + logger.info(f"Статистика сформирована: пользователи={result['system']['total_users']}, общий трафик={total_user_traffic}") + return result + + except RemnaWaveAPIError as e: + logger.error(f"Ошибка RemnaWave API при получении статистики: {e}") + return {"error": str(e)} + except Exception as e: + logger.error(f"Общая ошибка получения системной статистики: {e}") + return {"error": f"Внутренняя ошибка сервера: {str(e)}"} + + + def _parse_bandwidth_string(self, bandwidth_str: str) -> int: + try: + if not bandwidth_str or bandwidth_str == '0 B' or bandwidth_str == '0': + return 0 + + bandwidth_str = bandwidth_str.replace(' ', '').upper() + + units = { + 'B': 1, + 'KB': 1024, + 'MB': 1024 ** 2, + 'GB': 1024 ** 3, + 'TB': 1024 ** 4, + 'KIB': 1024, + 'MIB': 1024 ** 2, + 'GIB': 1024 ** 3, + 'TIB': 1024 ** 4, + 'KBPS': 1024, + 'MBPS': 1024 ** 2, + 'GBPS': 1024 ** 3 + } + + import re + match = re.match(r'([0-9.,]+)([A-Z]+)', bandwidth_str) + if match: + value_str = match.group(1).replace(',', '.') + value = float(value_str) + unit = match.group(2) + + if unit in units: + result = int(value * units[unit]) + logger.debug(f"Парсинг '{bandwidth_str}': {value} {unit} = {result} байт") + return result + else: + logger.warning(f"Неизвестная единица измерения: {unit}") + + logger.warning(f"Не удалось распарсить строку трафика: '{bandwidth_str}'") + return 0 + + except Exception as e: + logger.error(f"Ошибка парсинга строки трафика '{bandwidth_str}': {e}") + return 0 + + async def get_all_nodes(self) -> List[Dict[str, Any]]: + + try: + async with self.api as api: + nodes = await api.get_all_nodes() + + result = [] + for node in nodes: + result.append({ + 'uuid': node.uuid, + 'name': node.name, + 'address': node.address, + 'country_code': node.country_code, + 'is_connected': node.is_connected, + 'is_disabled': node.is_disabled, + 'is_node_online': node.is_node_online, + 'is_xray_running': node.is_xray_running, + 'users_online': node.users_online, + 'traffic_used_bytes': node.traffic_used_bytes, + 'traffic_limit_bytes': node.traffic_limit_bytes + }) + + logger.info(f"✅ Получено {len(result)} нод из RemnaWave") + return result + + except Exception as e: + logger.error(f"Ошибка получения нод из RemnaWave: {e}") + return [] + + async def test_connection(self) -> bool: + + try: + async with self.api as api: + stats = await api.get_system_stats() + logger.info("✅ Соединение с RemnaWave API работает") + return True + + except Exception as e: + logger.error(f"❌ Ошибка соединения с RemnaWave API: {e}") + return False + + async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]: + try: + async with self.api as api: + node = await api.get_node_by_uuid(node_uuid) + + if not node: + return None + + return { + "uuid": node.uuid, + "name": node.name, + "address": node.address, + "country_code": node.country_code, + "is_connected": node.is_connected, + "is_disabled": node.is_disabled, + "is_node_online": node.is_node_online, + "is_xray_running": node.is_xray_running, + "users_online": node.users_online or 0, + "traffic_used_bytes": node.traffic_used_bytes or 0, + "traffic_limit_bytes": node.traffic_limit_bytes or 0 + } + + except Exception as e: + logger.error(f"Ошибка получения информации о ноде {node_uuid}: {e}") + return None + + async def manage_node(self, node_uuid: str, action: str) -> bool: + try: + async with self.api as api: + if action == "enable": + await api.enable_node(node_uuid) + elif action == "disable": + await api.disable_node(node_uuid) + elif action == "restart": + await api.restart_node(node_uuid) + else: + return False + + logger.info(f"✅ Действие {action} выполнено для ноды {node_uuid}") + return True + + except Exception as e: + logger.error(f"Ошибка управления нодой {node_uuid}: {e}") + return False + + async def restart_all_nodes(self) -> bool: + try: + async with self.api as api: + result = await api.restart_all_nodes() + + if result: + logger.info("✅ Команда перезагрузки всех нод отправлена") + + return result + + except Exception as e: + logger.error(f"Ошибка перезагрузки всех нод: {e}") + return False + + async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: List[str]) -> bool: + try: + async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api: + data = { + 'uuid': squad_uuid, + 'inbounds': inbound_uuids + } + response = await api._make_request('PATCH', '/api/internal-squads', data) + return True + except Exception as e: + logger.error(f"Error updating squad inbounds: {e}") + return False + + async def get_all_squads(self) -> List[Dict[str, Any]]: + + try: + async with self.api as api: + squads = await api.get_internal_squads() + + result = [] + for squad in squads: + result.append({ + 'uuid': squad.uuid, + 'name': squad.name, + 'members_count': squad.members_count, + 'inbounds_count': squad.inbounds_count, + 'inbounds': squad.inbounds + }) + + logger.info(f"✅ Получено {len(result)} сквадов из RemnaWave") + return result + + except Exception as e: + logger.error(f"Ошибка получения сквадов из RemnaWave: {e}") + return [] + + async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]: + try: + async with self.api as api: + squad = await api.create_internal_squad(name, inbounds) + + logger.info(f"✅ Создан новый сквад: {name}") + return squad.uuid + + except Exception as e: + logger.error(f"Ошибка создания сквада {name}: {e}") + return None + + async def update_squad(self, uuid: str, name: str = None, inbounds: List[str] = None) -> bool: + try: + async with self.api as api: + await api.update_internal_squad(uuid, name, inbounds) + + logger.info(f"✅ Обновлен сквад {uuid}") + return True + + except Exception as e: + logger.error(f"Ошибка обновления сквада {uuid}: {e}") + return False + + async def delete_squad(self, uuid: str) -> bool: + try: + async with self.api as api: + result = await api.delete_internal_squad(uuid) + + if result: + logger.info(f"✅ Удален сквад {uuid}") + + return result + + except Exception as e: + logger.error(f"Ошибка удаления сквада {uuid}: {e}") + return False + + async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]: + try: + stats = {"created": 0, "updated": 0, "errors": 0} + + logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}") + + async with self.api as api: + panel_users_data = await api._make_request('GET', '/api/users') + panel_users = panel_users_data['response']['users'] + + logger.info(f"👥 Найдено пользователей в панели: {len(panel_users)}") + + for i, panel_user in enumerate(panel_users): + try: + telegram_id = panel_user.get('telegramId') + if not telegram_id: + logger.debug(f"➡️ Пропускаем пользователя без telegram_id") + continue + + logger.info(f"🔄 Обрабатываем пользователя {i+1}/{len(panel_users)}: {telegram_id}") + + db_user = await get_user_by_telegram_id(db, telegram_id) + + if not db_user: + if sync_type in ["new_only", "all"]: + logger.info(f"📝 Создание пользователя для telegram_id {telegram_id}") + + from app.database.crud.user import create_user + + db_user = await create_user( + db=db, + telegram_id=telegram_id, + username=panel_user.get('username') or f"user_{telegram_id}", + first_name=f"Panel User {telegram_id}", + language="ru" + ) + + await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid')) + + await self._create_subscription_from_panel_data(db, db_user, panel_user) + + stats["created"] += 1 + logger.info(f"✅ Создан пользователь {telegram_id} с подпиской") + + else: + if sync_type in ["update_only", "all"]: + logger.debug(f"🔄 Обновление пользователя {telegram_id}") + + if not db_user.remnawave_uuid: + await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid')) + + await self._update_subscription_from_panel_data(db, db_user, panel_user) + + stats["updated"] += 1 + logger.debug(f"✅ Обновлён пользователь {telegram_id}") + + except Exception as user_error: + logger.error(f"❌ Ошибка обработки пользователя {telegram_id}: {user_error}") + stats["errors"] += 1 + continue + + logger.info(f"🎯 Синхронизация завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}") + return stats + + except Exception as e: + logger.error(f"❌ Критическая ошибка синхронизации пользователей: {e}") + return {"created": 0, "updated": 0, "errors": 1} + + async def _create_subscription_from_panel_data(self, db: AsyncSession, user, panel_user): + try: + from app.database.crud.subscription import create_subscription + from app.database.models import SubscriptionStatus + from datetime import datetime, timedelta + import pytz + + expire_at_str = panel_user.get('expireAt', '') + try: + if expire_at_str: + if expire_at_str.endswith('Z'): + expire_at_str = expire_at_str[:-1] + '+00:00' + + expire_at = datetime.fromisoformat(expire_at_str) + + if expire_at.tzinfo is not None: + expire_at = expire_at.replace(tzinfo=None) + + else: + expire_at = datetime.utcnow() + timedelta(days=30) + except Exception as date_error: + logger.warning(f"⚠️ Ошибка парсинга даты {expire_at_str}: {date_error}") + expire_at = datetime.utcnow() + timedelta(days=30) + + panel_status = panel_user.get('status', 'ACTIVE') + current_time = datetime.utcnow() + + if panel_status == 'ACTIVE' and expire_at > current_time: + status = SubscriptionStatus.ACTIVE + elif expire_at <= current_time: + status = SubscriptionStatus.EXPIRED + else: + status = SubscriptionStatus.DISABLED + + traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0) + traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0 + + used_traffic_bytes = panel_user.get('usedTrafficBytes', 0) + traffic_used_gb = used_traffic_bytes / (1024**3) + + active_squads = panel_user.get('activeInternalSquads', []) + squad_uuids = [] + if isinstance(active_squads, list): + for squad in active_squads: + if isinstance(squad, dict) and 'uuid' in squad: + squad_uuids.append(squad['uuid']) + elif isinstance(squad, str): + squad_uuids.append(squad) + + subscription_data = { + 'user_id': user.id, + 'status': status.value, + 'is_trial': False, + 'end_date': expire_at, + 'traffic_limit_gb': traffic_limit_gb, + 'traffic_used_gb': traffic_used_gb, + 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, + 'connected_squads': squad_uuids, + 'remnawave_short_uuid': panel_user.get('shortUuid'), + 'subscription_url': panel_user.get('subscriptionUrl', '') + } + + subscription = await create_subscription(db, **subscription_data) + logger.info(f"✅ Создана подписка для пользователя {user.telegram_id} до {expire_at}") + + except Exception as e: + logger.error(f"❌ Ошибка создания подписки для пользователя {user.telegram_id}: {e}") + try: + from app.database.crud.subscription import create_subscription + from app.database.models import SubscriptionStatus + + basic_subscription = await create_subscription( + db=db, + user_id=user.id, + status=SubscriptionStatus.ACTIVE.value, + is_trial=False, + end_date=datetime.utcnow() + timedelta(days=30), + traffic_limit_gb=0, + traffic_used_gb=0.0, + device_limit=1, + connected_squads=[], + remnawave_short_uuid=panel_user.get('shortUuid'), + subscription_url=panel_user.get('subscriptionUrl', '') + ) + logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}") + except Exception as basic_error: + logger.error(f"❌ Ошибка создания базовой подписки: {basic_error}") + + async def _update_subscription_from_panel_data(self, db: AsyncSession, user, panel_user): + try: + from app.database.crud.subscription import get_subscription_by_user_id + from datetime import datetime, timedelta + + subscription = await get_subscription_by_user_id(db, user.id) + + if not subscription: + await self._create_subscription_from_panel_data(db, user, panel_user) + return + + used_traffic_bytes = panel_user.get('usedTrafficBytes', 0) + traffic_used_gb = used_traffic_bytes / (1024**3) + + if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01: + subscription.traffic_used_gb = traffic_used_gb + + if not subscription.remnawave_short_uuid: + subscription.remnawave_short_uuid = panel_user.get('shortUuid') + + if not subscription.subscription_url: + subscription.subscription_url = panel_user.get('subscriptionUrl', '') + + active_squads = panel_user.get('activeInternalSquads', []) + squad_uuids = [] + if isinstance(active_squads, list): + for squad in active_squads: + if isinstance(squad, dict) and 'uuid' in squad: + squad_uuids.append(squad['uuid']) + elif isinstance(squad, str): + squad_uuids.append(squad) + + if squad_uuids != subscription.connected_squads: + subscription.connected_squads = squad_uuids + + await db.commit() + logger.debug(f"✅ Обновлена подписка для пользователя {user.telegram_id}") + + except Exception as e: + logger.error(f"❌ Ошибка обновления подписки для пользователя {user.telegram_id}: {e}") + + async def sync_users_to_panel(self, db: AsyncSession) -> Dict[str, int]: + try: + stats = {"created": 0, "updated": 0, "errors": 0} + + users = await get_users_list(db, offset=0, limit=10000) + + async with self.api as api: + for user in users: + if not user.subscription: + continue + + try: + subscription = user.subscription + + if user.remnawave_uuid: + await api.update_user( + uuid=user.remnawave_uuid, + status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, + expire_at=subscription.end_date, + traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, + traffic_limit_strategy=TrafficLimitStrategy.MONTH, + hwid_device_limit=subscription.device_limit, + active_internal_squads=subscription.connected_squads + ) + stats["updated"] += 1 + else: + username = f"user_{user.telegram_id}" + + new_user = await api.create_user( + username=username, + expire_at=subscription.end_date, + status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, + traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, + traffic_limit_strategy=TrafficLimitStrategy.MONTH, + telegram_id=user.telegram_id, + hwid_device_limit=subscription.device_limit, + description=f"Bot user: {user.full_name}", + active_internal_squads=subscription.connected_squads + ) + + # Обновляем UUID в нашей базе + await update_user(db, user, remnawave_uuid=new_user.uuid) + subscription.remnawave_short_uuid = new_user.short_uuid + await db.commit() + + stats["created"] += 1 + + except Exception as e: + logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}") + stats["errors"] += 1 + + logger.info(f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}") + return stats + + except Exception as e: + logger.error(f"Ошибка синхронизации пользователей в панель: {e}") + return {"created": 0, "updated": 0, "errors": 1} + + async def get_user_traffic_stats(self, telegram_id: int) -> Optional[Dict[str, Any]]: + try: + async with self.api as api: + users = await api.get_user_by_telegram_id(telegram_id) + + if not users: + return None + + user = users[0] + + return { + "used_traffic_bytes": user.used_traffic_bytes, + "used_traffic_gb": user.used_traffic_bytes / (1024**3), + "lifetime_used_traffic_bytes": user.lifetime_used_traffic_bytes, + "lifetime_used_traffic_gb": user.lifetime_used_traffic_bytes / (1024**3), + "traffic_limit_bytes": user.traffic_limit_bytes, + "traffic_limit_gb": user.traffic_limit_bytes / (1024**3) if user.traffic_limit_bytes > 0 else 0, + "subscription_url": user.subscription_url + } + + except Exception as e: + logger.error(f"Ошибка получения статистики трафика для пользователя {telegram_id}: {e}") + return None + + async def test_api_connection(self) -> Dict[str, Any]: + try: + async with self.api as api: + system_stats = await api.get_system_stats() + + return { + "status": "connected", + "message": "Подключение успешно", + "api_url": settings.REMNAWAVE_API_URL, + "system_info": system_stats + } + + except RemnaWaveAPIError as e: + return { + "status": "error", + "message": f"Ошибка API: {e.message}", + "status_code": e.status_code, + "api_url": settings.REMNAWAVE_API_URL + } + except Exception as e: + return { + "status": "error", + "message": f"Ошибка подключения: {str(e)}", + "api_url": settings.REMNAWAVE_API_URL + } + + async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]: + try: + async with self.api as api: + usage_data = await api.get_nodes_realtime_usage() + return usage_data + + except Exception as e: + logger.error(f"Ошибка получения актуального использования нод: {e}") + return [] + + async def get_squad_details(self, squad_uuid: str) -> Optional[Dict]: + try: + async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api: + squad = await api.get_internal_squad_by_uuid(squad_uuid) + if squad: + return { + 'uuid': squad.uuid, + 'name': squad.name, + 'members_count': squad.members_count, + 'inbounds_count': squad.inbounds_count, + 'inbounds': squad.inbounds + } + return None + except Exception as e: + logger.error(f"Error getting squad details: {e}") + return None + + async def add_all_users_to_squad(self, squad_uuid: str) -> bool: + try: + async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api: + response = await api._make_request('POST', f'/api/internal-squads/{squad_uuid}/bulk-actions/add-users') + return response.get('response', {}).get('eventSent', False) + except Exception as e: + logger.error(f"Error adding users to squad: {e}") + return False + + async def remove_all_users_from_squad(self, squad_uuid: str) -> bool: + try: + async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api: + response = await api._make_request('DELETE', f'/api/internal-squads/{squad_uuid}/bulk-actions/remove-users') + return response.get('response', {}).get('eventSent', False) + except Exception as e: + logger.error(f"Error removing users from squad: {e}") + return False + + async def delete_squad(self, squad_uuid: str) -> bool: + try: + async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api: + response = await api.delete_internal_squad(squad_uuid) + return response + except Exception as e: + logger.error(f"Error deleting squad: {e}") + return False + + async def get_all_inbounds(self) -> List[Dict]: + try: + async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api: + response = await api._make_request('GET', '/api/config-profiles/inbounds') + inbounds_data = response.get('response', {}).get('inbounds', []) + + return [ + { + 'uuid': inbound['uuid'], + 'tag': inbound['tag'], + 'type': inbound['type'], + 'network': inbound.get('network'), + 'security': inbound.get('security'), + 'port': inbound.get('port') + } + for inbound in inbounds_data + ] + except Exception as e: + logger.error(f"Error getting all inbounds: {e}") + return [] + + async def rename_squad(self, squad_uuid: str, new_name: str) -> bool: + try: + async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api: + data = { + 'uuid': squad_uuid, + 'name': new_name + } + response = await api._make_request('PATCH', '/api/internal-squads', data) + return True + except Exception as e: + logger.error(f"Error renaming squad: {e}") + return False + + async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool: + try: + async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api: + squad = await api.create_internal_squad(name, inbound_uuids) + return squad is not None + except Exception as e: + logger.error(f"Error creating squad: {e}") + return False + + async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]: + try: + async with self.api as api: + start_str = start_date.isoformat() + "Z" + end_str = end_date.isoformat() + "Z" + + params = { + 'start': start_str, + 'end': end_str + } + + usage_data = await api._make_request( + 'GET', + f'/api/nodes/usage/{node_uuid}/users/range', + params=params + ) + + return usage_data.get('response', []) + + except Exception as e: + logger.error(f"Ошибка получения статистики использования ноды {node_uuid}: {e}") + return [] + + async def get_node_statistics(self, node_uuid: str) -> Optional[Dict[str, Any]]: + try: + node = await self.get_node_details(node_uuid) + if not node: + return None + + realtime_stats = await self.get_nodes_realtime_usage() + + node_realtime = None + for stats in realtime_stats: + if stats.get('nodeUuid') == node_uuid: + node_realtime = stats + break + + from datetime import datetime, timedelta + end_date = datetime.now() + start_date = end_date - timedelta(days=7) + + usage_history = await self.get_node_user_usage_by_range( + node_uuid, start_date, end_date + ) + + return { + 'node': node, + 'realtime': node_realtime, + 'usage_history': usage_history, + 'last_updated': datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}") + + async def validate_user_data_before_sync(self, panel_user) -> bool: + try: + if not panel_user.telegram_id: + logger.debug(f"Нет telegram_id для пользователя {panel_user.uuid}") + return False + + if not panel_user.uuid: + logger.debug(f"Нет UUID для пользователя {panel_user.telegram_id}") + return False + + if panel_user.telegram_id <= 0: + logger.debug(f"Некорректный telegram_id: {panel_user.telegram_id}") + return False + + return True + + except Exception as e: + logger.error(f"Ошибка валидации данных пользователя: {e}") + return False \ No newline at end of file diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py new file mode 100644 index 00000000..7f0bc943 --- /dev/null +++ b/app/services/subscription_service.py @@ -0,0 +1,240 @@ +import logging +from datetime import datetime, timedelta +from typing import Optional, List, Tuple +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import Subscription, User +from app.external.remnawave_api import ( + RemnaWaveAPI, RemnaWaveUser, UserStatus, + TrafficLimitStrategy, RemnaWaveAPIError +) +from app.database.crud.user import get_user_by_id + +logger = logging.getLogger(__name__) + + +class SubscriptionService: + + def __init__(self): + self.api = RemnaWaveAPI( + base_url=settings.REMNAWAVE_API_URL, + api_key=settings.REMNAWAVE_API_KEY + ) + + async def create_remnawave_user( + self, + db: AsyncSession, + subscription: Subscription + ) -> Optional[RemnaWaveUser]: + + try: + user = await get_user_by_id(db, subscription.user_id) + if not user: + logger.error(f"Пользователь {subscription.user_id} не найден") + return None + + async with self.api as api: + existing_users = await api.get_user_by_telegram_id(user.telegram_id) + if existing_users: + remnawave_user = existing_users[0] + updated_user = await api.update_user( + uuid=remnawave_user.uuid, + status=UserStatus.ACTIVE, + expire_at=subscription.end_date, + traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), + traffic_limit_strategy=TrafficLimitStrategy.MONTH, + hwid_device_limit=subscription.device_limit, + active_internal_squads=subscription.connected_squads + ) + else: + username = f"user_{user.telegram_id}" + updated_user = await api.create_user( + username=username, + expire_at=subscription.end_date, + status=UserStatus.ACTIVE, + traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), + traffic_limit_strategy=TrafficLimitStrategy.MONTH, + telegram_id=user.telegram_id, + hwid_device_limit=subscription.device_limit, + description=f"Bot user: {user.full_name}", + active_internal_squads=subscription.connected_squads + ) + + subscription.remnawave_short_uuid = updated_user.short_uuid + subscription.subscription_url = updated_user.subscription_url + user.remnawave_uuid = updated_user.uuid + + await db.commit() + + logger.info(f"✅ Создан RemnaWave пользователь для подписки {subscription.id}") + logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}") + logger.info(f"📊 Стратегия сброса трафика: MONTH") + return updated_user + + except RemnaWaveAPIError as e: + logger.error(f"Ошибка RemnaWave API: {e}") + return None + except Exception as e: + logger.error(f"Ошибка создания RemnaWave пользователя: {e}") + return None + + async def update_remnawave_user( + self, + db: AsyncSession, + subscription: Subscription + ) -> Optional[RemnaWaveUser]: + + try: + user = await get_user_by_id(db, subscription.user_id) + if not user or not user.remnawave_uuid: + logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}") + return None + + async with self.api as api: + updated_user = await api.update_user( + uuid=user.remnawave_uuid, + status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, + expire_at=subscription.end_date, + traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), + traffic_limit_strategy=TrafficLimitStrategy.MONTH, + hwid_device_limit=subscription.device_limit, + active_internal_squads=subscription.connected_squads + ) + + subscription.subscription_url = updated_user.subscription_url + await db.commit() + + logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid}") + logger.info(f"📊 Стратегия сброса трафика: MONTH") + return updated_user + + except RemnaWaveAPIError as e: + logger.error(f"Ошибка обновления RemnaWave пользователя: {e}") + return None + except Exception as e: + logger.error(f"Ошибка обновления RemnaWave пользователя: {e}") + return None + + async def disable_remnawave_user(self, user_uuid: str) -> bool: + + try: + async with self.api as api: + await api.disable_user(user_uuid) + logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}") + return True + + except Exception as e: + logger.error(f"Ошибка отключения RemnaWave пользователя: {e}") + return False + + async def revoke_subscription( + self, + db: AsyncSession, + subscription: Subscription + ) -> Optional[str]: + + try: + user = await get_user_by_id(db, subscription.user_id) + if not user or not user.remnawave_uuid: + return None + + async with self.api as api: + updated_user = await api.revoke_user_subscription(user.remnawave_uuid) + + subscription.remnawave_short_uuid = updated_user.short_uuid + subscription.subscription_url = updated_user.subscription_url + await db.commit() + + logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}") + return updated_user.subscription_url + + except Exception as e: + logger.error(f"Ошибка обновления ссылки подписки: {e}") + return None + + async def get_subscription_info(self, short_uuid: str) -> Optional[dict]: + + try: + async with self.api as api: + info = await api.get_subscription_info(short_uuid) + return info + + except Exception as e: + logger.error(f"Ошибка получения информации о подписке: {e}") + return None + + async def sync_subscription_usage( + self, + db: AsyncSession, + subscription: Subscription + ) -> bool: + + try: + user = await get_user_by_id(db, subscription.user_id) + if not user or not user.remnawave_uuid: + return False + + async with self.api as api: + remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid) + if not remnawave_user: + return False + + used_gb = self._bytes_to_gb(remnawave_user.used_traffic_bytes) + subscription.traffic_used_gb = used_gb + + await db.commit() + + logger.debug(f"Синхронизирован трафик для подписки {subscription.id}: {used_gb} ГБ") + return True + + except Exception as e: + logger.error(f"Ошибка синхронизации трафика: {e}") + return False + + async def calculate_subscription_price( + self, + period_days: int, + traffic_gb: int, + server_squad_ids: List[int], + devices: int, + db: AsyncSession + ) -> Tuple[int, List[int]]: + + from app.config import PERIOD_PRICES, TRAFFIC_PRICES + from app.database.crud.server_squad import get_server_squad_by_id + + base_price = PERIOD_PRICES.get(period_days, 0) + traffic_price = TRAFFIC_PRICES.get(traffic_gb, 0) + + server_prices = [] + total_servers_price = 0 + + for server_id in server_squad_ids: + server = await get_server_squad_by_id(db, server_id) + if server and server.is_available and not server.is_full: + server_prices.append(server.price_kopeks) + total_servers_price += server.price_kopeks + else: + server_prices.append(0) + + devices_price = max(0, devices - 1) * settings.PRICE_PER_DEVICE + + total_price = base_price + traffic_price + total_servers_price + devices_price + return total_price, server_prices + + async def _get_countries_price(self, country_uuids: List[str]) -> int: + # TODO: Реализовать получение цен из базы данных сквадов + # Пока возвращаем базовую логику + price_per_country = 1000 + return len(country_uuids) * price_per_country + + def _gb_to_bytes(self, gb: int) -> int: + if gb == 0: + return 0 + return gb * 1024 * 1024 * 1024 + + def _bytes_to_gb(self, bytes_value: int) -> float: + if bytes_value == 0: + return 0.0 + return bytes_value / (1024 * 1024 * 1024) \ No newline at end of file diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py new file mode 100644 index 00000000..f4265572 --- /dev/null +++ b/app/services/tribute_service.py @@ -0,0 +1,284 @@ +import logging +import hashlib +import hmac +import json +from typing import Optional, Dict, Any +from datetime import datetime + +from aiogram import Bot +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.database import get_db +from app.database.models import Transaction, TransactionType, PaymentMethod +from app.database.crud.transaction import ( + create_transaction, get_transaction_by_external_id, complete_transaction +) +from app.database.crud.user import get_user_by_telegram_id, add_user_balance +from app.external.tribute import TributeService as TributeAPI + +logger = logging.getLogger(__name__) + + +class TributeService: + + def __init__(self, bot: Bot): + self.bot = bot + self.tribute_api = TributeAPI() + + async def create_payment_link( + self, + user_id: int, + amount_kopeks: int, + description: str = "Пополнение баланса" + ) -> Optional[str]: + + if not settings.TRIBUTE_ENABLED: + logger.warning("Tribute платежи отключены") + return None + + try: + payment_url = await self.tribute_api.create_payment_link( + user_id=user_id, + amount_kopeks=amount_kopeks, + description=description + ) + + if not payment_url: + return None + + + return payment_url + + except Exception as e: + logger.error(f"Ошибка создания Tribute платежа: {e}") + return None + + async def process_webhook( + self, + payload: str, + signature: Optional[str] = None + ) -> Dict[str, Any]: + + if signature and settings.TRIBUTE_WEBHOOK_SECRET: + if not self.tribute_api.verify_webhook_signature(payload, signature): + logger.warning("Неверная подпись Tribute webhook") + return {"status": "error", "reason": "invalid_signature"} + + try: + webhook_data = json.loads(payload) + except json.JSONDecodeError: + logger.error("Некорректный JSON в Tribute webhook") + return {"status": "error", "reason": "invalid_json"} + + logger.info(f"Получен Tribute webhook: {json.dumps(webhook_data, ensure_ascii=False)}") + + processed_data = await self.tribute_api.process_webhook(webhook_data) + if not processed_data: + return {"status": "ignored", "reason": "invalid_data"} + + event_type = processed_data.get("event_type", "payment") + status = processed_data.get("status") + + if event_type == "payment" and status == "paid": + await self._handle_successful_payment(processed_data) + elif event_type == "payment" and status == "failed": + await self._handle_failed_payment(processed_data) + elif event_type == "refund": + await self._handle_refund(processed_data) + + return {"status": "ok", "event": event_type} + + async def _handle_successful_payment(self, payment_data: Dict[str, Any]): + + try: + user_id = payment_data["user_id"] + amount_kopeks = payment_data["amount_kopeks"] + payment_id = payment_data["payment_id"] + + async for session in get_db(): + existing_transaction = await get_transaction_by_external_id( + session, f"donation_{payment_id}", PaymentMethod.TRIBUTE + ) + + if existing_transaction: + logger.warning(f"Транзакция с donation_request_id {payment_id} уже существует") + return + + user = await get_user_by_telegram_id(session, user_id) + if not user: + logger.error(f"Пользователь {user_id} не найден") + return + + transaction = await create_transaction( + db=session, + user_id=user.id, + type=TransactionType.DEPOSIT, + amount_kopeks=amount_kopeks, + description=f"Пополнение через Tribute: {amount_kopeks/100}₽", + payment_method=PaymentMethod.TRIBUTE, + external_id=f"donation_{payment_id}", + is_completed=True + ) + + user.balance_kopeks += amount_kopeks + await session.commit() + + await self._send_success_notification(user_id, amount_kopeks) + + logger.info(f"Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_id}") + break + + except Exception as e: + logger.error(f"Ошибка обработки успешного Tribute платежа: {e}") + + async def _handle_failed_payment(self, payment_data: Dict[str, Any]): + + try: + user_id = payment_data["user_id"] + payment_id = payment_data["payment_id"] + + async for session in get_db(): + transaction = await get_transaction_by_external_id( + session, payment_id, PaymentMethod.TRIBUTE + ) + + if transaction: + transaction.description = f"{transaction.description} (платеж отклонен)" + await session.commit() + + await self._send_failure_notification(user_id) + + logger.info(f"Обработан неудачный Tribute платеж для пользователя {user_id}") + break + + except Exception as e: + logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}") + + async def _handle_refund(self, refund_data: Dict[str, Any]): + + try: + user_id = refund_data["user_id"] + amount_kopeks = refund_data["amount_kopeks"] + payment_id = refund_data["payment_id"] + + async for session in get_db(): + await create_transaction( + db=session, + user_id=user_id, + type=TransactionType.REFUND, + amount_kopeks=-amount_kopeks, + description=f"Возврат Tribute платежа {payment_id}", + payment_method=PaymentMethod.TRIBUTE, + external_id=f"refund_{payment_id}", + is_completed=True + ) + + user = await get_user_by_telegram_id(session, user_id) + if user and user.balance_kopeks >= amount_kopeks: + user.balance_kopeks -= amount_kopeks + await session.commit() + + await self._send_refund_notification(user_id, amount_kopeks) + + logger.info(f"Обработан возврат Tribute: {amount_kopeks/100}₽ для пользователя {user_id}") + break + + except Exception as e: + logger.error(f"Ошибка обработки возврата Tribute: {e}") + + async def _send_success_notification(self, user_id: int, amount_kopeks: int): + + try: + amount_rubles = amount_kopeks / 100 + + text = ( + f"✅ **Платеж успешно получен!**\n\n" + f"💰 Сумма: {amount_rubles:.2f} ₽\n" + f"💳 Способ оплаты: Tribute\n" + f"🎉 Средства зачислены на баланс!\n\n" + f"Спасибо за оплату! 🙏" + ) + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], + [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")] + ]) + + await self.bot.send_message( + user_id, + text, + reply_markup=keyboard, + parse_mode="Markdown" + ) + + except Exception as e: + logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}") + + async def _send_failure_notification(self, user_id: int): + + try: + text = ( + "❌ **Платеж не прошел**\n\n" + "К сожалению, ваш платеж через Tribute был отклонен.\n\n" + "Возможные причины:\n" + "• Недостаточно средств на карте\n" + "• Технические проблемы банка\n" + "• Превышен лимит операций\n\n" + "Попробуйте еще раз или обратитесь в поддержку." + ) + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="menu_balance")], + [InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")] + ]) + + await self.bot.send_message( + user_id, + text, + reply_markup=keyboard, + parse_mode="Markdown" + ) + + except Exception as e: + logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}") + + async def _send_refund_notification(self, user_id: int, amount_kopeks: int): + + try: + amount_rubles = amount_kopeks / 100 + + text = ( + f"🔄 **Возврат средств**\n\n" + f"💰 Сумма возврата: {amount_rubles:.2f} ₽\n" + f"💳 Способ: Tribute\n\n" + f"Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n" + f"Если у вас есть вопросы, обратитесь в поддержку." + ) + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")], + [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")] + ]) + + await self.bot.send_message( + user_id, + text, + reply_markup=keyboard, + parse_mode="Markdown" + ) + + except Exception as e: + logger.error(f"Ошибка отправки уведомления о возврате: {e}") + + async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]: + return await self.tribute_api.get_payment_status(payment_id) + + async def create_refund( + self, + payment_id: str, + amount_kopeks: Optional[int] = None, + reason: str = "Возврат по запросу" + ) -> Optional[Dict[str, Any]]: + return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason) \ No newline at end of file diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 00000000..6c77f1ba --- /dev/null +++ b/app/services/user_service.py @@ -0,0 +1,333 @@ +import logging +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.user import ( + get_user_by_id, get_user_by_telegram_id, get_users_list, + get_users_count, get_users_statistics, get_inactive_users, + add_user_balance, subtract_user_balance, update_user, delete_user +) +from app.database.crud.transaction import get_user_transactions_count +from app.database.crud.subscription import get_subscription_by_user_id +from app.database.models import User, UserStatus +from app.config import settings + +logger = logging.getLogger(__name__) + + +class UserService: + + async def get_user_profile( + self, + db: AsyncSession, + user_id: int + ) -> Optional[Dict[str, Any]]: + try: + user = await get_user_by_id(db, user_id) + if not user: + return None + + subscription = await get_subscription_by_user_id(db, user_id) + transactions_count = await get_user_transactions_count(db, user_id) + + return { + "user": user, + "subscription": subscription, + "transactions_count": transactions_count, + "is_admin": settings.is_admin(user.telegram_id), + "registration_days": (datetime.utcnow() - user.created_at).days + } + + except Exception as e: + logger.error(f"Ошибка получения профиля пользователя {user_id}: {e}") + return None + + async def search_users( + self, + db: AsyncSession, + query: str, + page: int = 1, + limit: int = 20 + ) -> Dict[str, Any]: + try: + offset = (page - 1) * limit + + users = await get_users_list( + db, offset=offset, limit=limit, search=query + ) + total_count = await get_users_count(db, search=query) + + total_pages = (total_count + limit - 1) // limit + + return { + "users": users, + "current_page": page, + "total_pages": total_pages, + "total_count": total_count, + "has_next": page < total_pages, + "has_prev": page > 1 + } + + except Exception as e: + logger.error(f"Ошибка поиска пользователей: {e}") + return { + "users": [], + "current_page": 1, + "total_pages": 1, + "total_count": 0, + "has_next": False, + "has_prev": False + } + + async def get_users_page( + self, + db: AsyncSession, + page: int = 1, + limit: int = 20, + status: Optional[UserStatus] = None + ) -> Dict[str, Any]: + try: + offset = (page - 1) * limit + + users = await get_users_list( + db, offset=offset, limit=limit, status=status + ) + total_count = await get_users_count(db, status=status) + + total_pages = (total_count + limit - 1) // limit + + return { + "users": users, + "current_page": page, + "total_pages": total_pages, + "total_count": total_count, + "has_next": page < total_pages, + "has_prev": page > 1 + } + + except Exception as e: + logger.error(f"Ошибка получения страницы пользователей: {e}") + return { + "users": [], + "current_page": 1, + "total_pages": 1, + "total_count": 0, + "has_next": False, + "has_prev": False + } + + async def update_user_balance( + self, + db: AsyncSession, + user_id: int, + amount_kopeks: int, + description: str, + admin_id: int + ) -> bool: + try: + user = await get_user_by_id(db, user_id) + if not user: + return False + + if amount_kopeks > 0: + await add_user_balance(db, user, amount_kopeks, description) + logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽") + else: + success = await subtract_user_balance(db, user, abs(amount_kopeks), description) + if success: + logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}₽") + return success + + return True + + except Exception as e: + logger.error(f"Ошибка изменения баланса пользователя: {e}") + return False + + async def block_user( + self, + db: AsyncSession, + user_id: int, + admin_id: int, + reason: str = "Заблокирован администратором" + ) -> bool: + try: + user = await get_user_by_id(db, user_id) + if not user: + return False + + await update_user(db, user, status=UserStatus.BLOCKED.value) + + logger.info(f"Админ {admin_id} заблокировал пользователя {user_id}: {reason}") + return True + + except Exception as e: + logger.error(f"Ошибка блокировки пользователя: {e}") + return False + + async def unblock_user( + self, + db: AsyncSession, + user_id: int, + admin_id: int + ) -> bool: + try: + user = await get_user_by_id(db, user_id) + if not user: + return False + + await update_user(db, user, status=UserStatus.ACTIVE.value) + + logger.info(f"Админ {admin_id} разблокировал пользователя {user_id}") + return True + + except Exception as e: + logger.error(f"Ошибка разблокировки пользователя: {e}") + return False + + async def delete_user_account( + self, + db: AsyncSession, + user_id: int, + admin_id: int + ) -> bool: + try: + user = await get_user_by_id(db, user_id) + if not user: + return False + + success = await delete_user(db, user) + + if success: + logger.info(f"Админ {admin_id} удалил пользователя {user_id}") + + return success + + except Exception as e: + logger.error(f"Ошибка удаления пользователя: {e}") + return False + + async def get_user_statistics(self, db: AsyncSession) -> Dict[str, Any]: + try: + stats = await get_users_statistics(db) + return stats + + except Exception as e: + logger.error(f"Ошибка получения статистики пользователей: {e}") + return { + "total_users": 0, + "active_users": 0, + "blocked_users": 0, + "new_today": 0, + "new_week": 0, + "new_month": 0 + } + + async def cleanup_inactive_users( + self, + db: AsyncSession, + months: int = None + ) -> int: + try: + if months is None: + months = settings.INACTIVE_USER_DELETE_MONTHS + + inactive_users = await get_inactive_users(db, months) + deleted_count = 0 + + for user in inactive_users: + success = await delete_user(db, user) + if success: + deleted_count += 1 + + logger.info(f"Удалено {deleted_count} неактивных пользователей") + return deleted_count + + except Exception as e: + logger.error(f"Ошибка очистки неактивных пользователей: {e}") + return 0 + + async def get_user_activity_summary( + self, + db: AsyncSession, + user_id: int + ) -> Dict[str, Any]: + try: + user = await get_user_by_id(db, user_id) + if not user: + return {} + + subscription = await get_subscription_by_user_id(db, user_id) + transactions_count = await get_user_transactions_count(db, user_id) + + days_since_registration = (datetime.utcnow() - user.created_at).days + + days_since_activity = (datetime.utcnow() - user.last_activity).days if user.last_activity else None + + return { + "user_id": user.id, + "telegram_id": user.telegram_id, + "username": user.username, + "full_name": user.full_name, + "status": user.status, + "language": user.language, + "balance_kopeks": user.balance_kopeks, + "registration_date": user.created_at, + "last_activity": user.last_activity, + "days_since_registration": days_since_registration, + "days_since_activity": days_since_activity, + "has_subscription": subscription is not None, + "subscription_active": subscription.is_active if subscription else False, + "subscription_trial": subscription.is_trial if subscription else False, + "transactions_count": transactions_count, + "referrer_id": user.referrer_id, + "referral_code": user.referral_code + } + + except Exception as e: + logger.error(f"Ошибка получения сводки активности пользователя {user_id}: {e}") + return {} + + async def get_users_by_criteria( + self, + db: AsyncSession, + criteria: Dict[str, Any] + ) -> List[User]: + try: + status = criteria.get('status') + has_subscription = criteria.get('has_subscription') + is_trial = criteria.get('is_trial') + min_balance = criteria.get('min_balance', 0) + max_balance = criteria.get('max_balance') + days_inactive = criteria.get('days_inactive') + + registered_after = criteria.get('registered_after') + registered_before = criteria.get('registered_before') + + users = await get_users_list(db, offset=0, limit=10000, status=status) + + filtered_users = [] + for user in users: + if user.balance_kopeks < min_balance: + continue + if max_balance and user.balance_kopeks > max_balance: + continue + + if registered_after and user.created_at < registered_after: + continue + if registered_before and user.created_at > registered_before: + continue + + if days_inactive and user.last_activity: + inactive_threshold = datetime.utcnow() - timedelta(days=days_inactive) + if user.last_activity > inactive_threshold: + continue + + filtered_users.append(user) + + return filtered_users + + except Exception as e: + logger.error(f"Ошибка получения пользователей по критериям: {e}") + return [] \ No newline at end of file diff --git a/app/states.py b/app/states.py new file mode 100644 index 00000000..eb0a7fde --- /dev/null +++ b/app/states.py @@ -0,0 +1,82 @@ +from aiogram.fsm.state import State, StatesGroup + + +class RegistrationStates(StatesGroup): + waiting_for_rules_accept = State() + waiting_for_referral_code = State() + + +class SubscriptionStates(StatesGroup): + selecting_period = State() + selecting_traffic = State() + selecting_countries = State() + selecting_devices = State() + confirming_purchase = State() + + adding_countries = State() + adding_traffic = State() + adding_devices = State() + extending_subscription = State() + confirming_traffic_reset = State() + + +class BalanceStates(StatesGroup): + waiting_for_amount = State() + waiting_for_stars_payment = State() + waiting_for_support_request = State() + + +class PromoCodeStates(StatesGroup): + waiting_for_code = State() + waiting_for_referral_code = State() + + +class AdminStates(StatesGroup): + + waiting_for_user_search = State() + editing_user_balance = State() + editing_user_subscription = State() + + creating_promocode = State() + setting_promocode_type = State() + setting_promocode_value = State() + setting_promocode_uses = State() + setting_promocode_expiry = State() + + waiting_for_broadcast_message = State() + confirming_broadcast = State() + + editing_squad_price = State() + editing_traffic_price = State() + editing_device_price = State() + + editing_rules_page = State() + + confirming_sync = State() + + editing_server_name = State() + editing_server_price = State() + editing_server_country = State() + editing_server_limit = State() + editing_server_description = State() + + creating_server_uuid = State() + creating_server_name = State() + creating_server_price = State() + creating_server_country = State() + + +class SupportStates(StatesGroup): + waiting_for_message = State() + + +class AutoPayStates(StatesGroup): + setting_autopay_days = State() + confirming_autopay_toggle = State() + +class SquadCreateStates(StatesGroup): + waiting_for_name = State() + selecting_inbounds = State() + +class SquadRenameStates(StatesGroup): + waiting_for_new_name = State() \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 00000000..77a7fa05 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1,3 @@ +""" +Утилиты +""" \ No newline at end of file diff --git a/app/utils/cache.py b/app/utils/cache.py new file mode 100644 index 00000000..408f7246 --- /dev/null +++ b/app/utils/cache.py @@ -0,0 +1,264 @@ +import json +import logging +from typing import Any, Optional, Union +from datetime import datetime, timedelta +import redis.asyncio as redis + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class CacheService: + + def __init__(self): + self.redis_client: Optional[redis.Redis] = None + self._connected = False + + async def connect(self): + try: + self.redis_client = redis.from_url(settings.REDIS_URL) + await self.redis_client.ping() + self._connected = True + logger.info("✅ Подключение к Redis кешу установлено") + except Exception as e: + logger.warning(f"⚠️ Не удалось подключиться к Redis: {e}") + self._connected = False + + async def disconnect(self): + if self.redis_client: + await self.redis_client.close() + self._connected = False + + async def get(self, key: str) -> Optional[Any]: + if not self._connected: + return None + + try: + value = await self.redis_client.get(key) + if value: + return json.loads(value) + return None + except Exception as e: + logger.error(f"Ошибка получения из кеша {key}: {e}") + return None + + async def set( + self, + key: str, + value: Any, + expire: Union[int, timedelta] = None + ) -> bool: + if not self._connected: + return False + + try: + serialized_value = json.dumps(value, default=str) + + if isinstance(expire, timedelta): + expire = int(expire.total_seconds()) + + await self.redis_client.set(key, serialized_value, ex=expire) + return True + except Exception as e: + logger.error(f"Ошибка записи в кеш {key}: {e}") + return False + + async def delete(self, key: str) -> bool: + if not self._connected: + return False + + try: + deleted = await self.redis_client.delete(key) + return deleted > 0 + except Exception as e: + logger.error(f"Ошибка удаления из кеша {key}: {e}") + return False + + async def exists(self, key: str) -> bool: + if not self._connected: + return False + + try: + return await self.redis_client.exists(key) + except Exception as e: + logger.error(f"Ошибка проверки существования в кеше {key}: {e}") + return False + + async def expire(self, key: str, seconds: int) -> bool: + if not self._connected: + return False + + try: + return await self.redis_client.expire(key, seconds) + except Exception as e: + logger.error(f"Ошибка установки TTL для {key}: {e}") + return False + + async def get_keys(self, pattern: str = "*") -> list: + if not self._connected: + return [] + + try: + keys = await self.redis_client.keys(pattern) + return [key.decode() if isinstance(key, bytes) else key for key in keys] + except Exception as e: + logger.error(f"Ошибка получения ключей по паттерну {pattern}: {e}") + return [] + + async def flush_all(self) -> bool: + if not self._connected: + return False + + try: + await self.redis_client.flushall() + logger.info("🗑️ Кеш полностью очищен") + return True + except Exception as e: + logger.error(f"Ошибка очистки кеша: {e}") + return False + + async def increment(self, key: str, amount: int = 1) -> Optional[int]: + if not self._connected: + return None + + try: + return await self.redis_client.incrby(key, amount) + except Exception as e: + logger.error(f"Ошибка инкремента {key}: {e}") + return None + + async def set_hash(self, name: str, mapping: dict, expire: int = None) -> bool: + if not self._connected: + return False + + try: + await self.redis_client.hset(name, mapping=mapping) + if expire: + await self.redis_client.expire(name, expire) + return True + except Exception as e: + logger.error(f"Ошибка записи хеша {name}: {e}") + return False + + async def get_hash(self, name: str, key: str = None) -> Optional[Union[dict, str]]: + if not self._connected: + return None + + try: + if key: + value = await self.redis_client.hget(name, key) + return value.decode() if value else None + else: + hash_data = await self.redis_client.hgetall(name) + return {k.decode(): v.decode() for k, v in hash_data.items()} + except Exception as e: + logger.error(f"Ошибка получения хеша {name}: {e}") + return None + + +cache = CacheService() + + +def cache_key(*parts) -> str: + return ":".join(str(part) for part in parts) + + +async def cached_function(key: str, expire: int = 300): + def decorator(func): + async def wrapper(*args, **kwargs): + cache_result = await cache.get(key) + if cache_result is not None: + return cache_result + + result = await func(*args, **kwargs) + await cache.set(key, result, expire) + return result + + return wrapper + return decorator + + +class UserCache: + + @staticmethod + async def get_user_data(user_id: int) -> Optional[dict]: + key = cache_key("user", user_id) + return await cache.get(key) + + @staticmethod + async def set_user_data(user_id: int, data: dict, expire: int = 3600) -> bool: + key = cache_key("user", user_id) + return await cache.set(key, data, expire) + + @staticmethod + async def delete_user_data(user_id: int) -> bool: + key = cache_key("user", user_id) + return await cache.delete(key) + + @staticmethod + async def get_user_session(user_id: int, session_key: str) -> Optional[Any]: + key = cache_key("session", user_id, session_key) + return await cache.get(key) + + @staticmethod + async def set_user_session( + user_id: int, + session_key: str, + data: Any, + expire: int = 1800 + ) -> bool: + key = cache_key("session", user_id, session_key) + return await cache.set(key, data, expire) + + +class SystemCache: + + @staticmethod + async def get_system_stats() -> Optional[dict]: + return await cache.get("system:stats") + + @staticmethod + async def set_system_stats(stats: dict, expire: int = 300) -> bool: + return await cache.set("system:stats", stats, expire) + + @staticmethod + async def get_nodes_status() -> Optional[list]: + return await cache.get("remnawave:nodes") + + @staticmethod + async def set_nodes_status(nodes: list, expire: int = 60) -> bool: + return await cache.set("remnawave:nodes", nodes, expire) + + @staticmethod + async def get_daily_stats(date: str) -> Optional[dict]: + key = cache_key("stats", "daily", date) + return await cache.get(key) + + @staticmethod + async def set_daily_stats(date: str, stats: dict) -> bool: + key = cache_key("stats", "daily", date) + return await cache.set(key, stats, 86400) # 24 часа + + +class RateLimitCache: + + @staticmethod + async def is_rate_limited(user_id: int, action: str, limit: int, window: int) -> bool: + key = cache_key("rate_limit", user_id, action) + current = await cache.get(key) + + if current is None: + await cache.set(key, 1, window) + return False + + if current >= limit: + return True + + await cache.increment(key) + return False + + @staticmethod + async def reset_rate_limit(user_id: int, action: str) -> bool: + key = cache_key("rate_limit", user_id, action) + return await cache.delete(key) \ No newline at end of file diff --git a/app/utils/decorators.py b/app/utils/decorators.py new file mode 100644 index 00000000..8acf22f9 --- /dev/null +++ b/app/utils/decorators.py @@ -0,0 +1,117 @@ +import logging +import functools +from typing import Callable, Any +from aiogram import types +from aiogram.fsm.context import FSMContext + +from app.config import settings +from app.localization.texts import get_texts + +logger = logging.getLogger(__name__) + + +def admin_required(func: Callable) -> Callable: + + @functools.wraps(func) + async def wrapper( + event: types.Update, + *args, + **kwargs + ) -> Any: + user = None + if isinstance(event, (types.Message, types.CallbackQuery)): + user = event.from_user + + if not user or not settings.is_admin(user.id): + texts = get_texts() + + if isinstance(event, types.Message): + await event.answer(texts.ACCESS_DENIED) + elif isinstance(event, types.CallbackQuery): + await event.answer(texts.ACCESS_DENIED, show_alert=True) + + logger.warning(f"Попытка доступа к админской функции от {user.id if user else 'Unknown'}") + return + + return await func(event, *args, **kwargs) + + return wrapper + + +def error_handler(func: Callable) -> Callable: + + @functools.wraps(func) + async def wrapper(*args, **kwargs) -> Any: + try: + return await func(*args, **kwargs) + except Exception as e: + logger.error(f"Ошибка в {func.__name__}: {e}", exc_info=True) + + event = None + db_user = kwargs.get('db_user') + + for arg in args: + if isinstance(arg, (types.Message, types.CallbackQuery)): + event = arg + break + + if event: + texts = get_texts(db_user.language if db_user else 'ru') + + if isinstance(event, types.Message): + await event.answer(texts.ERROR) + elif isinstance(event, types.CallbackQuery): + await event.answer(texts.ERROR, show_alert=True) + + return wrapper + + +def state_cleanup(func: Callable) -> Callable: + + @functools.wraps(func) + async def wrapper(*args, **kwargs) -> Any: + state = kwargs.get('state') + + try: + return await func(*args, **kwargs) + except Exception as e: + if state and isinstance(state, FSMContext): + await state.clear() + raise e + + return wrapper + + +def typing_action(func: Callable) -> Callable: + + @functools.wraps(func) + async def wrapper( + event: types.Update, + *args, + **kwargs + ) -> Any: + if isinstance(event, types.Message): + await event.bot.send_chat_action( + chat_id=event.chat.id, + action="typing" + ) + + return await func(event, *args, **kwargs) + + return wrapper + + +def rate_limit(rate: float = 1.0, key: str = None): + def decorator(func: Callable) -> Callable: + + @functools.wraps(func) + async def wrapper( + event: types.Update, + *args, + **kwargs + ) -> Any: + return await func(event, *args, **kwargs) + + return wrapper + + return decorator \ No newline at end of file diff --git a/app/utils/formatters.py b/app/utils/formatters.py new file mode 100644 index 00000000..d7e8ce41 --- /dev/null +++ b/app/utils/formatters.py @@ -0,0 +1,207 @@ +from datetime import datetime, timedelta +from typing import Union, Optional + + +def format_datetime(dt: Union[datetime, str], format_str: str = "%d.%m.%Y %H:%M") -> str: + if isinstance(dt, str): + if dt == "now" or dt == "": + dt = datetime.now() + else: + try: + dt = datetime.fromisoformat(dt.replace('Z', '+00:00')) + except (ValueError, AttributeError): + dt = datetime.now() + + return dt.strftime(format_str) + + +def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str: + if isinstance(dt, str): + if dt == "now" or dt == "": + dt = datetime.now() + else: + try: + dt = datetime.fromisoformat(dt.replace('Z', '+00:00')) + except (ValueError, AttributeError): + dt = datetime.now() + + return dt.strftime(format_str) + + +def format_time_ago(dt: Union[datetime, str]) -> str: + if isinstance(dt, str): + if dt == "now" or dt == "": + dt = datetime.now() + else: + try: + dt = datetime.fromisoformat(dt.replace('Z', '+00:00')) + except (ValueError, AttributeError): + dt = datetime.now() + + now = datetime.utcnow() + diff = now - dt + + if diff.days > 0: + if diff.days == 1: + return "вчера" + elif diff.days < 7: + return f"{diff.days} дн. назад" + elif diff.days < 30: + weeks = diff.days // 7 + return f"{weeks} нед. назад" + elif diff.days < 365: + months = diff.days // 30 + return f"{months} мес. назад" + else: + years = diff.days // 365 + return f"{years} г. назад" + + elif diff.seconds > 3600: + hours = diff.seconds // 3600 + return f"{hours} ч. назад" + + elif diff.seconds > 60: + minutes = diff.seconds // 60 + return f"{minutes} мин. назад" + + else: + return "только что" + + +def format_duration(seconds: int) -> str: + if seconds < 60: + return f"{seconds} сек." + + minutes = seconds // 60 + if minutes < 60: + return f"{minutes} мин." + + hours = minutes // 60 + if hours < 24: + return f"{hours} ч." + + days = hours // 24 + return f"{days} дн." + + +def format_bytes(bytes_value: int) -> str: + if bytes_value == 0: + return "0 B" + + units = ["B", "KB", "MB", "GB", "TB"] + size = float(bytes_value) + unit_index = 0 + + while size >= 1024 and unit_index < len(units) - 1: + size /= 1024 + unit_index += 1 + + if size == int(size): + return f"{int(size)} {units[unit_index]}" + else: + return f"{size:.1f} {units[unit_index]}" + + +def format_percentage(value: float, decimals: int = 1) -> str: + return f"{value:.{decimals}f}%" + + +def format_number(number: Union[int, float], separator: str = " ") -> str: + if isinstance(number, float): + integer_part = int(number) + decimal_part = number - integer_part + + formatted_integer = f"{integer_part:,}".replace(",", separator) + + if decimal_part > 0: + return f"{formatted_integer}.{decimal_part:.2f}".split('.')[0] + f".{str(decimal_part).split('.')[1][:2]}" + else: + return formatted_integer + else: + return f"{number:,}".replace(",", separator) + + +def format_price_range(min_price: int, max_price: int) -> str: + from app.config import settings + + min_formatted = settings.format_price(min_price) + max_formatted = settings.format_price(max_price) + + if min_price == max_price: + return min_formatted + else: + return f"{min_formatted} - {max_formatted}" + + +def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str: + if len(text) <= max_length: + return text + + return text[:max_length - len(suffix)] + suffix + + +def format_username(username: Optional[str], user_id: int, full_name: Optional[str] = None) -> str: + if full_name: + return full_name + elif username: + return f"@{username}" + else: + return f"ID{user_id}" + + +def format_subscription_status( + is_active: bool, + is_trial: bool, + end_date: Union[datetime, str], + language: str = "ru" +) -> str: + + if isinstance(end_date, str): + try: + end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + except (ValueError, AttributeError): + end_date = datetime.now() + + if not is_active: + return "❌ Неактивна" if language == "ru" else "❌ Inactive" + + if is_trial: + status = "🎁 Тестовая" if language == "ru" else "🎁 Trial" + else: + status = "✅ Активна" if language == "ru" else "✅ Active" + + now = datetime.utcnow() + if end_date > now: + days_left = (end_date - now).days + if days_left > 0: + status += f" ({days_left} дн.)" if language == "ru" else f" ({days_left} days)" + else: + hours_left = (end_date - now).seconds // 3600 + status += f" ({hours_left} ч.)" if language == "ru" else f" ({hours_left} hrs)" + else: + status = "⏰ Истекла" if language == "ru" else "⏰ Expired" + + return status + + +def format_traffic_usage(used_gb: float, limit_gb: int, language: str = "ru") -> str: + + if limit_gb == 0: + if language == "ru": + return f"{used_gb:.1f} ГБ / ∞" + else: + return f"{used_gb:.1f} GB / ∞" + + percentage = (used_gb / limit_gb) * 100 if limit_gb > 0 else 0 + + if language == "ru": + return f"{used_gb:.1f} ГБ / {limit_gb} ГБ ({percentage:.1f}%)" + else: + return f"{used_gb:.1f} GB / {limit_gb} GB ({percentage:.1f}%)" + + +def format_boolean(value: bool, language: str = "ru") -> str: + if language == "ru": + return "✅ Да" if value else "❌ Нет" + else: + return "✅ Yes" if value else "❌ No" \ No newline at end of file diff --git a/app/utils/pagination.py b/app/utils/pagination.py new file mode 100644 index 00000000..b70dc5cc --- /dev/null +++ b/app/utils/pagination.py @@ -0,0 +1,82 @@ +from typing import List, TypeVar, Generic, Dict, Any +from math import ceil + +T = TypeVar('T') + + +class PaginationResult(Generic[T]): + + def __init__( + self, + items: List[T], + total_count: int, + page: int, + per_page: int + ): + self.items = items + self.total_count = total_count + self.page = page + self.per_page = per_page + self.total_pages = ceil(total_count / per_page) if per_page > 0 else 1 + self.has_prev = page > 1 + self.has_next = page < self.total_pages + self.prev_page = page - 1 if self.has_prev else None + self.next_page = page + 1 if self.has_next else None + + +def paginate_list( + items: List[T], + page: int = 1, + per_page: int = 10 +) -> PaginationResult[T]: + total_count = len(items) + + start_index = (page - 1) * per_page + end_index = start_index + per_page + + page_items = items[start_index:end_index] + + return PaginationResult( + items=page_items, + total_count=total_count, + page=page, + per_page=per_page + ) + + +def get_pagination_info( + total_count: int, + page: int = 1, + per_page: int = 10 +) -> Dict[str, Any]: + total_pages = ceil(total_count / per_page) if per_page > 0 else 1 + + return { + "total_count": total_count, + "page": page, + "per_page": per_page, + "total_pages": total_pages, + "has_prev": page > 1, + "has_next": page < total_pages, + "prev_page": page - 1 if page > 1 else None, + "next_page": page + 1 if page < total_pages else None, + "offset": (page - 1) * per_page + } + + +def get_page_numbers( + current_page: int, + total_pages: int, + max_visible: int = 5 +) -> List[int]: + if total_pages <= max_visible: + return list(range(1, total_pages + 1)) + + half_visible = max_visible // 2 + start_page = max(1, current_page - half_visible) + end_page = min(total_pages, start_page + max_visible - 1) + + if end_page - start_page + 1 < max_visible: + start_page = max(1, end_page - max_visible + 1) + + return list(range(start_page, end_page + 1)) \ No newline at end of file diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py new file mode 100644 index 00000000..d218d61b --- /dev/null +++ b/app/utils/user_utils.py @@ -0,0 +1,76 @@ +import logging +import random +import string +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database.models import User + +logger = logging.getLogger(__name__) + + +async def mark_user_as_had_paid_subscription( + db: AsyncSession, + user: User +) -> None: + if not user.has_had_paid_subscription: + user.has_had_paid_subscription = True + user.updated_at = datetime.utcnow() + await db.commit() + logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку") + + +async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str: + + base_code = str(telegram_id)[-6:] + + for attempt in range(10): + if attempt == 0: + referral_code = base_code + else: + suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=2)) + referral_code = base_code + suffix + + result = await db.execute( + select(User.id).where(User.referral_code == referral_code) + ) + + if not result.scalar(): + return referral_code + + import uuid + return str(uuid.uuid4())[:8] + + +async def get_user_referral_summary(db: AsyncSession, user_id: int) -> dict: + + try: + from app.services.referral_service import get_referral_stats_for_user + from app.database.crud.referral import get_referral_earnings_by_user + + stats = await get_referral_stats_for_user(db, user_id) + + recent_earnings = await get_referral_earnings_by_user(db, user_id, limit=5) + + return { + **stats, + "recent_earnings": [ + { + "amount_kopeks": earning.amount_kopeks, + "reason": earning.reason, + "created_at": earning.created_at, + "referral_name": earning.referral.full_name if earning.referral else "Неизвестно" + } + for earning in recent_earnings + ] + } + + except Exception as e: + logger.error(f"Ошибка получения сводки рефералов: {e}") + return { + "invited_count": 0, + "paid_referrals_count": 0, + "total_earned_kopeks": 0, + "month_earned_kopeks": 0, + "recent_earnings": [] + } \ No newline at end of file diff --git a/app/utils/validators.py b/app/utils/validators.py new file mode 100644 index 00000000..9818bdb7 --- /dev/null +++ b/app/utils/validators.py @@ -0,0 +1,137 @@ +import re +from typing import Optional, Union +from datetime import datetime + + +def validate_email(email: str) -> bool: + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return re.match(pattern, email) is not None + + +def validate_phone(phone: str) -> bool: + pattern = r'^\+?[1-9]\d{1,14}$' + cleaned_phone = re.sub(r'[\s\-\(\)]', '', phone) + return re.match(pattern, cleaned_phone) is not None + + +def validate_telegram_username(username: str) -> bool: + if not username: + return False + username = username.lstrip('@') + pattern = r'^[a-zA-Z0-9_]{5,32}$' + return re.match(pattern, username) is not None + + +def validate_promocode(code: str) -> bool: + if not code or len(code) < 3 or len(code) > 20: + return False + return code.replace('_', '').replace('-', '').isalnum() + + +def validate_amount(amount_str: str, min_amount: float = 0, max_amount: float = float('inf')) -> Optional[float]: + try: + amount = float(amount_str.replace(',', '.')) + if min_amount <= amount <= max_amount: + return amount + return None + except (ValueError, TypeError): + return None + + +def validate_positive_integer(value: Union[str, int], max_value: int = None) -> Optional[int]: + try: + num = int(value) + if num > 0 and (max_value is None or num <= max_value): + return num + return None + except (ValueError, TypeError): + return None + + +def validate_date_string(date_str: str, date_format: str = "%Y-%m-%d") -> Optional[datetime]: + try: + return datetime.strptime(date_str, date_format) + except ValueError: + return None + + +def validate_url(url: str) -> bool: + pattern = r'^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$' + return re.match(pattern, url) is not None + + +def validate_uuid(uuid_str: str) -> bool: + pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + return re.match(pattern, uuid_str.lower()) is not None + + +def validate_traffic_amount(traffic_str: str) -> Optional[int]: + traffic_str = traffic_str.upper().strip() + + if traffic_str in ['UNLIMITED', 'БЕЗЛИМИТ', '∞']: + return 0 + + units = { + 'MB': 1, + 'GB': 1024, + 'TB': 1024 * 1024, + 'МБ': 1, + 'ГБ': 1024, + 'ТБ': 1024 * 1024 + } + + for unit, multiplier in units.items(): + if traffic_str.endswith(unit): + try: + value = float(traffic_str[:-len(unit)].strip()) + return int(value * multiplier) + except ValueError: + break + + try: + return int(float(traffic_str)) + except ValueError: + return None + + +def validate_subscription_period(days: Union[str, int]) -> Optional[int]: + try: + days_int = int(days) + if 1 <= days_int <= 3650: + return days_int + return None + except (ValueError, TypeError): + return None + + +def sanitize_html(text: str) -> str: + allowed_tags = ['b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'code', 'pre'] + + for tag in allowed_tags: + text = re.sub(f'<{tag}>', f'<{tag}>', text, flags=re.IGNORECASE) + text = re.sub(f'', f'', text, flags=re.IGNORECASE) + + text = re.sub(r'<(?!/?(?:' + '|'.join(allowed_tags) + r')\b)[^>]*>', '', text) + + return text + + +def validate_device_count(count: Union[str, int]) -> Optional[int]: + try: + count_int = int(count) + if 1 <= count_int <= 10: + return count_int + return None + except (ValueError, TypeError): + return None + + +def validate_referral_code(code: str) -> bool: + if not code: + return False + + if code.startswith('ref') and len(code) > 3: + user_id_part = code[3:] + return user_id_part.isdigit() + + return validate_promocode(code) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 00000000..bb63f2f9 --- /dev/null +++ b/main.py @@ -0,0 +1,81 @@ +import asyncio +import logging +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).parent)) + +from app.bot import setup_bot +from app.config import settings +from app.database.database import init_db +from app.services.monitoring_service import monitoring_service +from app.external.webhook_server import WebhookServer + + +async def main(): + logging.basicConfig( + level=getattr(logging, settings.LOG_LEVEL), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(settings.LOG_FILE, encoding='utf-8'), + logging.StreamHandler(sys.stdout) + ] + ) + + logger = logging.getLogger(__name__) + logger.info("🚀 Запуск VPN бота...") + + webhook_server = None + + try: + logger.info("📊 Инициализация базы данных...") + await init_db() + + logger.info("🤖 Настройка бота...") + bot, dp = await setup_bot() + + monitoring_service.bot = bot + + # Инициализируем webhook сервер если Tribute включен + if settings.TRIBUTE_ENABLED: + logger.info("🌐 Запуск webhook сервера для Tribute...") + webhook_server = WebhookServer(bot) + await webhook_server.start() + else: + logger.info("ℹ️ Tribute отключен, webhook сервер не запускается") + + logger.info("🔍 Запуск службы мониторинга...") + monitoring_task = asyncio.create_task(monitoring_service.start_monitoring()) + + logger.info("🔄 Запуск polling...") + + try: + await asyncio.gather( + dp.start_polling(bot), + monitoring_task + ) + except Exception as e: + logger.error(f"Ошибка в основном цикле: {e}") + monitoring_service.stop_monitoring() + if webhook_server: + await webhook_server.stop() + raise + + except Exception as e: + logger.error(f"❌ Критическая ошибка при запуске: {e}") + raise + finally: + logger.info("🛑 Завершение работы бота") + monitoring_service.stop_monitoring() + if webhook_server: + await webhook_server.stop() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\n🛑 Бот остановлен пользователем") + except Exception as e: + print(f"❌ Критическая ошибка: {e}") + sys.exit(1) \ No newline at end of file diff --git a/migrations/alembic/alembic.ini b/migrations/alembic/alembic.ini new file mode 100644 index 00000000..8bb34d06 --- /dev/null +++ b/migrations/alembic/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = migrations/alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/migrations/alembic/env.py b/migrations/alembic/env.py new file mode 100644 index 00000000..8fab7583 --- /dev/null +++ b/migrations/alembic/env.py @@ -0,0 +1,67 @@ +import asyncio +import sys +from pathlib import Path +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +sys.path.append(str(Path(__file__).parent.parent.parent)) + +from app.database.models import Base +from app.config import settings + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..382d6c09 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +# Основные зависимости +aiogram==3.4.1 +aiohttp==3.9.1 +asyncpg==0.29.0 +SQLAlchemy==2.0.25 +alembic==1.13.1 +aiosqlite==0.19.0 + +# Дополнительные зависимости +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-dotenv==1.0.0 +redis==5.0.1 + +# Логирование и мониторинг +structlog==23.2.0 + +# Утилиты +python-dateutil==2.8.2 +pytz==2023.4 +cryptography>=41.0.0