Merge branch 'BEDOLAGA-DEV:main' into main

This commit is contained in:
Mikhail
2026-01-20 17:20:46 +03:00
committed by GitHub
34 changed files with 939 additions and 158 deletions
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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
+61
View File
@@ -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
+15 -1
View File
@@ -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)
+45
View File
@@ -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)
+381 -89
View File
@@ -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",
)
+10 -6
View File
@@ -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:
+1
View File
@@ -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] = []
+17 -1
View File
@@ -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:
"""
Проверяет и синхронизирует серверы при запуске.
+53 -4
View File
@@ -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,
+1 -1
View File
@@ -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:
+4 -4
View File
@@ -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(
+1 -1
View File
@@ -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
View File
@@ -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
+5 -4
View File
@@ -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
+6 -3
View File
@@ -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:
+21
View File
@@ -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.",
+21
View File
@@ -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": "❓ Не понимаю эту команду. Используйте кнопки меню.",
+13 -1
View File
@@ -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):
+1 -1
View File
@@ -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,
+13 -6
View File
@@ -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,
)
-1
View File
@@ -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
+43 -1
View File
@@ -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,
+9 -3
View File
@@ -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):
+58 -6
View File
@@ -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
+4 -1
View File
@@ -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:
+60 -2
View File
@@ -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:
+10 -6
View File
@@ -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:
+1
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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")