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'{tag}>', f'{tag}>', 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