Merge branch 'BEDOLAGA-DEV:main' into main
This commit is contained in:
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.1.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.1.1-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.1.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.1.1-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v3.1.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.1.1-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.1.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.1.1-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.1.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.1.1-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v3.1.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.1.1-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.1.0"
|
||||
ARG VERSION="v3.1.1"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
"""FastAPI dependencies for cabinet module."""
|
||||
|
||||
import logging
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
from aiogram import Bot
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import User
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.config import settings
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from .auth.jwt_handler import get_token_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
# Кешированный Bot для проверки подписки на канал
|
||||
_channel_check_bot: Optional[Bot] = None
|
||||
|
||||
|
||||
def _get_channel_check_bot() -> Bot:
|
||||
"""Получить или создать Bot для проверки подписки на канал."""
|
||||
global _channel_check_bot
|
||||
if _channel_check_bot is None:
|
||||
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
|
||||
return _channel_check_bot
|
||||
|
||||
|
||||
async def get_cabinet_db() -> AsyncSession:
|
||||
"""Get database session for cabinet operations."""
|
||||
@@ -40,6 +56,11 @@ async def get_current_cabinet_user(
|
||||
Raises:
|
||||
HTTPException: If token is invalid, expired, or user not found
|
||||
"""
|
||||
# Check maintenance mode first (except for admins - checked later)
|
||||
if maintenance_service.is_maintenance_active():
|
||||
# We need to check token first to see if user is admin
|
||||
pass # Will check after getting user
|
||||
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -80,6 +101,46 @@ async def get_current_cabinet_user(
|
||||
detail="User account is not active",
|
||||
)
|
||||
|
||||
# Check maintenance mode (allow admins to pass)
|
||||
if maintenance_service.is_maintenance_active():
|
||||
if not settings.is_admin(user.telegram_id):
|
||||
status_info = maintenance_service.get_status_info()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"code": "maintenance",
|
||||
"message": maintenance_service.get_maintenance_message() or "Service is under maintenance",
|
||||
"reason": status_info.get("reason"),
|
||||
},
|
||||
)
|
||||
|
||||
# Check required channel subscription
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
|
||||
# Skip check for admins
|
||||
if not settings.is_admin(user.telegram_id):
|
||||
try:
|
||||
bot = _get_channel_check_bot()
|
||||
chat_member = await bot.get_chat_member(
|
||||
chat_id=settings.CHANNEL_SUB_ID,
|
||||
user_id=user.telegram_id
|
||||
)
|
||||
# Не закрываем сессию - бот переиспользуется
|
||||
|
||||
if chat_member.status not in ["member", "administrator", "creator"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"code": "channel_subscription_required",
|
||||
"message": "Please subscribe to our channel to continue",
|
||||
"channel_link": settings.CHANNEL_LINK,
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check channel subscription for user {user.telegram_id}: {e}")
|
||||
# Don't block user if check fails
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -94,6 +94,16 @@ async def _store_refresh_token(
|
||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||
expires_at = get_refresh_token_expires_at()
|
||||
|
||||
# Check if token already exists (handles race conditions)
|
||||
existing = await db.execute(
|
||||
select(CabinetRefreshToken).where(
|
||||
CabinetRefreshToken.token_hash == token_hash
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
# Token already stored, skip
|
||||
return
|
||||
|
||||
token_record = CabinetRefreshToken(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
@@ -101,7 +111,11 @@ async def _store_refresh_token(
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(token_record)
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception:
|
||||
# Handle race condition if token was inserted between check and insert
|
||||
await db.rollback()
|
||||
|
||||
|
||||
@router.post("/telegram", response_model=AuthResponse)
|
||||
|
||||
@@ -31,6 +31,7 @@ BRANDING_LOGO_KEY = "CABINET_BRANDING_LOGO" # Stores "custom" or "default"
|
||||
THEME_COLORS_KEY = "CABINET_THEME_COLORS" # Stores JSON with theme colors
|
||||
ENABLED_THEMES_KEY = "CABINET_ENABLED_THEMES" # Stores JSON with enabled themes {"dark": true, "light": false}
|
||||
ANIMATION_ENABLED_KEY = "CABINET_ANIMATION_ENABLED" # Stores "true" or "false"
|
||||
FULLSCREEN_ENABLED_KEY = "CABINET_FULLSCREEN_ENABLED" # Stores "true" or "false"
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml"}
|
||||
@@ -106,6 +107,16 @@ class AnimationEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class FullscreenEnabledResponse(BaseModel):
|
||||
"""Fullscreen enabled setting."""
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class FullscreenEnabledUpdate(BaseModel):
|
||||
"""Request to update fullscreen setting."""
|
||||
enabled: bool
|
||||
|
||||
|
||||
# Default theme colors
|
||||
DEFAULT_THEME_COLORS = {
|
||||
"accent": "#3b82f6",
|
||||
@@ -549,3 +560,37 @@ async def update_animation_enabled(
|
||||
logger.info(f"Admin {admin.telegram_id} set animation enabled: {payload.enabled}")
|
||||
|
||||
return AnimationEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Fullscreen Routes ============
|
||||
|
||||
@router.get("/fullscreen", response_model=FullscreenEnabledResponse)
|
||||
async def get_fullscreen_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get fullscreen enabled setting.
|
||||
This is a public endpoint - no authentication required.
|
||||
"""
|
||||
fullscreen_value = await get_setting_value(db, FULLSCREEN_ENABLED_KEY)
|
||||
|
||||
if fullscreen_value is not None:
|
||||
enabled = fullscreen_value.lower() == "true"
|
||||
return FullscreenEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: disabled
|
||||
return FullscreenEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch("/fullscreen", response_model=FullscreenEnabledResponse)
|
||||
async def update_fullscreen_enabled(
|
||||
payload: FullscreenEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update fullscreen enabled setting. Admin only."""
|
||||
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}")
|
||||
|
||||
return FullscreenEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
@@ -32,6 +33,7 @@ from app.services.subscription_purchase_service import (
|
||||
PurchaseBalanceError,
|
||||
)
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.cache import cache, cache_key, RateLimitCache
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.subscription import (
|
||||
@@ -130,6 +132,9 @@ def _subscription_to_response(
|
||||
if last_charge:
|
||||
next_daily_charge_at = last_charge + timedelta(days=1)
|
||||
|
||||
# Проверяем настройку скрытия ссылки (скрывается только текст, кнопки работают)
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
|
||||
return SubscriptionResponse(
|
||||
id=subscription.id,
|
||||
status=actual_status, # Use actual_status instead of raw status
|
||||
@@ -149,6 +154,7 @@ def _subscription_to_response(
|
||||
autopay_enabled=subscription.autopay_enabled or False,
|
||||
autopay_days_before=subscription.autopay_days_before or 3,
|
||||
subscription_url=subscription.subscription_url,
|
||||
hide_subscription_link=hide_link,
|
||||
is_active=is_active,
|
||||
is_expired=is_expired,
|
||||
traffic_purchases=traffic_purchases or [],
|
||||
@@ -638,7 +644,10 @@ async def purchase_traffic(
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
if getattr(user, "remnawave_uuid", None):
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync traffic with RemnaWave: {e}")
|
||||
|
||||
@@ -1476,8 +1485,25 @@ async def purchase_tariff(
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync with RemnaWave
|
||||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
try:
|
||||
if getattr(user, "remnawave_uuid", None):
|
||||
await service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason="покупка тарифа (cabinet)",
|
||||
)
|
||||
else:
|
||||
await service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason="покупка тарифа (cabinet)",
|
||||
)
|
||||
except Exception as remnawave_error:
|
||||
logger.error(f"Failed to sync subscription with RemnaWave: {remnawave_error}")
|
||||
|
||||
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
|
||||
if not is_daily_tariff:
|
||||
@@ -1557,25 +1583,34 @@ async def purchase_devices(
|
||||
detail="Ваша подписка неактивна",
|
||||
)
|
||||
|
||||
# Get tariff for device price
|
||||
# Get tariff for device price (if exists)
|
||||
tariff = None
|
||||
if subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
if not tariff or not tariff.device_price_kopeks:
|
||||
# Determine device price and max limit from tariff or settings
|
||||
if tariff and tariff.device_price_kopeks:
|
||||
device_price = tariff.device_price_kopeks
|
||||
max_device_limit = tariff.max_device_limit
|
||||
else:
|
||||
# Classic mode - use settings
|
||||
device_price = settings.PRICE_PER_DEVICE
|
||||
max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
|
||||
if not device_price or device_price <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Докупка устройств недоступна для вашего тарифа",
|
||||
detail="Докупка устройств недоступна",
|
||||
)
|
||||
|
||||
# Check max device limit
|
||||
current_devices = subscription.device_limit or 1
|
||||
new_device_count = current_devices + request.devices
|
||||
if tariff.max_device_limit and new_device_count > tariff.max_device_limit:
|
||||
if max_device_limit and new_device_count > max_device_limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Максимальное количество устройств для вашего тарифа: {tariff.max_device_limit}",
|
||||
detail=f"Максимальное количество устройств: {max_device_limit}",
|
||||
)
|
||||
|
||||
# Calculate prorated price based on remaining days
|
||||
@@ -1589,7 +1624,7 @@ async def purchase_devices(
|
||||
total_days = 30 # Base period for device price calculation
|
||||
|
||||
# Price = device_price * devices * (days_left / 30)
|
||||
price_kopeks = int(tariff.device_price_kopeks * request.devices * days_left / total_days)
|
||||
price_kopeks = int(device_price * request.devices * days_left / total_days)
|
||||
price_kopeks = max(100, price_kopeks) # Minimum 1 ruble
|
||||
|
||||
# Check balance
|
||||
@@ -1621,7 +1656,13 @@ async def purchase_devices(
|
||||
|
||||
# Sync with RemnaWave
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
try:
|
||||
if getattr(user, "remnawave_uuid", None):
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
else:
|
||||
await service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync devices with RemnaWave: {e}")
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -1671,15 +1712,23 @@ async def get_device_price(
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
if not tariff or not tariff.device_price_kopeks:
|
||||
# Determine device price and max limit from tariff or settings
|
||||
if tariff and tariff.device_price_kopeks:
|
||||
device_price = tariff.device_price_kopeks
|
||||
max_device_limit = tariff.max_device_limit
|
||||
else:
|
||||
# Classic mode - use settings
|
||||
device_price = settings.PRICE_PER_DEVICE
|
||||
max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
|
||||
if not device_price or device_price <= 0:
|
||||
return {
|
||||
"available": False,
|
||||
"reason": "Докупка устройств недоступна для вашего тарифа",
|
||||
"reason": "Докупка устройств недоступна",
|
||||
}
|
||||
|
||||
# Check max device limit
|
||||
current_devices = subscription.device_limit or 1
|
||||
max_device_limit = tariff.max_device_limit
|
||||
can_add = max_device_limit - current_devices if max_device_limit else None
|
||||
|
||||
if max_device_limit and current_devices >= max_device_limit:
|
||||
@@ -1709,7 +1758,7 @@ async def get_device_price(
|
||||
days_left = max(1, (end_date - now).days)
|
||||
total_days = 30
|
||||
|
||||
price_per_device_kopeks = int(tariff.device_price_kopeks * days_left / total_days)
|
||||
price_per_device_kopeks = int(device_price * days_left / total_days)
|
||||
price_per_device_kopeks = max(100, price_per_device_kopeks)
|
||||
total_price_kopeks = price_per_device_kopeks * devices
|
||||
|
||||
@@ -1724,7 +1773,7 @@ async def get_device_price(
|
||||
"max_device_limit": max_device_limit,
|
||||
"can_add": can_add,
|
||||
"days_left": days_left,
|
||||
"base_device_price_kopeks": tariff.device_price_kopeks,
|
||||
"base_device_price_kopeks": device_price,
|
||||
}
|
||||
|
||||
|
||||
@@ -1796,67 +1845,127 @@ def _convert_remnawave_block_to_step(block: Dict[str, Any], url_scheme: str = ""
|
||||
return step
|
||||
|
||||
|
||||
# Known app URL schemes (fallback if RemnaWave doesn't provide urlScheme)
|
||||
KNOWN_APP_URL_SCHEMES = {
|
||||
# iOS
|
||||
"happ": "happ://add/",
|
||||
"streisand": "streisand://import/",
|
||||
"shadowrocket": "sub://",
|
||||
"shadow rocket": "sub://",
|
||||
"karing": "karing://install-config?url=",
|
||||
"foxray": "foxray://yiguo.dev/sub/add/?url=",
|
||||
"fox ray": "foxray://yiguo.dev/sub/add/?url=",
|
||||
"v2box": "v2box://install-sub?url=",
|
||||
"sing-box": "sing-box://import-remote-profile?url=",
|
||||
"singbox": "sing-box://import-remote-profile?url=",
|
||||
"quantumult x": "quantumult-x://add-resource?remote-resource=",
|
||||
"quantumultx": "quantumult-x://add-resource?remote-resource=",
|
||||
"quantumult": "quantumult-x://add-resource?remote-resource=",
|
||||
"surge": "surge3://install-config?url=",
|
||||
"loon": "loon://import?sub=",
|
||||
"stash": "stash://install-config?url=",
|
||||
# Android
|
||||
"v2rayn": "v2rayng://install-sub?url=",
|
||||
"v2rayng": "v2rayng://install-sub?url=",
|
||||
"v2ray ng": "v2rayng://install-sub?url=",
|
||||
"nekoray": "sn://subscription?url=",
|
||||
"nekobox": "sn://subscription?url=",
|
||||
"neko ray": "sn://subscription?url=",
|
||||
"neko box": "sn://subscription?url=",
|
||||
"surfboard": "surfboard://install-config?url=",
|
||||
# PC (Windows/macOS/Linux)
|
||||
"clash": "clash://install-config?url=",
|
||||
"clash meta": "clash://install-config?url=",
|
||||
"clash verge": "clash://install-config?url=",
|
||||
"clash verge rev": "clash://install-config?url=",
|
||||
"clashx": "clashx://install-config?url=",
|
||||
"clashx meta": "clash://install-config?url=",
|
||||
"clashx pro": "clash://install-config?url=",
|
||||
"flclash": "clash://install-config?url=",
|
||||
"flclashx": "clash://install-config?url=",
|
||||
"koala clash": "clash://install-config?url=",
|
||||
"koalaclash": "clash://install-config?url=",
|
||||
"hiddify": "hiddify://install-config/?url=",
|
||||
"hiddify next": "hiddify://install-config/?url=",
|
||||
"mihomo party": "clash://install-config?url=",
|
||||
"mihomo": "clash://install-config?url=",
|
||||
}
|
||||
|
||||
def _extract_scheme_from_buttons(buttons: List[Dict[str, Any]]) -> str:
|
||||
"""Extract URL scheme from buttons list."""
|
||||
for btn in buttons:
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
link = btn.get("link", "") or btn.get("url", "") or btn.get("buttonLink", "")
|
||||
if not link:
|
||||
continue
|
||||
# Check for subscription link placeholder (case-insensitive)
|
||||
link_upper = link.upper()
|
||||
if "{{SUBSCRIPTION_LINK}}" in link_upper or "SUBSCRIPTION_LINK" in link_upper:
|
||||
# Extract scheme: "prizrak-box://install-config?url={{SUBSCRIPTION_LINK}}" -> "prizrak-box://install-config?url="
|
||||
scheme = re.sub(r'\{\{SUBSCRIPTION_LINK\}\}', '', link, flags=re.IGNORECASE)
|
||||
if scheme and "://" in scheme:
|
||||
return scheme
|
||||
# Also check for type="subscriptionLink" buttons with custom schemes
|
||||
btn_type = btn.get("type", "")
|
||||
if btn_type == "subscriptionLink" and "://" in link and not link.startswith("http"):
|
||||
# Extract base scheme from link like "prizrak-box://install-config?url="
|
||||
scheme = link.split("{{")[0] if "{{" in link else link
|
||||
if scheme and "://" in scheme:
|
||||
return scheme
|
||||
return ""
|
||||
|
||||
|
||||
def _get_url_scheme_for_app(app: Dict[str, Any]) -> str:
|
||||
"""Get URL scheme for app - from config, buttons, or fallback by name."""
|
||||
# 1. Check urlScheme field
|
||||
scheme = str(app.get("urlScheme", "")).strip()
|
||||
if scheme:
|
||||
return scheme
|
||||
|
||||
# 2. Extract from buttons in blocks (RemnaWave format)
|
||||
blocks = app.get("blocks", [])
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
buttons = block.get("buttons", [])
|
||||
scheme = _extract_scheme_from_buttons(buttons)
|
||||
if scheme:
|
||||
return scheme
|
||||
|
||||
# 3. Check buttons directly in app (alternative structure)
|
||||
direct_buttons = app.get("buttons", [])
|
||||
if direct_buttons:
|
||||
scheme = _extract_scheme_from_buttons(direct_buttons)
|
||||
if scheme:
|
||||
return scheme
|
||||
|
||||
# 4. Check in step structures (cabinet format)
|
||||
for step_key in ["installationStep", "addSubscriptionStep", "connectAndUseStep"]:
|
||||
step = app.get(step_key, {})
|
||||
if isinstance(step, dict):
|
||||
step_buttons = step.get("buttons", [])
|
||||
scheme = _extract_scheme_from_buttons(step_buttons)
|
||||
if scheme:
|
||||
return scheme
|
||||
|
||||
# No scheme found
|
||||
logger.debug(f"_get_url_scheme_for_app: No scheme found for app '{app.get('name')}', "
|
||||
f"has blocks: {bool(app.get('blocks'))}, "
|
||||
f"has buttons: {bool(app.get('buttons'))}, "
|
||||
f"has urlScheme: {bool(app.get('urlScheme'))}")
|
||||
return ""
|
||||
|
||||
|
||||
def _find_subscription_block(blocks: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""Find block that contains subscriptionLink button."""
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
buttons = block.get("buttons", [])
|
||||
for btn in buttons:
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
# Check for subscriptionLink type or {{SUBSCRIPTION_LINK}} in link
|
||||
btn_type = btn.get("type", "")
|
||||
link = btn.get("link", "") or btn.get("url", "")
|
||||
if btn_type == "subscriptionLink" or (link and "SUBSCRIPTION_LINK" in link.upper()):
|
||||
return block
|
||||
return None
|
||||
|
||||
|
||||
def _find_connect_block(blocks: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""Find block that is about connection/usage (usually last or has specific keywords)."""
|
||||
# Look for block with "connect" or "use" in title
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
title = block.get("title", {})
|
||||
title_en = title.get("en", "") if isinstance(title, dict) else ""
|
||||
title_lower = title_en.lower()
|
||||
if "connect" in title_lower or "use" in title_lower:
|
||||
return block
|
||||
# Fallback to last block if no match
|
||||
return blocks[-1] if blocks else None
|
||||
|
||||
|
||||
def _convert_remnawave_app_to_cabinet(app: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Convert RemnaWave app format to cabinet app format."""
|
||||
blocks = app.get("blocks", [])
|
||||
url_scheme = app.get("urlScheme", "")
|
||||
url_scheme = _get_url_scheme_for_app(app)
|
||||
|
||||
# If urlScheme is missing, try to determine from app name
|
||||
if not url_scheme:
|
||||
app_name = app.get("name", "").lower().strip()
|
||||
url_scheme = KNOWN_APP_URL_SCHEMES.get(app_name, "")
|
||||
# Debug log for conversion (не логируем отсутствие urlScheme - для Happ это нормально)
|
||||
app_name = app.get("name", "unknown")
|
||||
if url_scheme:
|
||||
logger.debug(f"_convert_remnawave_app_to_cabinet: app '{app_name}' -> urlScheme='{url_scheme}'")
|
||||
|
||||
# Map blocks to steps based on position
|
||||
installation_step = _convert_remnawave_block_to_step(blocks[0], url_scheme) if len(blocks) > 0 else {"description": {}}
|
||||
subscription_step = _convert_remnawave_block_to_step(blocks[1], url_scheme) if len(blocks) > 1 else {"description": {}}
|
||||
connect_step = _convert_remnawave_block_to_step(blocks[2], url_scheme) if len(blocks) > 2 else {"description": {}}
|
||||
# Smart block mapping: find blocks by their content, not just position
|
||||
# 1. First block is usually installation
|
||||
installation_block = blocks[0] if len(blocks) > 0 else None
|
||||
# 2. Find subscription block (with subscriptionLink button)
|
||||
subscription_block = _find_subscription_block(blocks)
|
||||
# 3. Find connect/use block (usually last or has "connect" in title)
|
||||
connect_block = _find_connect_block(blocks)
|
||||
|
||||
# Convert blocks to steps
|
||||
installation_step = _convert_remnawave_block_to_step(installation_block, url_scheme) if installation_block else {"description": {}}
|
||||
subscription_step = _convert_remnawave_block_to_step(subscription_block, url_scheme) if subscription_block else {"description": {}}
|
||||
connect_step = _convert_remnawave_block_to_step(connect_block, url_scheme) if connect_block else {"description": {}}
|
||||
|
||||
# Ensure subscription step has a deepLink button if urlScheme exists
|
||||
if url_scheme:
|
||||
@@ -1886,7 +1995,7 @@ def _convert_remnawave_app_to_cabinet(app: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"id": app.get("name", "").lower().replace(" ", "-"),
|
||||
"name": app.get("name", ""),
|
||||
"isFeatured": app.get("featured", False),
|
||||
"urlScheme": url_scheme, # Use resolved url_scheme (with fallback from app name)
|
||||
"urlScheme": url_scheme,
|
||||
"isNeedBase64Encoding": app.get("isNeedBase64Encoding", False),
|
||||
"installationStep": installation_step,
|
||||
"addSubscriptionStep": subscription_step,
|
||||
@@ -1966,22 +2075,36 @@ def _load_app_config() -> Dict[str, Any]:
|
||||
return _load_app_config_from_file()
|
||||
|
||||
|
||||
def _create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]:
|
||||
"""Create deep link for app with subscription URL."""
|
||||
if not subscription_url or not isinstance(app, dict):
|
||||
logger.debug(f"_create_deep_link: no subscription_url or invalid app")
|
||||
def _is_happ_app(app: Dict[str, Any]) -> bool:
|
||||
"""Check if app is Happ (uses happ_cryptolink scheme)."""
|
||||
name = str(app.get("name", "")).lower()
|
||||
svg_icon_key = str(app.get("svgIconKey", "")).lower()
|
||||
return name == "happ" or svg_icon_key == "happ"
|
||||
|
||||
|
||||
def _create_deep_link(
|
||||
app: Dict[str, Any],
|
||||
subscription_url: str,
|
||||
subscription_crypto_link: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Create deep link for app with subscription URL.
|
||||
|
||||
Uses urlScheme from RemnaWave config or fallback by app name.
|
||||
For Happ apps, uses subscription_crypto_link directly (contains happ:// scheme).
|
||||
"""
|
||||
if not isinstance(app, dict):
|
||||
return None
|
||||
|
||||
scheme = str(app.get("urlScheme", "")).strip()
|
||||
if not scheme:
|
||||
# Try fallback from app name
|
||||
app_name = app.get("name", "").lower().strip()
|
||||
scheme = KNOWN_APP_URL_SCHEMES.get(app_name, "")
|
||||
if scheme:
|
||||
logger.info(f"_create_deep_link: used fallback urlScheme for '{app_name}': {scheme}")
|
||||
# For Happ, use crypto_link directly if available (already has happ:// scheme)
|
||||
if _is_happ_app(app) and subscription_crypto_link:
|
||||
return subscription_crypto_link
|
||||
|
||||
if not subscription_url:
|
||||
return None
|
||||
|
||||
scheme = _get_url_scheme_for_app(app)
|
||||
if not scheme:
|
||||
logger.warning(f"_create_deep_link: no urlScheme for app '{app.get('name', 'unknown')}'")
|
||||
logger.debug(f"_create_deep_link: no urlScheme for app '{app.get('name', 'unknown')}'")
|
||||
return None
|
||||
|
||||
payload = subscription_url
|
||||
@@ -2005,6 +2128,7 @@ async def get_available_countries(
|
||||
) -> Dict[str, Any]:
|
||||
"""Get available countries/servers for the user."""
|
||||
from app.database.crud.server_squad import get_available_server_squads
|
||||
from app.utils.pricing_utils import calculate_prorated_price, apply_percentage_discount
|
||||
|
||||
await db.refresh(user, ["subscription"])
|
||||
|
||||
@@ -2015,25 +2139,59 @@ async def get_available_countries(
|
||||
)
|
||||
|
||||
connected_squads = []
|
||||
days_left = 0
|
||||
if user.subscription:
|
||||
connected_squads = user.subscription.connected_squads or []
|
||||
# Calculate days left for prorated pricing
|
||||
if user.subscription.end_date:
|
||||
from datetime import datetime
|
||||
delta = user.subscription.end_date - datetime.utcnow()
|
||||
days_left = max(0, delta.days)
|
||||
|
||||
# Get discount from promo group
|
||||
servers_discount_percent = 0
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group:
|
||||
servers_discount_percent = promo_group.get_discount_percent("servers", None)
|
||||
|
||||
countries = []
|
||||
for server in available_servers:
|
||||
base_price = server.price_kopeks
|
||||
|
||||
# Apply discount
|
||||
if servers_discount_percent > 0:
|
||||
discounted_price, _ = apply_percentage_discount(base_price, servers_discount_percent)
|
||||
else:
|
||||
discounted_price = base_price
|
||||
|
||||
# Calculate prorated price if subscription exists
|
||||
prorated_price = discounted_price
|
||||
if user.subscription and user.subscription.end_date:
|
||||
prorated_price, _ = calculate_prorated_price(
|
||||
discounted_price,
|
||||
user.subscription.end_date,
|
||||
)
|
||||
|
||||
countries.append({
|
||||
"uuid": server.squad_uuid,
|
||||
"name": server.display_name,
|
||||
"country_code": server.country_code,
|
||||
"price_kopeks": server.price_kopeks,
|
||||
"price_rubles": server.price_kopeks / 100,
|
||||
"base_price_kopeks": base_price,
|
||||
"price_kopeks": prorated_price, # Prorated price with discount
|
||||
"price_per_month_kopeks": discounted_price, # Monthly price with discount
|
||||
"price_rubles": prorated_price / 100,
|
||||
"is_available": server.is_available and not server.is_full,
|
||||
"is_connected": server.squad_uuid in connected_squads,
|
||||
"has_discount": servers_discount_percent > 0,
|
||||
"discount_percent": servers_discount_percent,
|
||||
})
|
||||
|
||||
return {
|
||||
"countries": countries,
|
||||
"connected_count": len(connected_squads),
|
||||
"has_subscription": user.subscription is not None,
|
||||
"days_left": days_left,
|
||||
"discount_percent": servers_discount_percent,
|
||||
}
|
||||
|
||||
|
||||
@@ -2175,7 +2333,10 @@ async def update_countries(
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, user.subscription)
|
||||
if getattr(user, "remnawave_uuid", None):
|
||||
await subscription_service.update_remnawave_user(db, user.subscription)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, user.subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync countries with RemnaWave: {e}")
|
||||
|
||||
@@ -2294,8 +2455,10 @@ async def get_app_config(
|
||||
await db.refresh(user, ["subscription"])
|
||||
|
||||
subscription_url = None
|
||||
subscription_crypto_link = None
|
||||
if user.subscription:
|
||||
subscription_url = user.subscription.subscription_url
|
||||
subscription_crypto_link = user.subscription.subscription_crypto_link
|
||||
|
||||
# Load config from RemnaWave (if configured) or local file
|
||||
config = await _load_app_config_async()
|
||||
@@ -2327,8 +2490,8 @@ async def get_app_config(
|
||||
}
|
||||
|
||||
# Add deep link if subscription exists
|
||||
if subscription_url:
|
||||
app_data["deepLink"] = _create_deep_link(app, subscription_url)
|
||||
if subscription_url or subscription_crypto_link:
|
||||
app_data["deepLink"] = _create_deep_link(app, subscription_url, subscription_crypto_link)
|
||||
|
||||
platform_apps.append(app_data)
|
||||
|
||||
@@ -2349,8 +2512,9 @@ async def get_app_config(
|
||||
return {
|
||||
"platforms": platforms,
|
||||
"platformNames": platform_names,
|
||||
"hasSubscription": bool(subscription_url),
|
||||
"hasSubscription": bool(subscription_url or subscription_crypto_link),
|
||||
"subscriptionUrl": subscription_url,
|
||||
"subscriptionCryptoLink": subscription_crypto_link,
|
||||
"branding": config.get("config", {}).get("branding", {}),
|
||||
}
|
||||
|
||||
@@ -2843,7 +3007,10 @@ async def switch_tariff(
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, user.subscription)
|
||||
if getattr(user, "remnawave_uuid", None):
|
||||
await subscription_service.update_remnawave_user(db, user.subscription)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, user.subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync tariff switch with RemnaWave: {e}")
|
||||
|
||||
@@ -3054,7 +3221,10 @@ async def switch_traffic_package(
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, user.subscription)
|
||||
if getattr(user, "remnawave_uuid", None):
|
||||
await subscription_service.update_remnawave_user(db, user.subscription)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, user.subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync traffic switch with RemnaWave: {e}")
|
||||
|
||||
@@ -3070,3 +3240,125 @@ async def switch_traffic_package(
|
||||
"balance_kopeks": user.balance_kopeks,
|
||||
"balance_label": settings.format_price(user.balance_kopeks),
|
||||
}
|
||||
|
||||
|
||||
# ============ Traffic Refresh ============
|
||||
|
||||
# Rate limit: 1 request per 60 seconds per user
|
||||
TRAFFIC_REFRESH_RATE_LIMIT = 1
|
||||
TRAFFIC_REFRESH_RATE_WINDOW = 60 # seconds
|
||||
TRAFFIC_CACHE_TTL = 60 # Cache traffic data for 60 seconds
|
||||
|
||||
|
||||
@router.post("/refresh-traffic")
|
||||
async def refresh_traffic(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Refresh traffic usage from RemnaWave panel.
|
||||
Rate limited to 1 request per 60 seconds.
|
||||
"""
|
||||
if not user.subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No active subscription",
|
||||
)
|
||||
|
||||
# Check rate limit
|
||||
is_limited = await RateLimitCache.is_rate_limited(
|
||||
user.telegram_id,
|
||||
"traffic_refresh",
|
||||
TRAFFIC_REFRESH_RATE_LIMIT,
|
||||
TRAFFIC_REFRESH_RATE_WINDOW,
|
||||
)
|
||||
|
||||
if is_limited:
|
||||
# Check if we have cached data
|
||||
traffic_cache_key = cache_key("traffic", user.telegram_id)
|
||||
cached_data = await cache.get(traffic_cache_key)
|
||||
|
||||
if cached_data:
|
||||
return {
|
||||
"success": True,
|
||||
"cached": True,
|
||||
"rate_limited": True,
|
||||
"retry_after_seconds": TRAFFIC_REFRESH_RATE_WINDOW,
|
||||
**cached_data,
|
||||
}
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Rate limited. Try again in {TRAFFIC_REFRESH_RATE_WINDOW} seconds.",
|
||||
headers={"Retry-After": str(TRAFFIC_REFRESH_RATE_WINDOW)},
|
||||
)
|
||||
|
||||
# Fetch traffic from RemnaWave
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
traffic_stats = await remnawave_service.get_user_traffic_stats(user.telegram_id)
|
||||
|
||||
if not traffic_stats:
|
||||
# Return current database values if RemnaWave unavailable
|
||||
traffic_data = {
|
||||
"traffic_used_bytes": int((user.subscription.traffic_used_gb or 0) * (1024**3)),
|
||||
"traffic_used_gb": round(user.subscription.traffic_used_gb or 0, 2),
|
||||
"traffic_limit_bytes": int((user.subscription.traffic_limit_gb or 0) * (1024**3)),
|
||||
"traffic_limit_gb": user.subscription.traffic_limit_gb or 0,
|
||||
"traffic_used_percent": round(
|
||||
((user.subscription.traffic_used_gb or 0) / (user.subscription.traffic_limit_gb or 1)) * 100
|
||||
if user.subscription.traffic_limit_gb
|
||||
else 0,
|
||||
1,
|
||||
),
|
||||
"is_unlimited": (user.subscription.traffic_limit_gb or 0) == 0,
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"cached": False,
|
||||
"source": "database",
|
||||
**traffic_data,
|
||||
}
|
||||
|
||||
# Update subscription with fresh data
|
||||
used_gb = traffic_stats.get("used_traffic_gb", 0)
|
||||
if abs((user.subscription.traffic_used_gb or 0) - used_gb) > 0.01:
|
||||
user.subscription.traffic_used_gb = used_gb
|
||||
user.subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Calculate percentage
|
||||
limit_gb = user.subscription.traffic_limit_gb or 0
|
||||
if limit_gb > 0:
|
||||
percent = min(100, (used_gb / limit_gb) * 100)
|
||||
else:
|
||||
percent = 0
|
||||
|
||||
traffic_data = {
|
||||
"traffic_used_bytes": traffic_stats.get("used_traffic_bytes", 0),
|
||||
"traffic_used_gb": round(used_gb, 2),
|
||||
"traffic_limit_bytes": traffic_stats.get("traffic_limit_bytes", 0),
|
||||
"traffic_limit_gb": limit_gb,
|
||||
"traffic_used_percent": round(percent, 1),
|
||||
"is_unlimited": limit_gb == 0,
|
||||
"lifetime_used_bytes": traffic_stats.get("lifetime_used_traffic_bytes", 0),
|
||||
"lifetime_used_gb": round(traffic_stats.get("lifetime_used_traffic_gb", 0), 2),
|
||||
}
|
||||
|
||||
# Cache the result
|
||||
traffic_cache_key = cache_key("traffic", user.telegram_id)
|
||||
await cache.set(traffic_cache_key, traffic_data, TRAFFIC_CACHE_TTL)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"cached": False,
|
||||
"source": "remnawave",
|
||||
**traffic_data,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing traffic for user {user.telegram_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to refresh traffic data",
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ class CabinetConnectionManager:
|
||||
self._admin_connections[user_id] = set()
|
||||
self._admin_connections[user_id].add(websocket)
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d",
|
||||
user_id, is_admin, len(self._user_connections)
|
||||
)
|
||||
@@ -59,7 +59,7 @@ class CabinetConnectionManager:
|
||||
if not self._admin_connections[user_id]:
|
||||
del self._admin_connections[user_id]
|
||||
|
||||
logger.info("Cabinet WS disconnected: user_id=%d", user_id)
|
||||
logger.debug("Cabinet WS disconnected: user_id=%d", user_id)
|
||||
|
||||
async def send_to_user(self, user_id: int, message: dict) -> None:
|
||||
"""Отправить сообщение конкретному пользователю."""
|
||||
@@ -160,7 +160,9 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
token = websocket.query_params.get("token")
|
||||
|
||||
if not token:
|
||||
logger.warning("Cabinet WS: No token from %s", client_host)
|
||||
logger.debug("Cabinet WS: No token from %s", client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason="Unauthorized: No token")
|
||||
return
|
||||
|
||||
@@ -168,14 +170,16 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
user_id, is_admin = await verify_cabinet_ws_token(token)
|
||||
|
||||
if not user_id:
|
||||
logger.warning("Cabinet WS: Invalid token from %s", client_host)
|
||||
logger.debug("Cabinet WS: Invalid token from %s", client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason="Unauthorized: Invalid token")
|
||||
return
|
||||
|
||||
# Принимаем соединение
|
||||
try:
|
||||
await websocket.accept()
|
||||
logger.info("Cabinet WS accepted: user_id=%d, is_admin=%s", user_id, is_admin)
|
||||
logger.debug("Cabinet WS accepted: user_id=%d, is_admin=%s", user_id, is_admin)
|
||||
except Exception as e:
|
||||
logger.error("Cabinet WS: Failed to accept from %s: %s", client_host, e)
|
||||
return
|
||||
@@ -210,7 +214,7 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Cabinet WS disconnected: user_id=%d", user_id)
|
||||
logger.debug("Cabinet WS disconnected: user_id=%d", user_id)
|
||||
except Exception as e:
|
||||
logger.exception("Cabinet WS error: %s", e)
|
||||
finally:
|
||||
|
||||
@@ -42,6 +42,7 @@ class SubscriptionResponse(BaseModel):
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int
|
||||
subscription_url: Optional[str] = None
|
||||
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
|
||||
is_active: bool
|
||||
is_expired: bool
|
||||
traffic_purchases: List[TrafficPurchaseInfo] = []
|
||||
|
||||
@@ -797,7 +797,7 @@ 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))
|
||||
@@ -805,6 +805,22 @@ async def get_server_ids_by_uuids(
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_server_squads_by_uuids(
|
||||
db: AsyncSession,
|
||||
squad_uuids: List[str]
|
||||
) -> List[ServerSquad]:
|
||||
"""Получает список ServerSquad объектов по их UUID с загрузкой allowed_promo_groups."""
|
||||
if not squad_uuids:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.squad_uuid.in_(squad_uuids))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def ensure_servers_synced(db: AsyncSession) -> None:
|
||||
"""
|
||||
Проверяет и синхронизирует серверы при запуске.
|
||||
|
||||
@@ -1081,12 +1081,61 @@ async def get_server_monthly_price(
|
||||
|
||||
async def get_servers_monthly_prices(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
server_squad_ids: List[int],
|
||||
*,
|
||||
user: Optional["User"] = None,
|
||||
) -> List[int]:
|
||||
"""Получает месячные цены серверов с проверкой доступности для промогруппы пользователя."""
|
||||
from app.database.models import ServerSquad
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
prices = []
|
||||
|
||||
# Загружаем промогруппы пользователя если нужно
|
||||
user_promo_group = None
|
||||
user_promo_group_id = None
|
||||
if user:
|
||||
try:
|
||||
# Пробуем загрузить промогруппы если ещё не загружены
|
||||
await db.refresh(user, ["user_promo_groups", "promo_group"])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
user_promo_group = user.get_primary_promo_group()
|
||||
user_promo_group_id = user_promo_group.id if user_promo_group else None
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось получить промогруппу пользователя: {e}")
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
price = await get_server_monthly_price(db, server_id)
|
||||
prices.append(price)
|
||||
# Загружаем сервер с промогруппами
|
||||
result = await db.execute(
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
server = result.scalar_one_or_none()
|
||||
|
||||
if not server:
|
||||
prices.append(0)
|
||||
continue
|
||||
|
||||
# Проверяем доступность сервера для промогруппы пользователя
|
||||
is_allowed = True
|
||||
if user_promo_group_id is not None and server.allowed_promo_groups:
|
||||
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
is_allowed = user_promo_group_id in allowed_ids
|
||||
|
||||
if server.is_available and is_allowed:
|
||||
prices.append(server.price_kopeks)
|
||||
else:
|
||||
# Сервер недоступен для промогруппы пользователя
|
||||
logger.warning(
|
||||
f"⚠️ Сервер {server.display_name} (id={server_id}) недоступен для "
|
||||
f"промогруппы пользователя (promo_group_id={user_promo_group_id}), "
|
||||
f"allowed_promo_groups={[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else []}"
|
||||
)
|
||||
prices.append(server.price_kopeks) # Всё равно берём реальную цену
|
||||
|
||||
return prices
|
||||
|
||||
def _get_discount_percent(
|
||||
@@ -1146,7 +1195,7 @@ async def calculate_subscription_total_cost(
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids, user=user)
|
||||
servers_price_per_month = sum(servers_prices)
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
|
||||
@@ -564,7 +564,7 @@ async def subtract_user_balance(
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
logger.error(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}")
|
||||
logger.info(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -40,10 +40,10 @@ if IS_SQLITE:
|
||||
else:
|
||||
poolclass = AsyncAdaptedQueuePool
|
||||
pool_kwargs = {
|
||||
"pool_size": 20,
|
||||
"max_overflow": 30,
|
||||
"pool_size": 30, # Увеличен с 20
|
||||
"max_overflow": 50, # Увеличен с 30
|
||||
"pool_timeout": 30,
|
||||
"pool_recycle": 3600,
|
||||
"pool_recycle": 1800, # Уменьшен с 3600 до 30 мин для более быстрого recycling
|
||||
"pool_pre_ping": True,
|
||||
# Агрессивная очистка мертвых соединений
|
||||
"pool_reset_on_return": "rollback",
|
||||
@@ -62,7 +62,7 @@ _pg_connect_args = {
|
||||
"idle_in_transaction_session_timeout": "300000", # 5 минут
|
||||
},
|
||||
"command_timeout": 60,
|
||||
"timeout": 10,
|
||||
"timeout": 30, # Увеличен с 10 до 30 сек для высокой нагрузки
|
||||
}
|
||||
|
||||
engine = create_async_engine(
|
||||
|
||||
Vendored
+1
-1
@@ -431,7 +431,7 @@ class RemnaWaveAPI:
|
||||
data['telegramId'] = telegram_id
|
||||
if email:
|
||||
data['email'] = email
|
||||
if hwid_device_limit:
|
||||
if hwid_device_limit is not None:
|
||||
data['hwidDeviceLimit'] = hwid_device_limit
|
||||
if description:
|
||||
data['description'] = description
|
||||
|
||||
+19
-1
@@ -1986,9 +1986,18 @@ async def required_sub_channel_check(
|
||||
await state.set_data(state_data)
|
||||
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
if settings.SKIP_REFERRAL_CODE:
|
||||
if settings.SKIP_REFERRAL_CODE or state_data.get('referral_code'):
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
|
||||
# Проверяем реферальный код из ссылки
|
||||
referrer_id = None
|
||||
ref_code_from_link = state_data.get('referral_code')
|
||||
if ref_code_from_link:
|
||||
referrer = await get_user_by_referral_code(db, ref_code_from_link)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
logger.info(f"✅ CHANNEL CHECK: Реферер найден из ссылки: {referrer.id}")
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, query.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
@@ -1999,9 +2008,18 @@ async def required_sub_channel_check(
|
||||
last_name=query.from_user.last_name,
|
||||
language=language,
|
||||
referral_code=referral_code,
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
# Обрабатываем реферальную регистрацию
|
||||
if referrer_id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id, bot)
|
||||
logger.info(f"✅ CHANNEL CHECK: Реферальная регистрация обработана для {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
# Показываем главное меню после создания пользователя
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
|
||||
@@ -2871,19 +2871,19 @@ async def confirm_purchase(
|
||||
await db.refresh(db_user)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# При покупке подписки ВСЕГДА сбрасываем трафик в панели
|
||||
if db_user.remnawave_uuid:
|
||||
remnawave_user = await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_traffic=True,
|
||||
reset_reason="покупка подписки",
|
||||
)
|
||||
else:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_traffic=True,
|
||||
reset_reason="покупка подписки",
|
||||
)
|
||||
|
||||
@@ -2892,7 +2892,7 @@ async def confirm_purchase(
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_traffic=True,
|
||||
reset_reason="покупка подписки (повторная попытка)",
|
||||
)
|
||||
|
||||
@@ -3758,6 +3758,7 @@ async def handle_trial_payment_method(
|
||||
elif payment_method == "yookassa_sbp":
|
||||
# Оплата через YooKassa СБП
|
||||
payment_result = await payment_service.create_yookassa_sbp_payment(
|
||||
db=db,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
|
||||
@@ -929,12 +929,13 @@ async def handle_custom_confirm(
|
||||
)
|
||||
|
||||
# Обновляем пользователя в Remnawave
|
||||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_traffic=True,
|
||||
reset_reason="покупка тарифа",
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -1228,12 +1229,13 @@ async def confirm_tariff_purchase(
|
||||
)
|
||||
|
||||
# Обновляем пользователя в Remnawave
|
||||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_traffic=True,
|
||||
reset_reason="покупка тарифа",
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -1395,12 +1397,13 @@ async def confirm_daily_tariff_purchase(
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Обновляем пользователя в Remnawave
|
||||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_traffic=True,
|
||||
reset_reason="покупка суточного тарифа",
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -1552,6 +1552,27 @@
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>A full day passed without activity</b>\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!",
|
||||
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Trial server",
|
||||
"TRIAL_SERVER_RANDOM_POOL": "🎲 Random choice among {count} servers",
|
||||
"PAID_TRIAL_HEADER": "⚡ <b>Trial Subscription</b>",
|
||||
"PAID_TRIAL_SELECT_PAYMENT": "Choose a payment method:",
|
||||
"PAID_TRIAL_CAN_PAY_BALANCE": "You can pay for the trial from your balance or choose another payment method.",
|
||||
"PAID_TRIAL_PAYMENT_DESC": "Trial subscription for {days} days",
|
||||
"PAID_TRIAL_INVOICE_TITLE": "Trial subscription for {days} days",
|
||||
"PAID_TRIAL_STARS_LABEL": "Trial subscription",
|
||||
"PAID_TRIAL_STARS_WAITING": "⭐ To pay for the trial subscription, click the payment button in the message above.\n\nAfter successful payment, the subscription will be activated automatically.",
|
||||
"PAID_TRIAL_YOOKASSA_SBP": "🏦 <b>SBP Payment</b>\n\nScan the QR code or follow the link to pay.\n\n💰 Amount: {amount}",
|
||||
"PAID_TRIAL_YOOKASSA_CARD": "💳 <b>Card Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
|
||||
"PAID_TRIAL_CRYPTOBOT": "🪙 <b>CryptoBot Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
|
||||
"PAID_TRIAL_HELEKET": "🪙 <b>Heleket Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
|
||||
"PAID_TRIAL_MULENPAY": "💳 <b>{name} Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
|
||||
"PAID_TRIAL_PAL24": "💳 <b>PayPalych Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
|
||||
"PAID_TRIAL_WATA": "💳 <b>WATA Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
|
||||
"PAID_TRIAL_PAY_BALANCE": "💳 Pay from balance",
|
||||
"PAID_TRIAL_BALANCE_SUCCESS": "✅ Trial subscription paid successfully!\n\nActivation in progress...",
|
||||
"PERIOD": "Period",
|
||||
"TRAFFIC": "Traffic",
|
||||
"DEVICES": "Devices",
|
||||
"PRICE": "Price",
|
||||
"YOUR_BALANCE": "Your balance",
|
||||
"UNBLOCK": "✅ Unblock",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.",
|
||||
|
||||
@@ -1569,6 +1569,27 @@
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
|
||||
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Тестовый сервер",
|
||||
"TRIAL_SERVER_RANDOM_POOL": "🎲 Случайный из {count} серверов",
|
||||
"PAID_TRIAL_HEADER": "⚡ <b>Пробная подписка</b>",
|
||||
"PAID_TRIAL_SELECT_PAYMENT": "Выберите подходящий способ оплаты:",
|
||||
"PAID_TRIAL_CAN_PAY_BALANCE": "Вы можете оплатить пробную подписку с баланса или выбрать другой способ оплаты.",
|
||||
"PAID_TRIAL_PAYMENT_DESC": "Пробная подписка на {days} дней",
|
||||
"PAID_TRIAL_INVOICE_TITLE": "Пробная подписка на {days} дней",
|
||||
"PAID_TRIAL_STARS_LABEL": "Пробная подписка",
|
||||
"PAID_TRIAL_STARS_WAITING": "⭐ Для оплаты пробной подписки нажмите кнопку оплаты в сообщении выше.\n\nПосле успешной оплаты подписка будет активирована автоматически.",
|
||||
"PAID_TRIAL_YOOKASSA_SBP": "🏦 <b>Оплата через СБП</b>\n\nОтсканируйте QR-код или перейдите по ссылке для оплаты.\n\n💰 Сумма: {amount}",
|
||||
"PAID_TRIAL_YOOKASSA_CARD": "💳 <b>Оплата картой</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
|
||||
"PAID_TRIAL_CRYPTOBOT": "🪙 <b>Оплата через CryptoBot</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
|
||||
"PAID_TRIAL_HELEKET": "🪙 <b>Оплата через Heleket</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
|
||||
"PAID_TRIAL_MULENPAY": "💳 <b>Оплата через {name}</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
|
||||
"PAID_TRIAL_PAL24": "💳 <b>Оплата через PayPalych</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
|
||||
"PAID_TRIAL_WATA": "💳 <b>Оплата через WATA</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
|
||||
"PAID_TRIAL_PAY_BALANCE": "💳 Оплатить с баланса",
|
||||
"PAID_TRIAL_BALANCE_SUCCESS": "✅ Пробная подписка успешно оплачена!\n\nАктивация выполняется...",
|
||||
"PERIOD": "Период",
|
||||
"TRAFFIC": "Трафик",
|
||||
"DEVICES": "Устройства",
|
||||
"PRICE": "Стоимость",
|
||||
"YOUR_BALANCE": "Ваш баланс",
|
||||
"UNBLOCK": "✅ Разблокировать",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
|
||||
@@ -25,11 +25,15 @@ class GlobalErrorMiddleware(BaseMiddleware):
|
||||
|
||||
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
error_message = str(error).lower()
|
||||
|
||||
|
||||
if self._is_old_query_error(error_message):
|
||||
return await self._handle_old_query(event, error)
|
||||
elif self._is_message_not_modified_error(error_message):
|
||||
return await self._handle_message_not_modified(event, error)
|
||||
elif self._is_topic_required_error(error_message):
|
||||
# Канал с топиками — просто игнорируем
|
||||
logger.debug(f"📋 [GlobalErrorMiddleware] Игнорируем ошибку топика: {error}")
|
||||
return None
|
||||
elif self._is_bad_request_error(error_message):
|
||||
return await self._handle_bad_request(event, error)
|
||||
else:
|
||||
@@ -53,6 +57,14 @@ class GlobalErrorMiddleware(BaseMiddleware):
|
||||
"bot was blocked by the user",
|
||||
"user is deactivated"
|
||||
])
|
||||
|
||||
def _is_topic_required_error(self, error_message: str) -> bool:
|
||||
return any(phrase in error_message for phrase in [
|
||||
"topic must be specified",
|
||||
"topic_closed",
|
||||
"topic_deleted",
|
||||
"forum_closed"
|
||||
])
|
||||
|
||||
async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
if isinstance(event, CallbackQuery):
|
||||
|
||||
@@ -266,7 +266,7 @@ class FreekassaService:
|
||||
params["i"] = ps_id
|
||||
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return f"https://pay.freekassa.ru/?{query}"
|
||||
return f"https://pay.fk.money/?{query}"
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
|
||||
@@ -284,12 +284,18 @@ class MonitoringService:
|
||||
if not user or not user.remnawave_uuid:
|
||||
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
|
||||
return None
|
||||
|
||||
|
||||
# Обновляем subscription в сессии, чтобы избежать detached instance
|
||||
try:
|
||||
await db.refresh(subscription)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date > current_time)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
await db.commit()
|
||||
@@ -581,8 +587,9 @@ class MonitoringService:
|
||||
)
|
||||
continue
|
||||
except TelegramBadRequest as error:
|
||||
logger.error(
|
||||
"❌ Ошибка Telegram при проверке подписки пользователя %s: %s",
|
||||
# PARTICIPANT_ID_INVALID - пользователь никогда не был в канале, это нормально
|
||||
logger.warning(
|
||||
"⚠️ Ошибка Telegram при проверке подписки пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -664,7 +664,6 @@ class YooKassaPaymentMixin:
|
||||
# Уведомление пользователю
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.config import settings
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=(
|
||||
|
||||
@@ -386,14 +386,25 @@ async def _auto_extend_subscription(
|
||||
subscription = prepared.subscription
|
||||
old_end_date = subscription.end_date
|
||||
was_trial = subscription.is_trial # Запоминаем, была ли подписка триальной
|
||||
old_tariff_id = subscription.tariff_id # Запоминаем старый тариф для определения смены
|
||||
|
||||
_apply_extension_updates(prepared)
|
||||
|
||||
# Определяем, произошла ли смена тарифа
|
||||
is_tariff_change = (
|
||||
prepared.tariff_id is not None
|
||||
and old_tariff_id != prepared.tariff_id
|
||||
)
|
||||
|
||||
try:
|
||||
# При смене тарифа передаём traffic_limit_gb для сброса трафика в БД
|
||||
updated_subscription = await extend_subscription(
|
||||
db,
|
||||
subscription,
|
||||
prepared.period_days,
|
||||
tariff_id=prepared.tariff_id if is_tariff_change else None,
|
||||
traffic_limit_gb=prepared.traffic_limit_gb if is_tariff_change else None,
|
||||
device_limit=prepared.device_limit if is_tariff_change else None,
|
||||
)
|
||||
|
||||
# НОВОЕ: Конвертируем триал в платную подписку ТОЛЬКО после успешного продления
|
||||
@@ -437,12 +448,14 @@ async def _auto_extend_subscription(
|
||||
)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
# При смене тарифа ВСЕГДА сбрасываем трафик, иначе по настройке
|
||||
should_reset_traffic = is_tariff_change or settings.RESET_TRAFFIC_ON_PAYMENT
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
updated_subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="продление подписки",
|
||||
reset_traffic=should_reset_traffic,
|
||||
reset_reason="смена тарифа" if is_tariff_change else "продление подписки",
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
@@ -687,12 +700,13 @@ async def _auto_purchase_tariff(
|
||||
transaction = None
|
||||
|
||||
# Обновляем Remnawave
|
||||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_traffic=True,
|
||||
reset_reason="покупка тарифа",
|
||||
)
|
||||
except Exception as error:
|
||||
@@ -921,12 +935,13 @@ async def _auto_purchase_daily_tariff(
|
||||
transaction = None
|
||||
|
||||
# Обновляем Remnawave
|
||||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_traffic=True,
|
||||
reset_reason="активация суточного тарифа",
|
||||
)
|
||||
except Exception as error:
|
||||
|
||||
@@ -620,7 +620,7 @@ class MiniAppSubscriptionPurchaseService:
|
||||
maximum = max(default_devices, settings.DEFAULT_DEVICE_LIMIT) + 10
|
||||
|
||||
return PurchaseDevicesConfig(
|
||||
minimum=1,
|
||||
minimum=settings.DEFAULT_DEVICE_LIMIT,
|
||||
maximum=maximum,
|
||||
default=default_devices,
|
||||
current=default_devices,
|
||||
@@ -1156,19 +1156,20 @@ class MiniAppSubscriptionPurchaseService:
|
||||
logger.error("Failed to register subscription servers: %s", error)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
# При покупке подписки ВСЕГДА сбрасываем трафик в панели
|
||||
try:
|
||||
if getattr(user, "remnawave_uuid", None):
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_traffic=True,
|
||||
reset_reason="miniapp purchase",
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_traffic=True,
|
||||
reset_reason="miniapp purchase",
|
||||
)
|
||||
except Exception as remnawave_error: # pragma: no cover - defensive logging
|
||||
|
||||
@@ -12,7 +12,7 @@ from aiogram import Bot
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids, get_server_squads_by_uuids
|
||||
from app.database.crud.subscription import (
|
||||
add_subscription_servers,
|
||||
calculate_subscription_total_cost,
|
||||
@@ -319,6 +319,9 @@ class SubscriptionRenewalService:
|
||||
if connected_uuids:
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_uuids)
|
||||
|
||||
# Валидация: проверяем доступность серверов для промогруппы пользователя
|
||||
await self._validate_servers_for_user_promo_group(db, user, connected_uuids)
|
||||
|
||||
# В режиме fixed_with_topup при продлении используем фиксированный лимит
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit = settings.get_fixed_traffic_limit()
|
||||
@@ -530,6 +533,45 @@ class SubscriptionRenewalService:
|
||||
old_end_date=old_end_date,
|
||||
)
|
||||
|
||||
async def _validate_servers_for_user_promo_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
server_uuids: List[str],
|
||||
) -> None:
|
||||
"""
|
||||
Проверяет, что все серверы подписки доступны для промогруппы пользователя.
|
||||
Логирует предупреждения если серверы недоступны.
|
||||
"""
|
||||
if not server_uuids:
|
||||
return
|
||||
|
||||
try:
|
||||
await db.refresh(user, ["user_promo_groups", "promo_group"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
user_promo_group = user.get_primary_promo_group() if user else None
|
||||
if not user_promo_group:
|
||||
return
|
||||
|
||||
servers = await get_server_squads_by_uuids(db, server_uuids)
|
||||
unavailable_servers = []
|
||||
|
||||
for server in servers:
|
||||
if server.allowed_promo_groups:
|
||||
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
if user_promo_group.id not in allowed_ids:
|
||||
unavailable_servers.append(server.display_name or server.squad_uuid)
|
||||
|
||||
if unavailable_servers:
|
||||
logger.warning(
|
||||
f"⚠️ Пользователь {user.telegram_id} (promo_group={user_promo_group.name}) "
|
||||
f"продлевает подписку с серверами, недоступными для его промогруппы: "
|
||||
f"{', '.join(unavailable_servers)}. "
|
||||
f"Это может привести к неправильному расчёту цены!"
|
||||
)
|
||||
|
||||
def build_option_payload(
|
||||
self,
|
||||
pricing: SubscriptionRenewalPricing,
|
||||
|
||||
@@ -320,11 +320,17 @@ class SubscriptionService:
|
||||
if not user or not user.remnawave_uuid:
|
||||
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
|
||||
return None
|
||||
|
||||
|
||||
# Загружаем tariff заранее, чтобы избежать lazy loading в async контексте
|
||||
try:
|
||||
await db.refresh(subscription, ["tariff"])
|
||||
except Exception:
|
||||
pass # tariff может быть None или уже загружен
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date > current_time)
|
||||
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ from app.localization.texts import get_texts
|
||||
|
||||
LOGO_PATH = Path(settings.LOGO_FILE)
|
||||
_PRIVACY_RESTRICTED_CODE = "BUTTON_USER_PRIVACY_RESTRICTED"
|
||||
_TOPIC_REQUIRED_ERRORS = (
|
||||
"topic must be specified",
|
||||
"TOPIC_CLOSED",
|
||||
"TOPIC_DELETED",
|
||||
"FORUM_CLOSED",
|
||||
)
|
||||
|
||||
|
||||
def is_qr_message(message: Message) -> bool:
|
||||
@@ -80,6 +86,15 @@ def is_privacy_restricted_error(error: Exception) -> bool:
|
||||
return _PRIVACY_RESTRICTED_CODE in message or _PRIVACY_RESTRICTED_CODE in description
|
||||
|
||||
|
||||
def is_topic_required_error(error: Exception) -> bool:
|
||||
"""Проверяет, является ли ошибка связанной с топиками/форумами."""
|
||||
if not isinstance(error, TelegramBadRequest):
|
||||
return False
|
||||
|
||||
description = str(error).lower()
|
||||
return any(err.lower() in description for err in _TOPIC_REQUIRED_ERRORS)
|
||||
|
||||
|
||||
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
@@ -97,15 +112,38 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
except TelegramBadRequest as error:
|
||||
if is_topic_required_error(error):
|
||||
# Канал с топиками — просто игнорируем, нельзя ответить без message_thread_id
|
||||
return None
|
||||
if is_privacy_restricted_error(error):
|
||||
fallback_text = append_privacy_hint(text, language)
|
||||
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
|
||||
return await _original_answer(self, fallback_text, **safe_kwargs)
|
||||
try:
|
||||
return await _original_answer(self, fallback_text, **safe_kwargs)
|
||||
except TelegramBadRequest as inner_error:
|
||||
if is_topic_required_error(inner_error):
|
||||
return None
|
||||
raise
|
||||
# Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
try:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except TelegramBadRequest as inner_error:
|
||||
if is_topic_required_error(inner_error):
|
||||
return None
|
||||
raise
|
||||
except Exception:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
try:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except TelegramBadRequest as inner_error:
|
||||
if is_topic_required_error(inner_error):
|
||||
return None
|
||||
raise
|
||||
try:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except TelegramBadRequest as error:
|
||||
if is_topic_required_error(error):
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
@@ -142,6 +180,8 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
try:
|
||||
return await self.edit_media(InputMediaPhoto(**media_kwargs), **edit_kwargs)
|
||||
except TelegramBadRequest as error:
|
||||
if is_topic_required_error(error):
|
||||
return None
|
||||
if is_privacy_restricted_error(error):
|
||||
fallback_text = append_privacy_hint(text, language)
|
||||
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
|
||||
@@ -149,17 +189,29 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
await self.delete()
|
||||
except Exception:
|
||||
pass
|
||||
return await _original_answer(self, fallback_text, **safe_kwargs)
|
||||
try:
|
||||
return await _original_answer(self, fallback_text, **safe_kwargs)
|
||||
except TelegramBadRequest as inner_error:
|
||||
if is_topic_required_error(inner_error):
|
||||
return None
|
||||
raise
|
||||
# Фоллбек: удалим и отправим обычный текст без фото
|
||||
try:
|
||||
await self.delete()
|
||||
except Exception:
|
||||
pass
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
try:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except TelegramBadRequest as inner_error:
|
||||
if is_topic_required_error(inner_error):
|
||||
return None
|
||||
raise
|
||||
# Обработка ошибок MESSAGE_ID_INVALID для сообщений без фото
|
||||
try:
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
except TelegramBadRequest as error:
|
||||
if is_topic_required_error(error):
|
||||
return None
|
||||
if "MESSAGE_ID_INVALID" in str(error) or "message to edit not found" in str(error).lower():
|
||||
# Сообщение удалено или недоступно — просто игнорируем
|
||||
return None
|
||||
|
||||
@@ -184,7 +184,10 @@ def resolve_hwid_device_limit(subscription: Optional[Subscription]) -> Optional[
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
return forced_limit
|
||||
if forced_limit is not None:
|
||||
return forced_limit
|
||||
# Если forced_limit не задан, используем device_limit из подписки
|
||||
# чтобы при смене тарифа лимит устройств обновлялся в панели
|
||||
|
||||
limit = getattr(subscription, "device_limit", None)
|
||||
if limit is None or limit <= 0:
|
||||
|
||||
@@ -209,6 +209,17 @@ router = APIRouter()
|
||||
promo_code_service = PromoCodeService()
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
|
||||
# Кешированный Bot для проверки подписки на канал (снижает нагрузку)
|
||||
_channel_check_bot: Optional[Bot] = None
|
||||
|
||||
|
||||
def _get_channel_check_bot() -> Bot:
|
||||
"""Получить или создать Bot для проверки подписки на канал."""
|
||||
global _channel_check_bot
|
||||
if _channel_check_bot is None:
|
||||
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
|
||||
return _channel_check_bot
|
||||
|
||||
|
||||
_CRYPTOBOT_MIN_USD = 1.0
|
||||
_CRYPTOBOT_MAX_USD = 1000.0
|
||||
@@ -3089,6 +3100,18 @@ async def get_subscription_details(
|
||||
payload: MiniAppSubscriptionRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionResponse:
|
||||
# Check maintenance mode first
|
||||
if maintenance_service.is_maintenance_active():
|
||||
status_info = maintenance_service.get_status_info()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"code": "maintenance",
|
||||
"message": maintenance_service.get_maintenance_message() or "Service is under maintenance",
|
||||
"reason": status_info.get("reason"),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN)
|
||||
except TelegramWebAppAuthError as error:
|
||||
@@ -3112,6 +3135,31 @@ async def get_subscription_details(
|
||||
detail="Invalid Telegram user identifier",
|
||||
) from None
|
||||
|
||||
# Check required channel subscription
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
|
||||
try:
|
||||
bot = _get_channel_check_bot()
|
||||
chat_member = await bot.get_chat_member(
|
||||
chat_id=settings.CHANNEL_SUB_ID,
|
||||
user_id=telegram_id
|
||||
)
|
||||
# Не закрываем сессию - бот переиспользуется
|
||||
|
||||
if chat_member.status not in ["member", "administrator", "creator"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"code": "channel_subscription_required",
|
||||
"message": "Please subscribe to our channel to continue",
|
||||
"channel_link": settings.CHANNEL_LINK,
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check channel subscription for user {telegram_id}: {e}")
|
||||
# Don't block user if check fails
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip()
|
||||
|
||||
@@ -3367,6 +3415,7 @@ async def get_subscription_details(
|
||||
subscription_url: Optional[str] = None
|
||||
subscription_crypto_link: Optional[str] = None
|
||||
happ_redirect_link: Optional[str] = None
|
||||
hide_subscription_link: bool = False
|
||||
remnawave_short_uuid: Optional[str] = None
|
||||
status_actual = "missing"
|
||||
subscription_status_value = "none"
|
||||
@@ -3381,6 +3430,8 @@ async def get_subscription_details(
|
||||
status_actual = subscription.actual_status
|
||||
subscription_status_value = subscription.status
|
||||
links_payload = await _load_subscription_links(subscription)
|
||||
# Флаг скрытия ссылки (скрывается только текст, кнопки работают)
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
subscription_url = (
|
||||
links_payload.get("subscription_url") or subscription.subscription_url
|
||||
)
|
||||
@@ -3533,6 +3584,7 @@ async def get_subscription_details(
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
user=response_user,
|
||||
subscription_url=subscription_url,
|
||||
hide_subscription_link=hide_subscription_link,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
subscription_purchase_url=purchase_url or None,
|
||||
links=links,
|
||||
@@ -3543,7 +3595,7 @@ async def get_subscription_details(
|
||||
connected_devices=devices,
|
||||
happ=links_payload.get("happ") if subscription else None,
|
||||
happ_link=links_payload.get("happ_link") if subscription else None,
|
||||
happ_crypto_link=links_payload.get("happ_crypto_link") if subscription else None,
|
||||
happ_crypto_link=subscription_crypto_link, # Используем уже вычисленное значение с fallback
|
||||
happ_cryptolink_redirect_link=happ_redirect_link,
|
||||
happ_cryptolink_redirect_template=settings.get_happ_cryptolink_redirect_template(),
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
@@ -6738,8 +6790,14 @@ async def purchase_tariff_endpoint(
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Синхронизируем с RemnaWave
|
||||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
await service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=True,
|
||||
reset_reason="покупка тарифа (miniapp)",
|
||||
)
|
||||
|
||||
# Сохраняем корзину для автопродления
|
||||
try:
|
||||
|
||||
@@ -51,25 +51,29 @@ async def verify_websocket_token(
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""WebSocket endpoint для real-time обновлений."""
|
||||
client_host = websocket.client.host if websocket.client else "unknown"
|
||||
logger.info("WebSocket connection attempt from %s", client_host)
|
||||
logger.debug("WebSocket connection attempt from %s", client_host)
|
||||
|
||||
# Сначала проверяем авторизацию ДО принятия соединения
|
||||
token = websocket.query_params.get("token") or websocket.query_params.get("api_key")
|
||||
|
||||
if not token:
|
||||
logger.warning("WebSocket: No token provided from %s", client_host)
|
||||
logger.debug("WebSocket: No token provided from %s", client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason="Unauthorized: No token provided")
|
||||
return
|
||||
|
||||
|
||||
if not await verify_websocket_token(websocket, token):
|
||||
logger.warning("WebSocket: Invalid token from %s", client_host)
|
||||
logger.debug("WebSocket: Invalid token from %s", client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason="Unauthorized: Invalid token")
|
||||
return
|
||||
|
||||
# Только после успешной проверки принимаем соединение
|
||||
try:
|
||||
await websocket.accept()
|
||||
logger.info("WebSocket connection accepted from %s", client_host)
|
||||
logger.debug("WebSocket connection accepted from %s", client_host)
|
||||
except Exception as e:
|
||||
logger.error("WebSocket: Failed to accept connection from %s: %s", client_host, e)
|
||||
return
|
||||
@@ -104,7 +108,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
logger.exception("Error processing WebSocket message: %s", error)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("WebSocket client disconnected")
|
||||
logger.debug("WebSocket client disconnected")
|
||||
except Exception as error:
|
||||
logger.exception("WebSocket error: %s", error)
|
||||
finally:
|
||||
|
||||
@@ -696,6 +696,7 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
user: MiniAppSubscriptionUser
|
||||
traffic_purchases: List[MiniAppTrafficPurchase] = Field(default_factory=list)
|
||||
subscription_url: Optional[str] = None
|
||||
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
|
||||
subscription_crypto_link: Optional[str] = None
|
||||
subscription_purchase_url: Optional[str] = None
|
||||
links: List[str] = Field(default_factory=list)
|
||||
|
||||
+32
-1
@@ -25,14 +25,45 @@ class WebAPIServer:
|
||||
logger.warning("WEB_API_WORKERS > 1 не поддерживается в embed-режиме, используем 1")
|
||||
workers = 1
|
||||
|
||||
# Кастомный конфиг логирования - скрываем спам от WebSocket
|
||||
log_config = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"()": "uvicorn.logging.DefaultFormatter",
|
||||
"fmt": "%(levelprefix)s %(message)s",
|
||||
"use_colors": None,
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"default": {
|
||||
"formatter": "default",
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": "ext://sys.stderr",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"uvicorn": {"handlers": ["default"], "level": "WARNING", "propagate": False},
|
||||
"uvicorn.error": {"level": "WARNING", "propagate": False},
|
||||
"uvicorn.access": {"level": "ERROR", "propagate": False},
|
||||
"uvicorn.protocols": {"level": "WARNING", "propagate": False},
|
||||
"uvicorn.protocols.websockets": {"level": "WARNING", "propagate": False},
|
||||
"uvicorn.protocols.websockets.websockets_impl": {"level": "WARNING", "propagate": False},
|
||||
"websockets": {"level": "WARNING", "propagate": False},
|
||||
"websockets.server": {"level": "WARNING", "propagate": False},
|
||||
},
|
||||
}
|
||||
|
||||
self._config = uvicorn.Config(
|
||||
app=self._app,
|
||||
host=settings.WEB_API_HOST,
|
||||
port=int(settings.WEB_API_PORT or 8080),
|
||||
log_level=settings.LOG_LEVEL.lower(),
|
||||
log_level="warning",
|
||||
workers=workers,
|
||||
lifespan="on",
|
||||
access_log=False,
|
||||
log_config=log_config,
|
||||
)
|
||||
self._server = uvicorn.Server(self._config)
|
||||
self._task: Optional[asyncio.Task[None]] = None
|
||||
|
||||
@@ -148,6 +148,10 @@ async def main():
|
||||
logging.getLogger("aiogram").setLevel(logging.WARNING)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.ERROR)
|
||||
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
||||
# Скрываем спам от WebSocket подключений (connection open/closed)
|
||||
logging.getLogger("uvicorn.protocols.websockets.websockets_impl").setLevel(logging.WARNING)
|
||||
logging.getLogger("websockets.server").setLevel(logging.WARNING)
|
||||
logging.getLogger("websockets").setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
timeline = StartupTimeline(logger, "Bedolaga Remnawave Bot")
|
||||
|
||||
Reference in New Issue
Block a user