NEW VERSION

NEW VERSION
This commit is contained in:
Egor
2025-08-20 23:57:04 +03:00
committed by GitHub
parent 92a07e2acf
commit 736e4c6cae
66 changed files with 18793 additions and 0 deletions

88
app/bot.py Normal file
View File

@@ -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

171
app/config.py Normal file
View File

@@ -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,
}

View File

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

View File

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

View File

@@ -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 """
🔒 <b>Правила использования сервиса</b>
1. Сервис предоставляется "как есть" без каких-либо гарантий.
2. Запрещается использование сервиса для незаконных действий.
3. Администрация оставляет за собой право заблокировать доступ пользователя при нарушении правил.
4. Возврат средств осуществляется в соответствии с политикой возврата.
5. Пользователь несет полную ответственность за безопасность своего аккаунта.
6. При возникновении вопросов обращайтесь в техническую поддержку.
Используя сервис, вы соглашаетесь с данными правилами.
"""

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

327
app/database/crud/user.py Normal file
View File

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

51
app/database/database.py Normal file
View File

@@ -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("✅ Подключение к базе данных закрыто")

421
app/database/models.py Normal file
View File

@@ -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")

521
app/external/remnawave_api.py vendored Normal file
View File

@@ -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

100
app/external/telegram_stars.py vendored Normal file
View File

@@ -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)

108
app/external/tribute.py vendored Normal file
View File

@@ -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

98
app/external/webhook_server.py vendored Normal file
View File

@@ -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
})

0
app/handlers/__init__.py Normal file
View File

View File

@@ -0,0 +1 @@
# Инициализация админских обработчиков

View File

@@ -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"
)

View File

@@ -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 = """
📨 <b>Управление рассылками</b>
Выберите тип рассылки:
- <b>Всем пользователям</b> - рассылка всем активным пользователям
- <b>По подпискам</b> - фильтрация по типу подписки
- <b>По критериям</b> - настраиваемые фильтры
- <b>История</b> - просмотр предыдущих рассылок
⚠️ Будьте осторожны с массовыми рассылками!
"""
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(
"🎯 <b>Выбор целевой аудитории</b>\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 = """
📋 <b>История рассылок</b>
❌ История рассылок пуста.
Отправьте первую рассылку, чтобы увидеть её здесь.
"""
keyboard = [[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]]
else:
text = f"📋 <b>История рассылок</b> (страница {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} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
🎯 Аудитория: {get_target_name(broadcast.target_type)}
👤 Админ: {broadcast.admin_name}
📝 Сообщение: <i>{message_preview}</i>
━━━━━━━━━━━━━━━━━━━━
"""
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"""
🔍 <b>Рассылка по критериям</b>
📊 <b>Доступные фильтры:</b>
👥 <b>По регистрации:</b>
• Сегодня: {stats['today']} чел.
За неделю: {stats['week']} чел.
За месяц: {stats['month']} чел.
💼 <b>По активности:</b>
• Активные сегодня: {stats['active_today']} чел.
• Неактивные 7+ дней: {stats['inactive_week']} чел.
• Неактивные 30+ дней: {stats['inactive_month']} чел.
🔗 <b>По источнику:</b>
• Через рефералов: {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"📨 <b>Создание рассылки</b>\n\n"
f"🎯 <b>Критерий:</b> {criteria_names.get(criteria, criteria)}\n"
f"👥 <b>Получателей:</b> {user_count}\n\n"
f"Введите текст сообщения для рассылки:\n\n"
f"<i>Поддерживается HTML разметка</i>",
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"📨 <b>Создание рассылки</b>\n\n"
f"🎯 <b>Аудитория:</b> {target_names.get(target, target)}\n"
f"👥 <b>Получателей:</b> {user_count}\n\n"
f"Введите текст сообщения для рассылки:\n\n"
f"<i>Поддерживается HTML разметка</i>",
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"""
📨 <b>Предварительный просмотр рассылки</b>
🎯 <b>Аудитория:</b> {target_display}
👥 <b>Получателей:</b> {user_count}
📝 <b>Сообщение:</b>
{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"""
✅ <b>Рассылка завершена!</b>
📊 <b>Результат:</b>
- Отправлено: {sent_count}
- Не доставлено: {failed_count}
- Всего пользователей: {len(users)}
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%
<b>Администратор:</b> {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)

View File

@@ -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"""
🔍 <b>Система мониторинга</b>
📊 <b>Статус:</b> {running_status}
🕐 <b>Последнее обновление:</b> {last_update}
⚙️ <b>Интервал проверки:</b> {settings.MONITORING_INTERVAL} мин
📈 <b>Статистика за 24 часа:</b>
Всего событий: {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"""
✅ <b>Принудительная проверка завершена</b>
📊 <b>Результаты проверки:</b>
• Истекших подписок: {results['expired']}
• Истекающих подписок: {results['expiring']}
• Готовых к автооплате: {results['autopay_ready']}
🕐 <b>Время проверки:</b> {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 = "📝 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполняла проверки."
else:
text = "📝 <b>Последние логи мониторинга:</b>\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} <code>{time_str}</code> {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<i>Показаны последние записи. Для просмотра всех логов используйте файл логов.</i>"
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"""
🧪 <b>Тестовое уведомление системы мониторинга</b>
Это тестовое сообщение для проверки работы системы уведомлений.
📊 <b>Статус системы:</b>
• Мониторинг: {'🟢 Работает' 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"""
📊 <b>Статистика мониторинга</b>
📱 <b>Подписки:</b>
Всего: {sub_stats['total_subscriptions']}
• Активных: {sub_stats['active_subscriptions']}
• Тестовых: {sub_stats['trial_subscriptions']}
• Платных: {sub_stats['paid_subscriptions']}
📈 <b>За сегодня:</b>
• Успешных операций: {mon_status['stats_24h']['successful']}
• Ошибок: {mon_status['stats_24h']['failed']}
• Успешность: {mon_status['stats_24h']['success_rate']}%
📊 <b>За неделю:</b>
Всего событий: {len(week_logs)}
• Успешных: {week_success}
• Ошибок: {week_errors}
• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
🔧 <b>Система:</b>
• Интервал: {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"""
🔍 <b>Быстрый статус мониторинга</b>
📊 <b>Статус:</b> {running_status}
📈 <b>События за 24ч:</b> {status['stats_24h']['total_events']}
✅ <b>Успешность:</b> {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)

View File

@@ -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"""
🎫 <b>Управление промокодами</b>
📊 <b>Статистика:</b>
- Всего промокодов: {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"🎫 <b>Список промокодов</b> (стр. {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} <code>{promo.code}</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"""
🎫 <b>Управление промокодом</b>
{type_emoji} <b>Код:</b> <code>{promo.code}</code>
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
"""
if promo.type == PromoCodeType.BALANCE.value:
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
if promo.valid_until:
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
text += f"📅 <b>Создан:</b> {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"""
⚠️ <b>Подтверждение удаления</b>
Вы действительно хотите удалить промокод <code>{promo.code}</code>?
<b>Внимание:</b> Это действие нельзя отменить!
"""
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"""
📊 <b>Статистика промокода</b> <code>{promo.code}</code>
📈 <b>Общая статистика:</b>
- Всего использований: {stats['total_uses']}
- Использований сегодня: {stats['today_uses']}
- Осталось использований: {promo.max_uses - promo.current_uses}
📅 <b>Последние использования:</b>
"""
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(
"🎫 <b>Создание промокода</b>\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"🎫 <b>Создание промокода</b>\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"💰 <b>Промокод:</b> <code>{code}</code>\n\n"
f"Введите сумму пополнения баланса (в рублях):"
)
await state.set_state(AdminStates.setting_promocode_value)
elif promo_type == "days":
await message.answer(
f"📅 <b>Промокод:</b> <code>{code}</code>\n\n"
f"Введите количество дней подписки:"
)
await state.set_state(AdminStates.setting_promocode_value)
elif promo_type == "trial":
await message.answer(
f"🎁 <b>Промокод:</b> <code>{code}</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"""
✅ <b>Промокод создан!</b>
🎫 <b>Код:</b> <code>{promocode.code}</code>
📝 <b>Тип:</b> {type_names.get(promo_type)}
"""
if promo_type == "balance":
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
elif promo_type in ["days", "trial"]:
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
if promocode.valid_until:
summary_text += f"⏰ <b>Действует до:</b> {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)

View File

@@ -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"""
🤝 <b>Реферальная статистика</b>
<b>Общие показатели:</b>
- Пользователей с рефералами: {stats['users_with_referrals']}
- Активных рефереров: {stats['active_referrers']}
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
<b>За период:</b>
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
<b>Средние показатели:</b>
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
<b>Топ-5 рефереров:</b>
"""
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"""
<b>Настройки:</b>
- Бонус за регистрацию: {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")

File diff suppressed because it is too large Load Diff

168
app/handlers/admin/rules.py Normal file
View File

@@ -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 = """
📋 <b>Управление правилами сервиса</b>
Текущие правила показываются пользователям при регистрации и в главном меню.
Выберите действие:
"""
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"📋 <b>Текущие правила сервиса</b>\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(
"✏️ <b>Редактирование правил</b>\n\n"
f"<b>Текущие правила:</b>\n{current_rules[:500]}{'...' if len(current_rules) > 500 else ''}\n\n"
"Отправьте новый текст правил сервиса.\n\n"
"<i>Поддерживается HTML разметка</i>",
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"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\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(
"✅ <b>Правила сервиса обновлены!</b>\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)

View File

@@ -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"""
🌐 <b>Управление серверами</b>
📊 <b>Статистика:</b>
Всего серверов: {stats['total_servers']}
• Доступные: {stats['available_servers']}
• Недоступные: {stats['unavailable_servers']}
С подключениями: {stats['servers_with_connections']}
💰 <b>Выручка от серверов:</b>
• Общая: {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 = "🌐 <b>Список серверов</b>\n\n❌ Серверы не найдены."
else:
text = f"🌐 <b>Список серверов</b>\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: <code>{server.squad_uuid}</code>\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"""
✅ <b>Синхронизация завершена</b>
📊 <b>Результаты:</b>
• Создано новых серверов: {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"""
🌐 <b>Редактирование сервера</b>
<b>Информация:</b>
• ID: {server.id}
• UUID: <code>{server.squad_uuid}</code>
• Название: {server.display_name}
• Оригинальное: {server.original_name or 'Не указано'}
• Статус: {status_emoji}
<b>Настройки:</b>
• Цена: {price_text}
• Код страны: {server.country_code or 'Не указан'}
• Лимит пользователей: {server.max_users or 'Без лимита'}
• Текущих пользователей: {server.current_users}
<b>Описание:</b>
{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"""
🌐 <b>Редактирование сервера</b>
<b>Информация:</b>
• ID: {server.id}
• UUID: <code>{server.squad_uuid}</code>
• Название: {server.display_name}
• Оригинальное: {server.original_name or 'Не указано'}
• Статус: {status_emoji}
<b>Настройки:</b>
• Цена: {price_text}
• Код страны: {server.country_code or 'Не указан'}
• Лимит пользователей: {server.max_users or 'Без лимита'}
• Текущих пользователей: {server.current_users}
<b>Описание:</b>
{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"💰 <b>Редактирование цены</b>\n\n"
f"Текущая цена: <b>{current_price}</b>\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"✅ Цена сервера изменена на: <b>{price_text}</b>",
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"✏️ <b>Редактирование названия</b>\n\n"
f"Текущее название: <b>{server.display_name}</b>\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"✅ Название сервера изменено на: <b>{new_name}</b>",
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"""
🗑️ <b>Удаление сервера</b>
Вы действительно хотите удалить сервер:
<b>{server.display_name}</b>
⚠️ <b>Внимание!</b>
Сервер можно удалить только если к нему нет активных подключений.
Это действие нельзя отменить!
"""
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"✅ Сервер <b>{server.display_name}</b> успешно удален!",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📋 К списку серверов", callback_data="admin_servers_list")]
]),
parse_mode="HTML"
)
else:
await callback.message.edit_text(
f"Не удалось удалить сервер <b>{server.display_name}</b>\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"""
📊 <b>Подробная статистика серверов</b>
<b>🌐 Общая информация:</b>
Всего серверов: {stats['total_servers']}
• Доступные: {stats['available_servers']}
• Недоступные: {stats['unavailable_servers']}
С активными подключениями: {stats['servers_with_connections']}
<b>💰 Финансовая статистика:</b>
• Общая выручка: {stats['total_revenue_rubles']:.2f}
• Средняя цена за сервер: {(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1)):.2f}
<b>🔥 Топ серверов по цене:</b>
"""
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"🌍 <b>Редактирование кода страны</b>\n\n"
f"Текущий код страны: <b>{current_country}</b>\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"✅ Код страны изменен на: <b>{country_text}</b>",
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"👥 <b>Редактирование лимита пользователей</b>\n\n"
f"Текущий лимит: <b>{current_limit}</b>\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"✅ Лимит пользователей изменен на: <b>{limit_text}</b>",
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"📝 <b>Редактирование описания</b>\n\n"
f"Текущее описание:\n<i>{current_desc}</i>\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<i>{desc_text}</i>",
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"""
✅ <b>Синхронизация завершена</b>
📊 <b>Результат:</b>
• Обновлено серверов: {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_"))

View File

@@ -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 = """
📊 <b>Статистика системы</b>
Выберите раздел для просмотра статистики:
"""
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"""
👥 <b>Статистика пользователей</b>
<b>Общие показатели:</b>
- Всего зарегистрировано: {stats['total_users']}
- Активных: {stats['active_users']} ({active_rate})
- Заблокированных: {stats['blocked_users']}
<b>Новые регистрации:</b>
- Сегодня: {stats['new_today']}
- За неделю: {stats['new_week']}
- За месяц: {stats['new_month']}
<b>Активность:</b>
- Коэффициент активности: {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"""
📱 <b>Статистика подписок</b>
<b>Общие показатели:</b>
- Всего подписок: {stats['total_subscriptions']}
- Активных: {stats['active_subscriptions']}
- Платных: {stats['paid_subscriptions']}
- Триальных: {stats['trial_subscriptions']}
<b>Конверсия:</b>
- Из триала в платную: {conversion_rate}
- Активных платных: {stats['paid_subscriptions']}
<b>Продажи:</b>
- Сегодня: {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"""
💰 <b>Статистика доходов</b>
<b>За текущий месяц:</b>
- Доходы: {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'])}
<b>Сегодня:</b>
- Транзакций: {month_stats['today']['transactions_count']}
- Доходы: {settings.format_price(month_stats['today']['income_kopeks'])}
<b>За все время:</b>
- Общий доход: {settings.format_price(all_time_stats['totals']['income_kopeks'])}
- Общая прибыль: {settings.format_price(all_time_stats['totals']['profit_kopeks'])}
<b>Способы оплаты:</b>
"""
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"""
🤝 <b>Реферальная статистика</b>
<b>Общие показатели:</b>
- Пользователей с рефералами: {stats['users_with_referrals']}
- Активных рефереров: {stats['active_referrers']}
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
<b>За период:</b>
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
<b>Средние показатели:</b>
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
<b>Топ рефереры:</b>
"""
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"""
📊 <b>Общая сводка системы</b>
<b>Пользователи:</b>
- Всего: {user_stats['total_users']}
- Активных: {user_stats['active_users']}
- Новых за месяц: {user_stats['new_month']}
<b>Подписки:</b>
- Активных: {sub_stats['active_subscriptions']}
- Платных: {sub_stats['paid_subscriptions']}
- Конверсия: {format_percentage(conversion_rate)}
<b>Финансы (месяц):</b>
- Доходы: {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())}
<b>Рост:</b>
- Пользователи: +{user_stats['new_month']} за месяц
- Продажи: +{sub_stats['purchased_month']} за месяц
<b>Обновлено:</b> {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"""
📈 <b>Доходы за период: {period}</b>
<b>Сводка:</b>
- Общий доход: {settings.format_price(total_revenue)}
- Дней с данными: {len(revenue_data)}
- Средний доход в день: {settings.format_price(int(avg_daily))}
<b>По дням:</b>
"""
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}"
)

View File

@@ -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"""
📱 <b>Управление подписками</b>
📊 <b>Статистика:</b>
- Всего: {stats['total_subscriptions']}
- Активных: {stats['active_subscriptions']}
- Платных: {stats['paid_subscriptions']}
- Триальных: {stats['trial_subscriptions']}
📈 <b>Продажи:</b>
- Сегодня: {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 = "📱 <b>Список подписок</b>\n\n❌ Подписки не найдены."
else:
text = f"📱 <b>Список подписок</b>\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"""
⏰ <b>Истекающие подписки</b>
📊 <b>Статистика:</b>
- Истекают через 3 дня: {len(expiring_3d)}
- Истекают завтра: {len(expiring_1d)}
- Уже истекли: {len(expired)}
<b>Истекают через 3 дня:</b>
"""
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<b>Истекают завтра:</b>\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"""
📊 <b>Детальная статистика подписок</b>
<b>📱 Общая информация:</b>
Всего подписок: {stats['total_subscriptions']}
• Активных: {stats['active_subscriptions']}
• Неактивных: {stats['total_subscriptions'] - stats['active_subscriptions']}
<b>💎 По типам:</b>
• Платных: {stats['paid_subscriptions']}
• Триальных: {stats['trial_subscriptions']}
<b>📈 Продажи:</b>
• Сегодня: {stats['purchased_today']}
За неделю: {stats['purchased_week']}
За месяц: {stats['purchased_month']}
<b>⏰ Истечение:</b>
• Истекают через 3 дня: {len(expiring_3d)}
• Истекают через 7 дней: {len(expiring_7d)}
• Уже истекли: {len(expired)}
<b>💰 Конверсия:</b>
• Из триала в платную: {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"""
⚙️ <b>Настройки цен</b>
<b>Периоды подписки:</b>
- 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)}
<b>Трафик-пакеты:</b>
- 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)}
<b>Дополнительно:</b>
- За устройство: {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 = "🌍 <b>Управление странами</b>\n\n"
if nodes_data:
text += "<b>Доступные серверы:</b>\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<b>Всего сквадов:</b> {len(squads_data)}\n"
total_members = sum(squad.get('members_count', 0) for squad in squads_data)
text += f"<b>Участников в сквадах:</b> {total_members}\n"
text += "\n<b>Сквады:</b>\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<b>Пользователи по регионам:</b>\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"""
🌍 <b>Управление странами</b>
❌ <b>Ошибка загрузки данных</b>
Не удалось получить информацию о серверах.
Проверьте подключение к RemnaWave API.
<b>Детали ошибки:</b> {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"""
⚠️ <b>Подписка истекает!</b>
Ваша подписка истекает через {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_")
)

856
app/handlers/admin/users.py Normal file
View File

@@ -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"""
👥 <b>Управление пользователями</b>
📊 <b>Статистика:</b>
Всего: {stats['total_users']}
• Активных: {stats['active_users']}
• Заблокированных: {stats['blocked_users']}
📈 <b>Новые пользователи:</b>
• Сегодня: {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"👥 <b>Список пользователей</b> (стр. {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} <b>{user.full_name}</b>\n"
text += f"🆔 <code>{user.telegram_id}</code>\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(
"🔍 <b>Поиск пользователя</b>\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"""
📊 <b>Детальная статистика пользователей</b>
👥 <b>Общие показатели:</b>
Всего: {stats['total_users']}
• Активных: {stats['active_users']}
• Заблокированных: {stats['blocked_users']}
📱 <b>Подписки:</b>
С активной подпиской: {users_with_subscription}
На триале: {trial_users}
• Без подписки: {stats['active_users'] - users_with_subscription}
💰 <b>Финансы:</b>
• Средний баланс: {settings.format_price(int(avg_balance))}
📈 <b>Регистрации:</b>
• Сегодня: {stats['new_today']}
За неделю: {stats['new_week']}
За месяц: {stats['new_month']}
📊 <b>Активность:</b>
• Конверсия в подписку: {(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"📱 <b>Подписка пользователя</b>\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
if subscription:
status_emoji = "" if subscription.is_active else ""
type_emoji = "🎁" if subscription.is_trial else "💎"
text += f"<b>Статус:</b> {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n"
text += f"<b>Тип:</b> {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n"
text += f"<b>Начало:</b> {format_datetime(subscription.start_date)}\n"
text += f"<b>Окончание:</b> {format_datetime(subscription.end_date)}\n"
text += f"<b>Трафик:</b> {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n"
text += f"<b>Устройства:</b> {subscription.device_limit}\n"
text += f"<b>Подключенных устройств:</b> {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"<b>Осталось дней:</b> {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 += "❌ <b>Подписка отсутствует</b>\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"💳 <b>Транзакции пользователя</b>\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n"
text += f"💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n"
if transactions:
text += "<b>Последние транзакции:</b>\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 += "📭 <b>Транзакции отсутствуют</b>"
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(
"🗑️ <b>Удаление пользователя</b>\n\n"
"⚠️ <b>ВНИМАНИЕ!</b>\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"🔍 По запросу '<b>{query}</b>' ничего не найдено",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")]
])
)
await state.clear()
return
text = f"🔍 <b>Результаты поиска:</b> '{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} <b>{user.full_name}</b>\n"
text += f"🆔 <code>{user.telegram_id}</code>\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"""
👤 <b>Управление пользователем</b>
<b>Основная информация:</b>
• Имя: {user.full_name}
• ID: <code>{user.telegram_id}</code>
• Username: @{user.username or 'не указан'}
• Статус: {status_text}
• Язык: {user.language}
<b>Финансы:</b>
• Баланс: {settings.format_price(user.balance_kopeks)}
• Транзакций: {profile['transactions_count']}
<b>Активность:</b>
• Регистрация: {format_datetime(user.created_at)}
• Последняя активность: {format_time_ago(user.last_activity) if user.last_activity else 'Неизвестно'}
• Дней с регистрации: {profile['registration_days']}
"""
if subscription:
text += f"""
<b>Подписка:</b>
• Тип: {'🎁 Триал' 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<b>Подписка:</b> Отсутствует"
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(
"💰 <b>Изменение баланса</b>\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(
"🚫 <b>Блокировка пользователя</b>\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"🗑️ <b>Неактивные пользователи</b>\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"🆔 <code>{user.telegram_id}</code>\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"
)

341
app/handlers/balance.py Normal file
View File

@@ -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 = "📊 <b>История операций</b>\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"⭐ <b>Оплата через Telegram Stars</b>\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"💳 <b>Пополнение банковской картой</b>\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"""
🛠️ <b>Пополнение через поддержку</b>
Для пополнения баланса обратитесь в техподдержку:
{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
)

85
app/handlers/common.py Normal file
View File

@@ -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"])
)

132
app/handlers/menu.py Normal file
View File

@@ -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 """
📋 <b>Правила использования сервиса</b>
1. Запрещается использование сервиса для незаконной деятельности
2. Запрещается нарушение авторских прав
3. Запрещается спам и рассылка вредоносного ПО
4. Запрещается использование сервиса для DDoS атак
5. Один аккаунт - один пользователь
6. Возврат средств производится только в исключительных случаях
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
"""
await callback.message.edit_text(
f"📋 <b>Правила сервиса</b>\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"
)

89
app/handlers/promocode.py Normal file
View File

@@ -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
)

144
app/handlers/referral.py Normal file
View File

@@ -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"👥 <b>Реферальная программа</b>\n\n"
referral_text += f"📊 <b>Ваша статистика:</b>\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"🎁 <b>Как работают награды:</b>\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"🔗 <b>Ваша реферальная ссылка:</b>\n"
referral_text += f"<code>{referral_link}</code>\n\n"
referral_text += f"🆔 <b>Ваш код:</b> <code>{db_user.referral_code}</code>\n\n"
if summary['recent_earnings']:
referral_text += f"💰 <b>Последние начисления:</b>\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"📝 <b>Приглашение создано!</b>\n\n"
f"Используйте кнопку ниже для отправки приглашения или скопируйте текст:\n\n"
f"<code>{invite_text}</code>",
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"
)

507
app/handlers/start.py Normal file
View File

@@ -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 ===")

1979
app/handlers/subscription.py Normal file

File diff suppressed because it is too large Load Diff

32
app/handlers/support.py Normal file
View File

@@ -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"
)

139
app/handlers/webhooks.py Normal file
View File

@@ -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="Ошибка обработки платежа")

529
app/keyboards/admin.py Normal file
View File

@@ -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)

594
app/keyboards/inline.py Normal file
View File

@@ -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)

116
app/keyboards/reply.py Normal file
View File

@@ -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
)

488
app/localization/texts.py Normal file
View File

@@ -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 """
🔒 <b>Service Usage Rules</b>
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
<b>By accepting the rules, you agree to comply with them.</b>
"""
else:
return """
📋 <b>Правила использования сервиса</b>
1. Запрещается использование сервиса для незаконной деятельности
2. Запрещается нарушение авторских прав
3. Запрещается спам и рассылка вредоносного ПО
4. Запрещается использование сервиса для DDoS атак
5. Один аккаунт - один пользователь
6. Возврат средств производится только в исключительных случаях
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
"""
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 = """
🎉 <b>Добро пожаловать в VPN сервис!</b>
Наш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.
🔐 <b>Преимущества:</b>
• Высокая скорость подключения
• Серверы в разных странах
• Надежная защита данных
• Круглосуточная поддержка
Для начала работы выберите язык интерфейса:
"""
LANGUAGE_SELECTED = "🌐 Язык интерфейса установлен: <b>Русский</b>"
RULES_ACCEPT = "✅ Принимаю правила"
RULES_DECLINE = "Не принимаю"
RULES_REQUIRED = "❗️ Для использования сервиса необходимо принять правила!"
REFERRAL_CODE_QUESTION = """
🤝 <b>У вас есть реферальный код от друга?</b>
Если у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!
Введите код или нажмите "Пропустить":
"""
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
MAIN_MENU = """
👤 <b>{user_name}</b>
💰 <b>Баланс:</b> {balance}
📱 <b>Подписка:</b> {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 = """
📱 <b>Информация о подписке</b>
📊 <b>Статус:</b> {status}
🎭 <b>Тип:</b> {type}
📅 <b>Действует до:</b> {end_date}
⏰ <b>Осталось дней:</b> {days_left}
📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}
🌍 <b>Серверы:</b> {countries_count} стран
📱 <b>Устройства:</b> {devices_used} / {devices_limit}
💳 <b>Автоплатеж:</b> {autopay_status}
"""
TRIAL_AVAILABLE = """
🎁 <b>Тестовая подписка</b>
Вы можете получить бесплатную тестовую подписку:
⏰ <b>Период:</b> {days} дней
📈 <b>Трафик:</b> {traffic} ГБ
📱 <b>Устройства:</b> {devices} шт.
🌍 <b>Сервер:</b> 1 страна
Активировать тестовую подписку?
"""
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
BUY_SUBSCRIPTION_START = """
💎 <b>Настройка подписки</b>
Давайте настроим вашу подписку под ваши потребности.
Сначала выберите период подписки:
"""
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 = """
📋 <b>Итоговая конфигурация</b>
📅 <b>Период:</b> {period} дней
📈 <b>Трафик:</b> {traffic}
🌍 <b>Страны:</b> {countries}
📱 <b>Устройства:</b> {devices}
💰 <b>Итого к оплате:</b> {total_price}
Подтвердить покупку?
"""
INSUFFICIENT_BALANCE = "❌ Недостаточно средств на балансе. Пополните баланс и попробуйте снова."
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
BALANCE_INFO = """
💰 <b>Баланс: {balance}</b>
Выберите действие:
"""
BALANCE_HISTORY = "📊 История операций"
BALANCE_TOP_UP = "💳 Пополнить"
BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку"
TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):"
TOP_UP_METHODS = """
💳 <b>Выберите способ оплаты</b>
Сумма: {amount}
"""
TOP_UP_STARS = "⭐ Telegram Stars"
TOP_UP_TRIBUTE = "💎 Банковская карта"
PROMOCODE_ENTER = "🎫 Введите промокод:"
PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}"
PROMOCODE_INVALID = "❌ Неверный промокод"
PROMOCODE_EXPIRED = "❌ Промокод истек"
PROMOCODE_USED = "❌ Промокод уже использован"
REFERRAL_INFO = """
🤝 <b>Реферальная программа</b>
👥 <b>Приглашено:</b> {referrals_count} друзей
💰 <b>Заработано:</b> {earned_amount}
🔗 <b>Ваша реферальная ссылка:</b>
<code>{referral_link}</code>
🎫 <b>Ваш промокод:</b>
<code>{referral_code}</code>
💰 <b>Условия:</b>
За каждого друга: {registration_bonus}
• Процент с пополнений: {commission_percent}%
"""
REFERRAL_INVITE_MESSAGE = """
🎯 <b>Приглашение в VPN сервис</b>
Привет! Приглашаю тебя в отличный VPN сервис!
🎁 По моей ссылке ты получишь бонус: {bonus}
🔗 Переходи: {link}
🎫 Или используй промокод: {code}
💪 Быстро, надежно, недорого!
"""
CREATE_INVITE = "📝 Создать приглашение"
TRIAL_ENDING_SOON = """
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через 2 часа.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
• 30 дней всего за {price}
• Безлимитный трафик
Все серверы доступны
• Поддержка до 3 устройств
⚡️ Успейте оформить до окончания тестового периода!
"""
SUBSCRIPTION_EXPIRING_PAID = """
⚠️ <b>Подписка истекает через {days} дней!</b>
Ваша платная подписка истекает {end_date}.
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически"
AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!"
SUBSCRIPTION_EXPIRED = """
❌ <b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
🔧 Доступ к серверам заблокирован до продления.
"""
AUTOPAY_SUCCESS = """
✅ <b>Автоплатеж выполнен</b>
Ваша подписка автоматически продлена на {days} дней.
Списано с баланса: {amount}
Новая дата окончания: {new_end_date}
"""
AUTOPAY_FAILED = """
❌ <b>Ошибка автоплатежа</b>
Не удалось списать средства для продления подписки.
💰 Ваш баланс: {balance}
💳 Требуется: {required}
Пополните баланс и продлите подписку вручную.
"""
SUPPORT_INFO = f"""
🛠️ <b>Техническая поддержка</b>
По всем вопросам обращайтесь к нашей поддержке:
👤 {settings.SUPPORT_USERNAME}
Мы поможем с:
• Настройкой подключения
• Решением технических проблем
• Вопросами по оплате
• Другими вопросами
⏰ Время ответа: обычно в течение 1-2 часов
"""
CONTACT_SUPPORT = "💬 Написать в поддержку"
ADMIN_PANEL = """
⚙️ <b>Административная панель</b>
Выберите раздел для управления:
"""
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 = """
⚠️ <b>Подписка истекает!</b>
Ваша подписка истекает через {days} дней.
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
"""
SUBSCRIPTION_EXPIRED = """
❌ <b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
"""
AUTOPAY_SUCCESS = """
✅ <b>Автоплатеж выполнен</b>
Ваша подписка автоматически продлена на {days} дней.
Списано с баланса: {amount}
"""
AUTOPAY_FAILED = """
❌ <b>Ошибка автоплатежа</b>
Не удалось списать средства для продления подписки.
Недостаточно средств на балансе: {balance}
Требуется: {required}
Пополните баланс и продлите подписку вручную.
"""
class EnglishTexts(Texts):
def __init__(self):
super().__init__("en")
WELCOME = """
🎉 <b>Welcome to VPN Service!</b>
Our service provides fast and secure internet access without restrictions.
🔐 <b>Advantages:</b>
• High connection speed
• Servers in different countries
• Reliable data protection
• 24/7 support
To get started, select interface language:
"""
LANGUAGE_SELECTED = "🌐 Interface language set: <b>English</b>"
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("✅ Кеш правил очищен")

93
app/middlewares/auth.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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)

3
app/services/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Сервисы бизнес-логики
"""

View File

@@ -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"""
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через 2 часа.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
🔥 <b>Специальное предложение:</b>
• 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()

View File

@@ -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

View File

@@ -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 "✅ Промокод активирован"

View File

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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 []

82
app/states.py Normal file
View File

@@ -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()

3
app/utils/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Утилиты
"""

264
app/utils/cache.py Normal file
View File

@@ -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)

117
app/utils/decorators.py Normal file
View File

@@ -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

207
app/utils/formatters.py Normal file
View File

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

82
app/utils/pagination.py Normal file
View File

@@ -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))

76
app/utils/user_utils.py Normal file
View File

@@ -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": []
}

137
app/utils/validators.py Normal file
View File

@@ -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)

81
main.py Normal file
View File

@@ -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)

View File

@@ -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

67
migrations/alembic/env.py Normal file
View File

@@ -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()

21
requirements.txt Normal file
View File

@@ -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