Compare commits

...

88 Commits

Author SHA1 Message Date
Egor 7c05b36b9d Update Python version to 2.9.0 in Dockerfile 2025-12-08 04:35:25 +03:00
Egor b398eecbeb Update versioning scheme to v2.9.0 2025-12-08 04:35:13 +03:00
Egor 5ab80b09a1 Update docker-hub.yml 2025-12-08 04:34:58 +03:00
Egor 535b92d43b Merge pull request #2129 from BEDOLAGA-DEV/dev5
Обновление Api Remnawave под версию 2.3.0 + Возможность установить таги для триалов и платных подписок + Правки Web Api
2025-12-08 04:28:00 +03:00
Egor cbaddd65e0 Merge pull request #2128 from BEDOLAGA-DEV/revert-2127-xs3s5d-bedolaga/add-ability-to-assign-internal-squads
Revert "Support user-specific internal squads"
2025-12-08 04:24:04 +03:00
Egor 136cae68f1 Revert "Support user-specific internal squads" 2025-12-08 04:23:57 +03:00
Egor 38abf49af9 Merge pull request #2127 from BEDOLAGA-DEV/xs3s5d-bedolaga/add-ability-to-assign-internal-squads
Support user-specific internal squads
2025-12-08 04:20:37 +03:00
Egor 799243a988 Support user-specific internal squads 2025-12-08 04:20:21 +03:00
Egor 6d024716c7 Merge pull request #2126 from BEDOLAGA-DEV/revert-2125-8z51jt-bedolaga/add-support-for-assigning-internal-squads
Revert "Add internal squad management for users and trials"
2025-12-08 04:17:14 +03:00
Egor 0892f494d9 Revert "Add internal squad management for users and trials" 2025-12-08 04:17:05 +03:00
Egor 42b67bdc47 Merge pull request #2125 from BEDOLAGA-DEV/8z51jt-bedolaga/add-support-for-assigning-internal-squads
Add internal squad management for users and trials
2025-12-08 04:15:35 +03:00
Egor 6c41263511 Add internal squad management for users and trials 2025-12-08 04:15:18 +03:00
Egor 1aab9550ce Merge pull request #2124 from BEDOLAGA-DEV/42kgo8-bedolaga/fix-squads-functionality-in-bot-api
Fix RemnaWave squad creation success flag
2025-12-08 04:00:58 +03:00
Egor d596b19d96 Fix RemnaWave squad creation success flag 2025-12-08 04:00:42 +03:00
Egor 0863e3023d Merge pull request #2123 from BEDOLAGA-DEV/revert-2122-mbdf7a-bedolaga/fix-squads-functionality-in-bot-api
Revert "Fix RemnaWave squad inbound serialization"
2025-12-08 04:00:31 +03:00
Egor 1ef2e1264f Revert "Fix RemnaWave squad inbound serialization" 2025-12-08 04:00:22 +03:00
Egor c40806a5a3 Merge pull request #2122 from BEDOLAGA-DEV/mbdf7a-bedolaga/fix-squads-functionality-in-bot-api
Fix RemnaWave squad inbound serialization
2025-12-08 03:52:53 +03:00
Egor 841c288313 Fix serialization of squad inbounds 2025-12-08 03:52:38 +03:00
Egor cd1a4a4a6e Merge pull request #2121 from BEDOLAGA-DEV/0w5so9-bedolaga/fix-critical-error-in-show_node_details
Bind refreshed callbacks to bot
2025-12-08 03:34:10 +03:00
Egor e9e61f4892 Bind refreshed callbacks to bot 2025-12-08 03:33:56 +03:00
Egor 167cddcd2f Merge pull request #2120 from BEDOLAGA-DEV/revert-2119-hcism8-bedolaga/fix-critical-error-in-show_node_details
Revert "Fix node details refresh callback usage"
2025-12-08 03:33:07 +03:00
Egor 5b4c597e9b Revert "Fix node details refresh callback usage" 2025-12-08 03:32:58 +03:00
Egor 0cb1c9b580 Merge pull request #2119 from BEDOLAGA-DEV/hcism8-bedolaga/fix-critical-error-in-show_node_details
Fix node details refresh callback usage
2025-12-08 03:26:46 +03:00
Egor ff4471a22a Fix node details refresh callback usage 2025-12-08 03:26:32 +03:00
Egor 93d7bd74a3 Merge pull request #2118 from BEDOLAGA-DEV/revert-2117-sifkp4-bedolaga/fix-critical-error-in-show_node_details
Revert "Fix callback reuse after node actions"
2025-12-08 03:26:22 +03:00
Egor c770948c1f Revert "Fix callback reuse after node actions" 2025-12-08 03:26:14 +03:00
Egor 2e1a6faec1 Merge pull request #2117 from BEDOLAGA-DEV/sifkp4-bedolaga/fix-critical-error-in-show_node_details
Fix callback reuse after node actions
2025-12-08 03:24:26 +03:00
Egor 0031c9e2e0 Fix callback reuse after node actions 2025-12-08 03:24:13 +03:00
Egor 637d2a07e0 Merge pull request #2116 from BEDOLAGA-DEV/udkyiq-bedolaga/expand-remnawave-node-statistics
Expand Remnawave node statistics
2025-12-08 03:19:36 +03:00
Egor 988ffbebdb Expand Remnawave node statistics 2025-12-08 03:19:23 +03:00
Egor 5a795a2ae2 Update remnawave_api.py 2025-12-08 03:13:16 +03:00
Egor 9b6cd74dbf Update remnawave_api.py 2025-12-08 03:01:21 +03:00
Egor daa2f13ca3 Merge pull request #2114 from BEDOLAGA-DEV/qhbf7v-bedolaga/add-custom-tag-feature-for-trial-users
Add configurable RemnaWave user tags for trial and paid subscriptions
2025-12-08 02:46:32 +03:00
Egor 386b9ae998 Add configurable user tags for trial and paid subscriptions 2025-12-08 02:44:53 +03:00
Egor e64854dc48 Update remnawave_api.py 2025-12-08 01:42:03 +03:00
Egor 23eed94009 Add functions to extract traffic bytes from user data 2025-12-08 01:32:23 +03:00
Egor f04ffa58e4 Update remnawave_api.py 2025-12-08 01:31:42 +03:00
Egor fc45db6f3c Merge pull request #2112 from BEDOLAGA-DEV/dev5
Dev5
2025-12-06 17:31:03 +03:00
Egor b8874772be Merge pull request #2111 from BEDOLAGA-DEV/8ioop5-bedolaga/add-api-endpoint-for-statistics
Add full statistics API endpoint
2025-12-06 16:14:52 +03:00
Egor d8c7793a26 Merge pull request #2110 from BEDOLAGA-DEV/dev5
Dev5
2025-12-06 15:34:35 +03:00
Egor 10d08d5b40 Add full statistics API endpoint 2025-12-06 15:32:01 +03:00
Egor b07a889826 Merge pull request #2109 from BEDOLAGA-DEV/ffowe2-bedolaga/-api
Add promo offer broadcast endpoint
2025-12-06 15:28:12 +03:00
Egor a24b4c72e9 Add promo offer broadcast endpoint 2025-12-06 15:21:54 +03:00
Egor 5e1d00ad0d Merge pull request #2108 from belousotroll/fix-calculating-tg-starts-amount
Fix calculating tg starts amount
2025-12-06 06:24:52 +03:00
belousotroll d33ebb1453 Merge branch 'BEDOLAGA-DEV:main' into fix-calculating-tg-starts-amount 2025-12-05 21:54:46 +07:00
Egor 7a2bcf3d2f Merge pull request #2107 from BEDOLAGA-DEV/dev5
Dev5
2025-12-05 10:22:44 +03:00
Egor dbb68582e4 Merge pull request #2106 from BEDOLAGA-DEV/gpower-bedolaga/add-telegram-id-support-to-promo-offers-api
Add Telegram ID support to promo offers API
2025-12-05 10:18:34 +03:00
Egor 479b9bc384 Add telegram id support for promo offers 2025-12-05 09:56:03 +03:00
Egor 73f3987481 Merge pull request #2105 from BEDOLAGA-DEV/dev5
Dev5
2025-12-03 07:30:06 +03:00
Egor 8b38f1e37d Merge pull request #2104 from BEDOLAGA-DEV/axy4i1-bedolaga/fix-telegram-api-error-in-show_balance_menu
Handle balance menu for YooKassa SBP QR and add Heleket localization keys
2025-12-03 07:29:43 +03:00
Egor 6976129972 Handle balance menu edits for SBP invoices and add Heleket locales 2025-12-03 07:29:01 +03:00
belousotroll 207e4673c0 Merge branch 'BEDOLAGA-DEV:main' into fix-calculating-tg-starts-amount 2025-11-30 20:30:14 +07:00
belousotroll 160ff7ff9f fix: calculating telegram starts amount 2025-11-30 19:29:29 +07:00
Egor 6c7c57138d Merge pull request #2099 from BEDOLAGA-DEV/dev5
Dev5
2025-11-29 09:52:18 +03:00
Egor 12b5a39194 Update subscription_purchase_service.py 2025-11-29 09:50:35 +03:00
Egor 7678150e6a Update subscription_auto_purchase_service.py 2025-11-29 09:50:05 +03:00
Egor 99e35cae4c Merge pull request #2098 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 23:02:44 +03:00
Egor af313a12ed Add files via upload 2025-11-28 23:00:08 +03:00
Egor 71d18287fe Add files via upload 2025-11-28 22:59:27 +03:00
Egor 6b24b69b53 Merge pull request #2097 from BEDOLAGA-DEV/dev5
Update menu.py
2025-11-28 21:28:54 +03:00
Egor 9528457b89 Update menu.py 2025-11-28 21:27:11 +03:00
Egor 69322640c5 Merge pull request #2096 from BEDOLAGA-DEV/dev5
Update purchase.py
2025-11-28 21:17:51 +03:00
Egor 3c0703b599 Update purchase.py 2025-11-28 21:13:49 +03:00
Egor d14aa5bd8a Update README.md 2025-11-28 06:42:43 +03:00
Egor baa5da243e Merge pull request #2095 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 06:24:37 +03:00
Egor fff01d1ce3 Update validators.py 2025-11-28 06:23:55 +03:00
Egor 52c8442423 Merge pull request #2094 from BEDOLAGA-DEV/revert-2093-acgzuy-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Remove blockquote markup to prevent Telegram parse errors"
2025-11-28 06:02:16 +03:00
Egor 150f9e741a Revert "Remove blockquote markup to prevent Telegram parse errors" 2025-11-28 06:02:08 +03:00
Egor 4ae2234b7f Merge pull request #2093 from BEDOLAGA-DEV/acgzuy-bedolaga/fix-unsupported-html-tag-parsing-error
Remove blockquote markup to prevent Telegram parse errors
2025-11-28 06:00:48 +03:00
Egor 71366a8133 Remove blockquote markup to prevent Telegram parse errors 2025-11-28 06:00:32 +03:00
Egor fb6cda2c63 Merge pull request #2092 from BEDOLAGA-DEV/revert-2091-ndvrn0-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Harden message send fallback for invalid HTML"
2025-11-28 06:00:25 +03:00
Egor 2d4f9da9a7 Revert "Harden message send fallback for invalid HTML" 2025-11-28 06:00:16 +03:00
Egor 7a39abe05c Merge pull request #2091 from BEDOLAGA-DEV/ndvrn0-bedolaga/fix-unsupported-html-tag-parsing-error
Harden message send fallback for invalid HTML
2025-11-28 05:51:20 +03:00
Egor 79659ec5fe Harden message send fallback for invalid HTML 2025-11-28 05:51:05 +03:00
Egor a3457d5853 Merge pull request #2090 from BEDOLAGA-DEV/revert-2089-pmhwx5-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Improve fallback on Telegram parse errors"
2025-11-28 05:50:59 +03:00
Egor 0392aa5b45 Revert "Improve fallback on Telegram parse errors" 2025-11-28 05:50:50 +03:00
Egor 2c68d9ded9 Merge pull request #2089 from BEDOLAGA-DEV/pmhwx5-bedolaga/fix-unsupported-html-tag-parsing-error
Improve fallback on Telegram parse errors
2025-11-28 05:49:24 +03:00
Egor a437ebd65b Improve fallback on Telegram parse errors 2025-11-28 05:49:04 +03:00
Egor e2323755de Merge pull request #2088 from BEDOLAGA-DEV/revert-2087-lyaqkf-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Handle Telegram parse errors gracefully"
2025-11-28 05:48:57 +03:00
Egor 5e23920723 Revert "Handle Telegram parse errors gracefully" 2025-11-28 05:48:48 +03:00
Egor 5e45fc1285 Merge pull request #2087 from BEDOLAGA-DEV/lyaqkf-bedolaga/fix-unsupported-html-tag-parsing-error
Handle Telegram parse errors gracefully
2025-11-28 05:43:41 +03:00
Egor ff1556ffcd Handle parse errors with plain text fallback 2025-11-28 05:43:23 +03:00
Egor 549e8fa332 Merge pull request #2086 from BEDOLAGA-DEV/revert-2085-aoflna-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Ensure photo replies default to HTML parse mode"
2025-11-28 05:43:15 +03:00
Egor 8a7fb598fa Revert "Ensure photo replies default to HTML parse mode" 2025-11-28 05:43:06 +03:00
Egor 521c364fbb Merge pull request #2085 from BEDOLAGA-DEV/aoflna-bedolaga/fix-unsupported-html-tag-parsing-error
Ensure photo replies default to HTML parse mode
2025-11-28 05:41:40 +03:00
Egor db60f5c5ba Ensure photo replies default to HTML parse mode 2025-11-28 05:41:24 +03:00
Egor affc07985e Merge pull request #2084 from BEDOLAGA-DEV/4drat9-bedolaga/fix-empty-page-on-/redoc
Ensure ReDoc loads reliably
2025-11-28 04:00:35 +03:00
Egor 2761255e65 Ensure ReDoc loads reliably 2025-11-28 03:58:59 +03:00
32 changed files with 1352 additions and 228 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="v2.8.0-$(git rev-parse --short HEAD)"
VERSION="v2.9.0-$(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="v2.8.0-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.0-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.8.0-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.0-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="v2.8.0-$(git rev-parse --short HEAD)"
VERSION="v2.9.0-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.8.0-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.0-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.8.0-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.0-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="v2.8.0"
ARG VERSION="v2.9.0"
ARG BUILD_DATE
ARG VCS_REF
+10
View File
@@ -1186,6 +1186,7 @@ REDIS_URL=redis://redis:6379/0
-**Продление/сокращение срока** подписки (±365 дней)
- 🚫 Блокировки с таймером и аудит действий
- 🛡️ **Защита от запрещенных никнеймов** с настраиваемым список банвордов (автоблокировка подозрительных имен)
- 💰 **Установка индивидуального реферального процента юзеру**
🎯 **Продажи, маркетинг и удержание**
- 🎫 Промокоды
@@ -1231,6 +1232,15 @@ REDIS_URL=redis://redis:6379/0
- 📘 **Управление пакетами трафика** (включение/отключение)
- 🧪 Тестовые платежи для каждого провайдера
- 🪙 Управление вебхуками всех платёжных систем
- ⚙️ **Управление настройками из бота** (с приоритетом в .env)
⚙️ **Remnawave**
- Синхронизация юзеров из панели в бота (Ручная/автоматическая по таймеру)
- Синхронизация юзеров из бота в панель
- Синхронизация сквадов(серверов) из панели в бота
- Управление нодами/сквадами прямо в боте
- Детальная статистика по нодам/панели
- Создание/Редактивание сквадов в боте
🗃️ **REST API для интеграций**
- 🔌 **FastAPI Web API** с полной документацией
+43 -3
View File
@@ -1,6 +1,7 @@
import hashlib
import hmac
import logging
import math
import os
import re
import html
@@ -19,6 +20,8 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
"joingroup",
]
USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$")
logger = logging.getLogger(__name__)
@@ -88,6 +91,7 @@ class Settings(BaseSettings):
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
TRIAL_PAYMENT_ENABLED: bool = False
TRIAL_ACTIVATION_PRICE: int = 0
TRIAL_USER_TAG: Optional[str] = None
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
DEFAULT_DEVICE_LIMIT: int = 1
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
@@ -119,6 +123,7 @@ class Settings(BaseSettings):
PRICE_90_DAYS: int = 269000
PRICE_180_DAYS: int = 499000
PRICE_360_DAYS: int = 899000
PAID_SUBSCRIPTION_USER_TAG: Optional[str] = None
PRICE_TRAFFIC_5GB: int = 2000
PRICE_TRAFFIC_10GB: int = 3500
@@ -776,13 +781,48 @@ class Settings(BaseSettings):
def kopeks_to_rubles(self, kopeks: int) -> float:
return kopeks / 100
def rubles_to_kopeks(self, rubles: float) -> int:
return int(rubles * 100)
@staticmethod
def _normalize_user_tag(value: Optional[str], setting_name: str) -> Optional[str]:
if value is None:
return None
cleaned = str(value).strip().upper()
if not cleaned:
return None
if len(cleaned) > 16:
logger.warning(
"Некорректная длина %s: максимум 16 символов, получено %s",
setting_name,
len(cleaned),
)
return None
if not USER_TAG_PATTERN.fullmatch(cleaned):
logger.warning(
"Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание",
setting_name,
)
return None
return cleaned
def get_trial_warning_hours(self) -> int:
return self.TRIAL_WARNING_HOURS
def get_trial_user_tag(self) -> Optional[str]:
return self._normalize_user_tag(self.TRIAL_USER_TAG, "TRIAL_USER_TAG")
def get_paid_subscription_user_tag(self) -> Optional[str]:
return self._normalize_user_tag(
self.PAID_SUBSCRIPTION_USER_TAG,
"PAID_SUBSCRIPTION_USER_TAG",
)
def get_bot_username(self) -> Optional[str]:
username = getattr(self, "BOT_USERNAME", None)
if not username:
@@ -1284,7 +1324,7 @@ class Settings(BaseSettings):
return stars * self.get_stars_rate()
def rubles_to_stars(self, rubles: float) -> int:
return max(1, int(rubles / self.get_stars_rate()))
return max(1, math.ceil(rubles / self.get_stars_rate()))
def get_admin_notifications_chat_id(self) -> Optional[int]:
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
+230 -29
View File
@@ -27,14 +27,22 @@ class TrafficLimitStrategy(Enum):
MONTH = "MONTH"
@dataclass
class UserTraffic:
"""Данные о трафике пользователя (новая структура API)"""
used_traffic_bytes: int
lifetime_used_traffic_bytes: int
online_at: Optional[datetime] = None
first_connected_at: Optional[datetime] = None
last_connected_node_uuid: Optional[str] = None
@dataclass
class RemnaWaveUser:
uuid: str
short_uuid: str
username: str
status: UserStatus
used_traffic_bytes: int
lifetime_used_traffic_bytes: int
traffic_limit_bytes: int
traffic_limit_strategy: TrafficLimitStrategy
expire_at: datetime
@@ -47,18 +55,60 @@ class RemnaWaveUser:
active_internal_squads: List[Dict[str, str]]
created_at: datetime
updated_at: datetime
user_traffic: Optional[UserTraffic] = None
sub_last_user_agent: Optional[str] = None
sub_last_opened_at: Optional[datetime] = None
online_at: Optional[datetime] = None
sub_revoked_at: Optional[datetime] = None
last_traffic_reset_at: Optional[datetime] = None
trojan_password: Optional[str] = None
vless_uuid: Optional[str] = None
ss_password: Optional[str] = None
first_connected_at: Optional[datetime] = None
last_triggered_threshold: int = 0
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
external_squad_uuid: Optional[str] = None
id: Optional[int] = None
@property
def used_traffic_bytes(self) -> int:
"""Обратная совместимость: получение used_traffic_bytes из user_traffic"""
if self.user_traffic:
return self.user_traffic.used_traffic_bytes
return 0
@property
def lifetime_used_traffic_bytes(self) -> int:
"""Обратная совместимость: получение lifetime_used_traffic_bytes из user_traffic"""
if self.user_traffic:
return self.user_traffic.lifetime_used_traffic_bytes
return 0
@property
def online_at(self) -> Optional[datetime]:
"""Обратная совместимость: получение online_at из user_traffic"""
if self.user_traffic:
return self.user_traffic.online_at
return None
@property
def first_connected_at(self) -> Optional[datetime]:
"""Обратная совместимость: получение first_connected_at из user_traffic"""
if self.user_traffic:
return self.user_traffic.first_connected_at
return None
@dataclass
class RemnaWaveInbound:
"""Структура inbound для Internal Squad"""
uuid: str
profile_uuid: str
tag: str
type: str
network: Optional[str] = None
security: Optional[str] = None
port: Optional[int] = None
raw_inbound: Optional[Any] = None
@dataclass
@@ -67,7 +117,21 @@ class RemnaWaveInternalSquad:
name: str
members_count: int
inbounds_count: int
inbounds: List[Dict[str, Any]]
inbounds: List[RemnaWaveInbound]
view_position: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@dataclass
class RemnaWaveAccessibleNode:
"""Доступная нода для Internal Squad"""
uuid: str
node_name: str
country_code: str
config_profile_uuid: str
config_profile_name: str
active_inbounds: List[str]
@dataclass
@@ -78,11 +142,39 @@ class RemnaWaveNode:
country_code: str
is_connected: bool
is_disabled: bool
is_node_online: bool
is_xray_running: bool
users_online: Optional[int]
traffic_used_bytes: Optional[int]
traffic_limit_bytes: Optional[int]
port: Optional[int] = None
is_connecting: bool = False
xray_version: Optional[str] = None
node_version: Optional[str] = None
view_position: int = 0
tags: Optional[List[str]] = None
# Новые поля API
last_status_change: Optional[datetime] = None
last_status_message: Optional[str] = None
xray_uptime: Optional[str] = None
is_traffic_tracking_active: bool = False
traffic_reset_day: Optional[int] = None
notify_percent: Optional[int] = None
consumption_multiplier: float = 1.0
cpu_count: Optional[int] = None
cpu_model: Optional[str] = None
total_ram: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
provider_uuid: Optional[str] = None
@property
def is_node_online(self) -> bool:
"""Обратная совместимость: is_node_online = is_connected"""
return self.is_connected
@property
def is_xray_running(self) -> bool:
"""Обратная совместимость: xray работает если нода подключена"""
return self.is_connected
@dataclass
@@ -444,8 +536,38 @@ class RemnaWaveAPI:
async def delete_internal_squad(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}')
return response['response']['isDeleted']
async def get_internal_squad_accessible_nodes(self, uuid: str) -> List[RemnaWaveAccessibleNode]:
"""Получает список доступных нод для Internal Squad"""
try:
response = await self._make_request('GET', f'/api/internal-squads/{uuid}/accessible-nodes')
return [self._parse_accessible_node(node) for node in response['response']['accessibleNodes']]
except RemnaWaveAPIError as e:
if e.status_code == 404:
return []
raise
async def add_users_to_internal_squad(self, uuid: str) -> bool:
"""Добавляет всех пользователей в Internal Squad (bulk action)"""
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/add-users')
return response['response']['eventSent']
async def remove_users_from_internal_squad(self, uuid: str) -> bool:
"""Удаляет всех пользователей из Internal Squad (bulk action)"""
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/remove-users')
return response['response']['eventSent']
async def reorder_internal_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveInternalSquad]:
"""
Изменяет порядок Internal Squads
items: список словарей с uuid и viewPosition
Пример: [{'uuid': '...', 'viewPosition': 0}, {'uuid': '...', 'viewPosition': 1}]
"""
data = {'items': items}
response = await self._make_request('POST', '/api/internal-squads/actions/reorder', data)
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
async def get_all_nodes(self) -> List[RemnaWaveNode]:
response = await self._make_request('GET', '/api/nodes')
return [self._parse_node(node) for node in response['response']]
@@ -586,42 +708,73 @@ class RemnaWaveAPI:
return False
def _parse_user_traffic(self, traffic_data: Optional[Dict]) -> Optional[UserTraffic]:
"""Парсит данные трафика из нового формата API"""
if not traffic_data:
return None
return UserTraffic(
used_traffic_bytes=int(traffic_data.get('usedTrafficBytes', 0)),
lifetime_used_traffic_bytes=int(traffic_data.get('lifetimeUsedTrafficBytes', 0)),
online_at=self._parse_optional_datetime(traffic_data.get('onlineAt')),
first_connected_at=self._parse_optional_datetime(traffic_data.get('firstConnectedAt')),
last_connected_node_uuid=traffic_data.get('lastConnectedNodeUuid')
)
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
happ_data = user_data.get('happ') or {}
happ_link = happ_data.get('link') or happ_data.get('url')
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
# Парсим userTraffic из нового формата API
user_traffic = self._parse_user_traffic(user_data.get('userTraffic'))
# Получаем status с fallback на ACTIVE
status_str = user_data.get('status') or 'ACTIVE'
try:
status = UserStatus(status_str)
except ValueError:
logger.warning(f"Неизвестный статус пользователя: {status_str}, используем ACTIVE")
status = UserStatus.ACTIVE
# Получаем trafficLimitStrategy с fallback
strategy_str = user_data.get('trafficLimitStrategy') or 'NO_RESET'
try:
traffic_strategy = TrafficLimitStrategy(strategy_str)
except ValueError:
logger.warning(f"Неизвестная стратегия трафика: {strategy_str}, используем NO_RESET")
traffic_strategy = TrafficLimitStrategy.NO_RESET
return RemnaWaveUser(
uuid=user_data['uuid'],
short_uuid=user_data['shortUuid'],
username=user_data['username'],
status=UserStatus(user_data['status']),
used_traffic_bytes=int(user_data['usedTrafficBytes']),
lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']),
traffic_limit_bytes=user_data['trafficLimitBytes'],
traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']),
status=status,
traffic_limit_bytes=user_data.get('trafficLimitBytes', 0),
traffic_limit_strategy=traffic_strategy,
expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')),
telegram_id=user_data.get('telegramId'),
email=user_data.get('email'),
hwid_device_limit=user_data.get('hwidDeviceLimit'),
description=user_data.get('description'),
tag=user_data.get('tag'),
subscription_url=user_data['subscriptionUrl'],
active_internal_squads=user_data['activeInternalSquads'],
subscription_url=user_data.get('subscriptionUrl', ''),
active_internal_squads=user_data.get('activeInternalSquads', []),
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
user_traffic=user_traffic,
sub_last_user_agent=user_data.get('subLastUserAgent'),
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
trojan_password=user_data.get('trojanPassword'),
vless_uuid=user_data.get('vlessUuid'),
ss_password=user_data.get('ssPassword'),
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
happ_link=happ_link,
happ_crypto_link=happ_crypto_link
happ_crypto_link=happ_crypto_link,
external_squad_uuid=user_data.get('externalSquadUuid'),
id=user_data.get('id')
)
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
@@ -629,28 +782,76 @@ class RemnaWaveAPI:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return None
def _parse_inbound(self, inbound_data: Dict) -> RemnaWaveInbound:
"""Парсит данные inbound"""
return RemnaWaveInbound(
uuid=inbound_data['uuid'],
profile_uuid=inbound_data['profileUuid'],
tag=inbound_data['tag'],
type=inbound_data['type'],
network=inbound_data.get('network'),
security=inbound_data.get('security'),
port=inbound_data.get('port'),
raw_inbound=inbound_data.get('rawInbound')
)
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
info = squad_data.get('info', {})
inbounds_raw = squad_data.get('inbounds', [])
inbounds = [self._parse_inbound(ib) for ib in inbounds_raw] if inbounds_raw else []
return RemnaWaveInternalSquad(
uuid=squad_data['uuid'],
name=squad_data['name'],
members_count=squad_data['info']['membersCount'],
inbounds_count=squad_data['info']['inboundsCount'],
inbounds=squad_data['inbounds']
members_count=info.get('membersCount', 0),
inbounds_count=info.get('inboundsCount', 0),
inbounds=inbounds,
view_position=squad_data.get('viewPosition', 0),
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
)
def _parse_accessible_node(self, node_data: Dict) -> RemnaWaveAccessibleNode:
"""Парсит данные доступной ноды для Internal Squad"""
return RemnaWaveAccessibleNode(
uuid=node_data['uuid'],
node_name=node_data['nodeName'],
country_code=node_data['countryCode'],
config_profile_uuid=node_data['configProfileUuid'],
config_profile_name=node_data['configProfileName'],
active_inbounds=node_data.get('activeInbounds', [])
)
def _parse_node(self, node_data: Dict) -> RemnaWaveNode:
return RemnaWaveNode(
uuid=node_data['uuid'],
name=node_data['name'],
address=node_data['address'],
country_code=node_data['countryCode'],
is_connected=node_data['isConnected'],
is_disabled=node_data['isDisabled'],
is_node_online=node_data['isNodeOnline'],
is_xray_running=node_data['isXrayRunning'],
country_code=node_data.get('countryCode', ''),
is_connected=node_data.get('isConnected', False),
is_disabled=node_data.get('isDisabled', False),
users_online=node_data.get('usersOnline'),
traffic_used_bytes=node_data.get('trafficUsedBytes'),
traffic_limit_bytes=node_data.get('trafficLimitBytes')
traffic_limit_bytes=node_data.get('trafficLimitBytes'),
port=node_data.get('port'),
is_connecting=node_data.get('isConnecting', False),
xray_version=node_data.get('xrayVersion'),
node_version=node_data.get('nodeVersion'),
view_position=node_data.get('viewPosition', 0),
tags=node_data.get('tags', []),
# Новые поля API
last_status_change=self._parse_optional_datetime(node_data.get('lastStatusChange')),
last_status_message=node_data.get('lastStatusMessage'),
xray_uptime=node_data.get('xrayUptime'),
is_traffic_tracking_active=node_data.get('isTrafficTrackingActive', False),
traffic_reset_day=node_data.get('trafficResetDay'),
notify_percent=node_data.get('notifyPercent'),
consumption_multiplier=node_data.get('consumptionMultiplier', 1.0),
cpu_count=node_data.get('cpuCount'),
cpu_model=node_data.get('cpuModel'),
total_ram=node_data.get('totalRam'),
created_at=self._parse_optional_datetime(node_data.get('createdAt')),
updated_at=self._parse_optional_datetime(node_data.get('updatedAt')),
provider_uuid=node_data.get('providerUuid')
)
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
+113 -56
View File
@@ -1316,7 +1316,29 @@ async def show_node_details(
status_emoji = "🟢" if node["is_node_online"] else "🔴"
xray_emoji = "" if node["is_xray_running"] else ""
status_change = (
format_datetime(node["last_status_change"])
if node.get("last_status_change")
else ""
)
created_at = (
format_datetime(node["created_at"])
if node.get("created_at")
else ""
)
updated_at = (
format_datetime(node["updated_at"])
if node.get("updated_at")
else ""
)
notify_percent = (
f"{node['notify_percent']}%" if node.get("notify_percent") is not None else ""
)
cpu_info = node.get("cpu_model") or ""
if node.get("cpu_count"):
cpu_info = f"{node['cpu_count']}x {cpu_info}"
text = f"""
🖥 <b>Нода: {node['name']}</b>
@@ -1325,15 +1347,29 @@ async def show_node_details(
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
- Подключена: {'📡 Да' if node['is_connected'] else '📵 Нет'}
- Отключена: {'❌ Да' if node['is_disabled'] else '✅ Нет'}
- Изменение статуса: {status_change}
- Сообщение: {node.get('last_status_message') or ''}
- Uptime Xray: {node.get('xray_uptime') or ''}
<b>Информация:</b>
- Адрес: {node['address']}
- Страна: {node['country_code']}
- Пользователей онлайн: {node['users_online']}
- CPU: {cpu_info}
- RAM: {node.get('total_ram') or ''}
- Провайдер: {node.get('provider_uuid') or ''}
<b>Трафик:</b>
- Использовано: {format_bytes(node['traffic_used_bytes'])}
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
- День сброса: {node.get('traffic_reset_day') or ''}
- Уведомления: {notify_percent}
- Множитель: {node.get('consumption_multiplier') or 1}
<b>Метаданные:</b>
- Создана: {created_at}
- Обновлена: {updated_at}
"""
await callback.message.edit_text(
@@ -1350,28 +1386,18 @@ async def manage_node(
db_user: User,
db: AsyncSession
):
action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1]
remnawave_service = RemnaWaveService()
success = await remnawave_service.manage_node(node_uuid, action)
if success:
action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"}
await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}")
else:
await callback.answer("❌ Ошибка выполнения действия", show_alert=True)
await show_node_details(
types.CallbackQuery(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
data=f"admin_node_manage_{node_uuid}",
message=callback.message
),
db_user,
db
)
action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1]
remnawave_service = RemnaWaveService()
success = await remnawave_service.manage_node(node_uuid, action)
if success:
action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"}
await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}")
else:
await callback.answer("❌ Ошибка выполнения действия", show_alert=True)
await show_node_details(callback, db_user, db)
@admin_required
@error_handler
@@ -1407,10 +1433,32 @@ async def show_node_statistics(
if stats.get('nodeUuid') == node_uuid:
node_realtime = stats
break
status_change = (
format_datetime(node["last_status_change"])
if node.get("last_status_change")
else ""
)
created_at = (
format_datetime(node["created_at"])
if node.get("created_at")
else ""
)
updated_at = (
format_datetime(node["updated_at"])
if node.get("updated_at")
else ""
)
notify_percent = (
f"{node['notify_percent']}%" if node.get("notify_percent") is not None else ""
)
cpu_info = node.get("cpu_model") or ""
if node.get("cpu_count"):
cpu_info = f"{node['cpu_count']}x {cpu_info}"
status_emoji = "🟢" if node["is_node_online"] else "🔴"
xray_emoji = "" if node["is_xray_running"] else ""
text = f"""
📊 <b>Статистика ноды: {node['name']}</b>
@@ -1418,10 +1466,26 @@ async def show_node_statistics(
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
- Пользователей онлайн: {node['users_online'] or 0}
- Изменение статуса: {status_change}
- Сообщение: {node.get('last_status_message') or ''}
- Uptime Xray: {node.get('xray_uptime') or ''}
<b>Ресурсы:</b>
- CPU: {cpu_info}
- RAM: {node.get('total_ram') or ''}
- Провайдер: {node.get('provider_uuid') or ''}
<b>Трафик:</b>
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
- День сброса: {node.get('traffic_reset_day') or ''}
- Уведомления: {notify_percent}
- Множитель: {node.get('consumption_multiplier') or 1}
<b>Метаданные:</b>
- Создана: {created_at}
- Обновлена: {updated_at}
"""
if node_realtime:
@@ -1456,18 +1520,25 @@ async def show_node_statistics(
except Exception as e:
logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}")
text = f"""
📊 <b>Статистика ноды: {node['name']}</b>
<b>Статус:</b>
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
- Пользователей онлайн: {node['users_online'] or 0}
- Изменение статуса: {format_datetime(node.get('last_status_change')) if node.get('last_status_change') else ''}
- Сообщение: {node.get('last_status_message') or ''}
- Uptime Xray: {node.get('xray_uptime') or ''}
<b>Трафик:</b>
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
- День сброса: {node.get('traffic_reset_day') or ''}
- Уведомления: {node.get('notify_percent') or ''}
- Множитель: {node.get('consumption_multiplier') or 1}
<b>Детальная статистика временно недоступна</b>
Возможные причины:
@@ -1566,17 +1637,11 @@ async def manage_squad_action(
await callback.answer("❌ Ошибка удаления сквада", show_alert=True)
return
await show_squad_details(
types.CallbackQuery(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
data=f"admin_squad_manage_{squad_uuid}",
message=callback.message
),
db_user,
db
)
refreshed_callback = callback.model_copy(
update={"data": f"admin_squad_manage_{squad_uuid}"}
).as_(callback.bot)
await show_squad_details(refreshed_callback, db_user, db)
@admin_required
@error_handler
@@ -1734,15 +1799,11 @@ async def cancel_squad_rename(
await state.clear()
new_callback = types.CallbackQuery(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
data=f"squad_edit_{squad_uuid}",
message=callback.message
)
await show_squad_edit_menu(new_callback, db_user, db)
refreshed_callback = callback.model_copy(
update={"data": f"squad_edit_{squad_uuid}"}
).as_(callback.bot)
await show_squad_edit_menu(refreshed_callback, db_user, db)
@admin_required
@error_handler
@@ -1953,15 +2014,11 @@ async def show_squad_edit_menu_short(
await callback.answer("❌ Сквад не найден", show_alert=True)
return
new_callback = types.CallbackQuery(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
data=f"squad_edit_{full_squad_uuid}",
message=callback.message
)
await show_squad_edit_menu(new_callback, db_user, db)
refreshed_callback = callback.model_copy(
update={"data": f"squad_edit_{full_squad_uuid}"}
).as_(callback.bot)
await show_squad_edit_menu(refreshed_callback, db_user, db)
@admin_required
@error_handler
+29 -6
View File
@@ -94,15 +94,38 @@ async def show_balance_menu(
db: AsyncSession
):
texts = get_texts(db_user.language)
balance_text = texts.BALANCE_INFO.format(
balance=texts.format_price(db_user.balance_kopeks)
)
await callback.message.edit_text(
balance_text,
reply_markup=get_balance_keyboard(db_user.language)
)
reply_markup = get_balance_keyboard(db_user.language)
try:
if callback.message and callback.message.text:
await callback.message.edit_text(
balance_text,
reply_markup=reply_markup
)
elif callback.message and callback.message.caption:
await callback.message.edit_caption(
balance_text,
reply_markup=reply_markup
)
else:
await callback.message.answer(
balance_text,
reply_markup=reply_markup
)
except TelegramBadRequest as error:
logger.warning(
"Failed to edit balance message, sending a new one instead: %s",
error,
)
await callback.message.answer(
balance_text,
reply_markup=reply_markup
)
await callback.answer()
+134 -2
View File
@@ -149,6 +149,18 @@ async def show_main_menu(
*,
skip_callback_answer: bool = False,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
db_user.last_activity = datetime.utcnow()
@@ -164,7 +176,7 @@ async def show_main_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
# Проверяем наличие сохраненной корзины в Redis
try:
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
@@ -230,6 +242,18 @@ async def show_service_rules(
db_user: User,
db: AsyncSession
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
from app.database.crud.rules import get_current_rules_content
texts = get_texts(db_user.language)
@@ -252,6 +276,18 @@ async def show_info_menu(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
header = texts.t("MENU_INFO_HEADER", "️ <b>Инфо</b>")
@@ -283,6 +319,18 @@ async def show_promo_groups_info(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
promo_groups = await get_auto_assign_promo_groups(db)
@@ -423,6 +471,18 @@ async def show_faq_pages(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
pages = await FaqService.get_pages(db, db_user.language)
@@ -467,6 +527,18 @@ async def show_faq_page(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
raw_data = callback.data or ""
@@ -590,6 +662,18 @@ async def show_privacy_policy(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
raw_page = 1
@@ -697,6 +781,18 @@ async def show_public_offer(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
raw_page = 1
@@ -804,6 +900,18 @@ async def show_language_menu(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
@@ -834,6 +942,18 @@ async def process_language_change(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
@@ -889,6 +1009,18 @@ async def handle_back_to_menu(
db_user: User,
db: AsyncSession
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
await state.clear()
texts = get_texts(db_user.language)
@@ -903,7 +1035,7 @@ async def handle_back_to_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
# Проверяем наличие сохраненной корзины в Redis
try:
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
+22
View File
@@ -406,6 +406,17 @@ async def apply_countries_changes(
await callback.answer()
return
# Проверяем, что пользователь не пытается отключить все страны (должна остаться хотя бы 1 страна)
if len(selected_countries) == 0:
await callback.answer(
texts.t(
"COUNTRIES_MINIMUM_REQUIRED",
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
),
show_alert=True
)
return
try:
if added and total_cost > 0:
success = await subtract_user_balance(
@@ -898,6 +909,17 @@ async def confirm_add_countries_to_subscription(
return
try:
# Проверяем, что пользователь не пытается отключить все страны (должна остаться хотя бы 1 страна)
if len(selected_countries) == 0:
await callback.answer(
texts.t(
"COUNTRIES_MINIMUM_REQUIRED",
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
),
show_alert=True
)
return
if new_countries and total_price > 0:
success = await subtract_user_balance(
db, db_user, total_price,
+86 -26
View File
@@ -1760,9 +1760,11 @@ async def select_devices(
)
countries = await _get_available_countries(db_user.promo_group_id)
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
selected_countries = data.get('countries', [])
countries_price = sum(
c['price_kopeks'] for c in countries
if c['uuid'] in data['countries']
if c['uuid'] in selected_countries
)
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
@@ -1821,9 +1823,15 @@ async def confirm_purchase(
countries = await _get_available_countries(db_user.promo_group_id)
months_in_period = data.get(
'months_in_period', calculate_months_from_days(data['period_days'])
)
period_days = data.get('period_days')
if period_days is None:
await callback.message.edit_text(
texts.t("SUBSCRIPTION_PURCHASE_ERROR", "Ошибка при оформлении подписки. Попробуйте начать сначала."),
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
months_in_period = data.get('months_in_period', calculate_months_from_days(period_days))
base_price = data.get('base_price')
base_price_original = data.get('base_price_original')
@@ -1831,10 +1839,10 @@ async def confirm_purchase(
base_discount_total = data.get('base_discount_total')
if base_price is None:
base_price_original = PERIOD_PRICES[data['period_days']]
base_price_original = PERIOD_PRICES[period_days]
base_discount_percent = db_user.get_promo_discount(
"period",
data['period_days'],
period_days,
)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
@@ -1842,11 +1850,11 @@ async def confirm_purchase(
)
else:
if base_price_original is None:
base_price_original = PERIOD_PRICES[data['period_days']]
base_price_original = PERIOD_PRICES[period_days]
if base_discount_percent is None:
base_discount_percent = db_user.get_promo_discount(
"period",
data['period_days'],
period_days,
)
if base_discount_total is None:
_, base_discount_total = apply_percentage_discount(
@@ -1859,14 +1867,16 @@ async def confirm_purchase(
countries_price_per_month = 0
per_month_prices: List[int] = []
for country in countries:
if country['uuid'] in data['countries']:
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
selected_countries = data.get('countries', [])
if country['uuid'] in selected_countries:
server_price_per_month = country['price_kopeks']
countries_price_per_month += server_price_per_month
per_month_prices.append(server_price_per_month)
servers_discount_percent = db_user.get_promo_discount(
"servers",
data['period_days'],
period_days,
)
total_servers_price = 0
total_servers_discount = 0
@@ -1928,7 +1938,7 @@ async def confirm_purchase(
else:
devices_discount_percent = db_user.get_promo_discount(
"devices",
data['period_days'],
period_days,
)
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
@@ -1944,9 +1954,15 @@ async def confirm_purchase(
)
else:
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb'))
traffic_price_per_month = data.get(
'traffic_price_per_month', settings.get_traffic_price(data['traffic_gb'])
)
traffic_gb = data.get('traffic_gb')
if traffic_gb is not None:
traffic_price_per_month = data.get(
'traffic_price_per_month', settings.get_traffic_price(traffic_gb)
)
else:
traffic_price_per_month = data.get(
'traffic_price_per_month', 0
)
if 'traffic_discount_percent' in data:
traffic_discount_percent = data.get('traffic_discount_percent', 0)
@@ -1960,7 +1976,7 @@ async def confirm_purchase(
else:
traffic_discount_percent = db_user.get_promo_discount(
"traffic",
data['period_days'],
period_days,
)
discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
traffic_price_per_month,
@@ -1971,7 +1987,7 @@ async def confirm_purchase(
total_servers_price = data.get('total_servers_price', total_countries_price)
cached_total_price = data['total_price']
cached_total_price = data.get('total_price', 0)
cached_promo_discount_value = data.get('promo_offer_discount_value', 0)
validation_total_price = data.get('total_price_before_promo_offer')
@@ -2181,10 +2197,10 @@ async def confirm_purchase(
trial_duration_days=trial_duration,
payment_method="balance",
first_payment_amount_kopeks=final_price,
first_paid_period_days=data['period_days']
first_paid_period_days=period_days
)
logger.info(
f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price / 100}")
f"Записана конверсия: {trial_duration} дн. триал → {period_days} дн. платная за {final_price / 100}")
except Exception as conversion_error:
logger.error(f"Ошибка записи конверсии: {conversion_error}")
@@ -2193,10 +2209,37 @@ async def confirm_purchase(
existing_subscription.traffic_limit_gb = final_traffic_gb
if should_update_devices:
existing_subscription.device_limit = selected_devices
existing_subscription.connected_squads = data['countries']
# Проверяем, что при обновлении существующей подписки есть хотя бы одна страна
selected_countries = data.get('countries', [])
if not selected_countries:
# В случае если подписка уже существовала, не разрешаем отключать все страны
# Если подписка новая, разрешаем, но обычно через UI пользователь должен выбрать хотя бы один сервер
if existing_subscription and existing_subscription.connected_squads is not None:
# Проверим, что в данных есть информация о том, что это обновление существующей подписки
# или что-то указывает, что не нужно отключать все страны
pass # Для простоты в этом случае просто проверим, что список стран не пустой
else:
# Для новой подписки разрешаем пустой список, если не является обновлением
pass
# Но для безопасности - если список стран пустой, проверим, что это разрешено
# иначе вернем ошибку
if not selected_countries:
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"COUNTRIES_MINIMUM_REQUIRED",
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
),
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
existing_subscription.connected_squads = selected_countries
existing_subscription.start_date = current_time
existing_subscription.end_date = current_time + timedelta(days=data['period_days']) + bonus_period
existing_subscription.end_date = current_time + timedelta(days=period_days) + bonus_period
existing_subscription.updated_at = current_time
existing_subscription.traffic_used_gb = 0.0
@@ -2222,12 +2265,29 @@ async def confirm_purchase(
if resolved_device_limit is None and devices_selection_enabled:
resolved_device_limit = default_device_limit
# Проверяем, что для новой подписки также есть хотя бы одна страна, если пользователь проходит через интерфейс стран
new_subscription_countries = data.get('countries', [])
if not new_subscription_countries:
# Проверяем, была ли это покупка через интерфейс стран, и если да, то требуем хотя бы одну страну
# Если в данных явно указано, что это интерфейс стран, или есть другие признаки - требуем страну
# Для упрощения - проверим, что страна обязательна, если идет через UI стран
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"COUNTRIES_MINIMUM_REQUIRED",
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
),
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
subscription = await create_paid_subscription_with_traffic_mode(
db=db,
user_id=db_user.id,
duration_days=data['period_days'],
duration_days=period_days,
device_limit=resolved_device_limit,
connected_squads=data['countries'],
connected_squads=new_subscription_countries,
traffic_gb=final_traffic_gb
)
@@ -2237,7 +2297,7 @@ async def confirm_purchase(
from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
from app.database.crud.subscription import add_subscription_servers
server_ids = await get_server_ids_by_uuids(db, data['countries'])
server_ids = await get_server_ids_by_uuids(db, data.get('countries', []))
if server_ids:
await add_subscription_servers(db, subscription, server_ids, server_prices)
@@ -2278,13 +2338,13 @@ async def confirm_purchase(
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_price,
description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)"
description=f"Подписка на {period_days} дней ({months_in_period} мес)"
)
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db, db_user, subscription, transaction, data['period_days'], was_trial_conversion
db, db_user, subscription, transaction, period_days, was_trial_conversion
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
+3
View File
@@ -865,6 +865,7 @@
"COUNTRY_CHANGES_REMOVED_HEADER": " <b>Removed countries:</b>\n",
"COUNTRY_CHANGES_REMOVED_WARNING": "️ Reconnecting later will be charged",
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
"COUNTRIES_MINIMUM_REQUIRED": "❌ Cannot disconnect all countries. At least one country must remain connected.",
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Manage subscription countries</b>\n\n📋 <b>Current countries ({current_count}):</b>\n{current_list}\n\n💡 <b>How it works:</b>\n✅ — currently connected\n — will be added (paid)\n — will be removed (free)\n⚪ — not selected\n\n⚠️ <b>Important:</b> Reconnecting removed countries will be charged again!",
"COUNTRY_MANAGEMENT_UNAVAILABLE": "️ Server management is unavailable — only one server is accessible",
@@ -1086,6 +1087,8 @@
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
+3
View File
@@ -877,6 +877,7 @@
"COUNTRY_CHANGES_REMOVED_HEADER": " <b>Отключены страны:</b>\n",
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Повторное подключение будет платным",
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
"COUNTRIES_MINIMUM_REQUIRED": "❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна.",
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Управление странами подписки</b>\n\n📋 <b>Текущие страны ({current_count}):</b>\n{current_list}\n\n💡 <b>Инструкция:</b>\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪ - не выбрана\n\n⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!",
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Управление серверами недоступно - доступен только один сервер",
@@ -1098,6 +1099,8 @@
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
+4 -2
View File
@@ -1093,10 +1093,12 @@
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банківська картка (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "швидко та зручно",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через підтримку</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через підтримку</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банківська картка</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банківська картка</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему швидких платежів YooKassa",
+2
View File
@@ -1096,6 +1096,8 @@
"PAYMENT_METHOD_SUPPORT_NAME":"🛠️<b>通过支持</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION":"通过Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME":"💳<b>银行卡</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION":"通过Heleket",
"PAYMENT_METHOD_HELEKET_NAME":"🪙<b>加密货币(Heleket)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION":"通过YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME":"💳<b>银行卡</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION":"通过YooKassa快速支付系统",
+1 -8
View File
@@ -56,14 +56,7 @@ class TelegramStarsMixin:
# Если количество звёзд не задано, вычисляем его из курса.
if stars_amount is None:
rate = Decimal(str(settings.get_stars_rate()))
if rate <= 0:
raise ValueError("Stars rate must be positive")
normalized_stars = (amount_rubles / rate).to_integral_value(
rounding=ROUND_FLOOR
)
stars_amount = int(normalized_stars) or 1
stars_amount = settings.rubles_to_stars(float(amount_rubles))
if stars_amount <= 0:
raise ValueError("Stars amount must be positive")
+52 -10
View File
@@ -3,6 +3,7 @@ import logging
import re
from contextlib import AsyncExitStack, asynccontextmanager
from datetime import datetime, timedelta
from dataclasses import asdict, is_dataclass
from typing import Any, Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
@@ -46,6 +47,26 @@ from app.utils.timezone import get_local_timezone
logger = logging.getLogger(__name__)
def _get_user_traffic_bytes(panel_user: Dict[str, Any]) -> int:
"""Извлекает usedTrafficBytes из панельного пользователя (совместимо с новым и старым API)"""
# Новый формат: userTraffic.usedTrafficBytes
user_traffic = panel_user.get('userTraffic')
if user_traffic and isinstance(user_traffic, dict):
return user_traffic.get('usedTrafficBytes', 0)
# Старый формат: usedTrafficBytes напрямую
return panel_user.get('usedTrafficBytes', 0)
def _get_lifetime_traffic_bytes(panel_user: Dict[str, Any]) -> int:
"""Извлекает lifetimeUsedTrafficBytes из панельного пользователя (совместимо с новым и старым API)"""
# Новый формат: userTraffic.lifetimeUsedTrafficBytes
user_traffic = panel_user.get('userTraffic')
if user_traffic and isinstance(user_traffic, dict):
return user_traffic.get('lifetimeUsedTrafficBytes', 0)
# Старый формат: lifetimeUsedTrafficBytes напрямую
return panel_user.get('lifetimeUsedTrafficBytes', 0)
_UUID_MAP_MISSING = object()
@@ -760,7 +781,20 @@ class RemnaWaveService:
"is_xray_running": node.is_xray_running,
"users_online": node.users_online or 0,
"traffic_used_bytes": node.traffic_used_bytes or 0,
"traffic_limit_bytes": node.traffic_limit_bytes or 0
"traffic_limit_bytes": node.traffic_limit_bytes or 0,
"last_status_change": node.last_status_change,
"last_status_message": node.last_status_message,
"xray_uptime": node.xray_uptime,
"is_traffic_tracking_active": node.is_traffic_tracking_active,
"traffic_reset_day": node.traffic_reset_day,
"notify_percent": node.notify_percent,
"consumption_multiplier": node.consumption_multiplier,
"cpu_count": node.cpu_count,
"cpu_model": node.cpu_model,
"total_ram": node.total_ram,
"created_at": node.created_at,
"updated_at": node.updated_at,
"provider_uuid": node.provider_uuid,
}
except Exception as e:
@@ -818,15 +852,19 @@ class RemnaWaveService:
try:
async with self.get_api_client() as api:
squads = await api.get_internal_squads()
result = []
for squad in squads:
inbounds = [
asdict(inbound) if is_dataclass(inbound) else inbound
for inbound in squad.inbounds or []
]
result.append({
'uuid': squad.uuid,
'name': squad.name,
'members_count': squad.members_count,
'inbounds_count': squad.inbounds_count,
'inbounds': squad.inbounds
'inbounds': inbounds,
})
logger.info(f"✅ Получено {len(result)} сквадов из Remnawave")
@@ -1463,10 +1501,10 @@ class RemnaWaveService:
traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0)
traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
used_traffic_bytes = _get_user_traffic_bytes(panel_user)
traffic_used_gb = used_traffic_bytes / (1024**3)
active_squads = panel_user.get('activeInternalSquads', [])
squad_uuids = []
if isinstance(active_squads, list):
@@ -1568,10 +1606,10 @@ class RemnaWaveService:
if subscription.status != new_status:
subscription.status = new_status
logger.debug(f"Обновлен статус подписки: {new_status}")
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
used_traffic_bytes = _get_user_traffic_bytes(panel_user)
traffic_used_gb = used_traffic_bytes / (1024**3)
if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01:
subscription.traffic_used_gb = traffic_used_gb
logger.debug(f"Обновлен использованный трафик: {traffic_used_gb} GB")
@@ -1827,12 +1865,16 @@ class RemnaWaveService:
async with self.get_api_client() as api:
squad = await api.get_internal_squad_by_uuid(squad_uuid)
if squad:
inbounds = [
asdict(inbound) if is_dataclass(inbound) else inbound
for inbound in squad.inbounds or []
]
return {
'uuid': squad.uuid,
'name': squad.name,
'members_count': squad.members_count,
'inbounds_count': squad.inbounds_count,
'inbounds': squad.inbounds
'inbounds': inbounds
}
return None
except Exception as e:
@@ -124,10 +124,13 @@ def _safe_int(value: Optional[object], default: int = 0) -> int:
async def _prepare_auto_extend_context(
db: AsyncSession,
user: User,
cart_data: dict,
) -> Optional[AutoExtendContext]:
subscription = getattr(user, "subscription", None)
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
if subscription is None:
logger.info(
"🔁 Автопокупка: у пользователя %s нет активной подписки для продления",
@@ -233,7 +236,7 @@ async def _auto_extend_subscription(
bot: Optional[Bot] = None,
) -> bool:
try:
prepared = await _prepare_auto_extend_context(user, cart_data)
prepared = await _prepare_auto_extend_context(db, user, cart_data)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s",
@@ -329,7 +329,9 @@ class MiniAppSubscriptionPurchaseService:
"""Builds configuration and pricing for subscription purchases in the mini app."""
async def build_options(self, db: AsyncSession, user: User) -> PurchaseOptionsContext:
subscription = getattr(user, "subscription", None)
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0)
currency = (getattr(user, "balance_currency", None) or "RUB").upper()
texts = get_texts(getattr(user, "language", None))
+24 -4
View File
@@ -132,6 +132,13 @@ class SubscriptionService:
self._last_config_signature = config_signature
@staticmethod
def _resolve_user_tag(subscription: Subscription) -> Optional[str]:
if getattr(subscription, "is_trial", False):
return settings.get_trial_user_tag()
return settings.get_paid_subscription_user_tag()
@property
def is_configured(self) -> bool:
return self._config_error is None
@@ -173,7 +180,9 @@ class SubscriptionService:
if not validation_success:
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
return None
user_tag = self._resolve_user_tag(subscription)
async with self.get_api_client() as api:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
@@ -201,6 +210,9 @@ class SubscriptionService:
active_internal_squads=subscription.connected_squads,
)
if user_tag is not None:
update_kwargs['tag'] = user_tag
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
@@ -236,6 +248,9 @@ class SubscriptionService:
active_internal_squads=subscription.connected_squads,
)
if user_tag is not None:
create_kwargs['tag'] = user_tag
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
@@ -288,15 +303,17 @@ class SubscriptionService:
is_actually_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
subscription.updated_at = current_time
await db.commit()
is_actually_active = False
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
user_tag = self._resolve_user_tag(subscription)
async with self.get_api_client() as api:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
@@ -314,6 +331,9 @@ class SubscriptionService:
active_internal_squads=subscription.connected_squads,
)
if user_tag is not None:
update_kwargs['tag'] = user_tag
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
+20
View File
@@ -220,6 +220,7 @@ class BotConfigurationService:
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
"PAID_SUBSCRIPTION_USER_TAG": "SUBSCRIPTION_PRICES",
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
@@ -227,6 +228,7 @@ class BotConfigurationService:
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
"TRIAL_WARNING_HOURS": "TRIAL",
"TRIAL_USER_TAG": "TRIAL",
"SUPPORT_USERNAME": "SUPPORT",
"SUPPORT_MENU_ENABLED": "SUPPORT",
"SUPPORT_SYSTEM_MODE": "SUPPORT",
@@ -643,6 +645,24 @@ class BotConfigurationService:
"warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.",
"dependencies": "Результат вызова getMe() в Telegram Bot API",
},
"TRIAL_USER_TAG": {
"description": (
"Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave."
),
"format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.",
"example": "TRIAL_USER",
"warning": "Неверный формат будет проигнорирован при создании пользователя.",
"dependencies": "Активация триала и включенная интеграция с RemnaWave",
},
"PAID_SUBSCRIPTION_USER_TAG": {
"description": (
"Тег, который бот ставит пользователю при покупке платной подписки в панели RemnaWave."
),
"format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.",
"example": "PAID_USER",
"warning": "Если тег не задан или невалиден, существующий тег не будет изменён.",
"dependencies": "Оплата подписки и интеграция с RemnaWave",
},
}
@classmethod
+39 -14
View File
@@ -121,27 +121,52 @@ def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
def sanitize_html(text: str) -> str:
"""
Безопасно санитизирует HTML-текст, заменяя HTML-сущности на соответствующие теги,
при этом предотвращая XSS-уязвимости за счет безопасной обработки атрибутов.
Args:
text (str): Текст с HTML-сущностями (например, &lt;b&gt; жирный &lt;/b&gt;)
Returns:
str: Санитизированный HTML-текст (например, <b> жирный </b>)
"""
if not text:
return text
text = html.escape(text)
# Для безопасности нужно обработать разрешенные теги, заменяя их сущности на теги
# Но при этом безопасно обрабатывая атрибуты, чтобы избежать XSS
allowed_tags = ALLOWED_HTML_TAGS.union(SELF_CLOSING_TAGS)
# Обработка всех разрешенных тегов
for tag in allowed_tags:
text = re.sub(
f'&lt;(/?{tag}\\b[^>]*)&gt;',
lambda m: "<"
+ (
m.group(1)
.replace("&quot;", "\"")
.replace("&#x27;", "'")
.replace("&amp;", "&")
)
+ ">",
text,
flags=re.IGNORECASE
)
# Паттерн: захватываем &lt;tag&gt;, &lt;/tag&gt;, или &lt;tag атрибуты&gt;
# Используем более сложный паттерн, чтобы захватить атрибуты до закрывающего &gt;
# (?s) - позволяет . захватывать новую строку
# [^>]*? - ленивый захват до >
pattern = rf'(&lt;)(/?{tag}\b)([^>]*?)(&gt;)'
def replace_tag(match):
opening = match.group(1) # &lt;
full_tag_content = match.group(2) # /?tagname
attrs_part = match.group(3) # атрибуты (без >)
closing = match.group(4) # &gt;
# Убираем начальный пробел, если есть
if attrs_part.startswith(' '):
attrs_part = attrs_part[1:]
# Формируем результат
if attrs_part:
# Безопасно обрабатываем атрибуты, заменяя только безопасные сущности
# Не разворачиваем &lt; и &gt; внутри атрибутов, чтобы избежать XSS
processed_attrs = attrs_part.replace('&quot;', '"').replace('&#x27;', "'")
return f'<{full_tag_content} {processed_attrs}>'
else:
return f'<{full_tag_content}>'
text = re.sub(pattern, replace_tag, text, flags=re.IGNORECASE)
return text
+9 -1
View File
@@ -4,6 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.webapi.docs import add_redoc_endpoint
from .middleware import RequestLoggingMiddleware
from .routes import (
@@ -144,11 +145,18 @@ def create_web_api_app() -> FastAPI:
title=settings.WEB_API_TITLE,
version=settings.WEB_API_VERSION,
docs_url=docs_config.get("docs_url"),
redoc_url=docs_config.get("redoc_url"),
redoc_url=None,
openapi_url=docs_config.get("openapi_url"),
swagger_ui_parameters={"persistAuthorization": True},
)
add_redoc_endpoint(
app,
redoc_url=docs_config.get("redoc_url"),
openapi_url=docs_config.get("openapi_url"),
title=settings.WEB_API_TITLE,
)
allowed_origins = settings.get_web_api_allowed_origins()
app.add_middleware(
CORSMiddleware,
+33
View File
@@ -0,0 +1,33 @@
from fastapi import FastAPI
from fastapi.openapi.docs import get_redoc_html
def add_redoc_endpoint(
app: FastAPI,
*,
redoc_url: str | None,
openapi_url: str | None,
title: str | None,
) -> None:
"""Attach a ReDoc endpoint if docs are enabled.
The default FastAPI ReDoc handler sometimes renders a blank page when the
CDN bundle fails to load. By explicitly registering the handler and
pinning the bundle version, we ensure the endpoint always returns a fully
rendered page.
"""
if not redoc_url or not openapi_url:
return
for route in app.router.routes:
if getattr(route, "path", None) == redoc_url:
return
@app.get(redoc_url, include_in_schema=False)
async def redoc_html(): # pragma: no cover - template rendering
return get_redoc_html(
openapi_url=openapi_url,
title=f"{title or app.title} - ReDoc",
redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js",
)
+146 -5
View File
@@ -11,16 +11,20 @@ from app.database.crud.discount_offer import (
list_discount_offers,
upsert_discount_offer,
)
from app.handlers.admin.messages import get_custom_users, get_target_users
from app.database.crud.promo_offer_log import list_promo_offer_logs
from app.database.crud.promo_offer_template import (
get_promo_offer_template_by_id,
list_promo_offer_templates,
update_promo_offer_template,
)
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, Subscription, User
from ..dependencies import get_db_session, require_api_token
from ..schemas.promo_offers import (
PromoOfferBroadcastRequest,
PromoOfferBroadcastResponse,
PromoOfferCreateRequest,
PromoOfferListResponse,
PromoOfferLogListResponse,
@@ -137,6 +141,14 @@ def _build_log_response(entry: PromoOfferLog) -> PromoOfferLogResponse:
)
async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
normalized = target.strip().lower()
if normalized.startswith("custom_"):
criteria = normalized[len("custom_"):]
return await get_custom_users(db, criteria)
return await get_target_users(db, normalized)
@router.get("", response_model=PromoOfferListResponse)
async def list_promo_offers(
_: Any = Security(require_api_token),
@@ -144,20 +156,35 @@ async def list_promo_offers(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user_id: Optional[int] = Query(None, ge=1),
telegram_id: Optional[int] = Query(None, ge=1),
notification_type: Optional[str] = Query(None, min_length=1),
is_active: Optional[bool] = Query(None),
) -> PromoOfferListResponse:
resolved_user_id = user_id
if telegram_id is not None:
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
if resolved_user_id and resolved_user_id != user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="telegram_id does not match the provided user_id",
)
resolved_user_id = user.id
offers = await list_discount_offers(
db,
offset=offset,
limit=limit,
user_id=user_id,
user_id=resolved_user_id,
notification_type=notification_type,
is_active=is_active,
)
total = await count_discount_offers(
db,
user_id=user_id,
user_id=resolved_user_id,
notification_type=notification_type,
is_active=is_active,
)
@@ -187,7 +214,26 @@ async def create_promo_offer(
if not payload.effect_type.strip():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty")
user = await db.get(User, payload.user_id)
target_user_id = payload.user_id
user: Optional[User] = None
if payload.telegram_id is not None:
user = await get_user_by_telegram_id(db, payload.telegram_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
if target_user_id and target_user_id != user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Provided user_id does not match telegram_id",
)
target_user_id = user.id
if target_user_id is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "user_id or telegram_id is required")
if user is None:
user = await db.get(User, target_user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
@@ -195,12 +241,12 @@ async def create_promo_offer(
subscription = await db.get(Subscription, payload.subscription_id)
if not subscription:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
if subscription.user_id != payload.user_id:
if subscription.user_id != target_user_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription does not belong to the user")
offer = await upsert_discount_offer(
db,
user_id=payload.user_id,
user_id=target_user_id,
subscription_id=payload.subscription_id,
notification_type=payload.notification_type.strip(),
discount_percent=payload.discount_percent,
@@ -215,6 +261,101 @@ async def create_promo_offer(
return _serialize_offer(offer)
@router.post(
"/broadcast",
response_model=PromoOfferBroadcastResponse,
status_code=status.HTTP_201_CREATED,
)
async def broadcast_promo_offers(
payload: PromoOfferBroadcastRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoOfferBroadcastResponse:
if payload.discount_percent < 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative")
if payload.bonus_amount_kopeks < 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative")
if payload.valid_hours <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive")
if not payload.notification_type.strip():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "notification_type must not be empty")
if not payload.effect_type.strip():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty")
recipients: dict[int, User] = {}
target = payload.target
if target:
users = await _resolve_target_users(db, target)
recipients.update({user.id: user for user in users if user and user.id})
target_user_id = payload.user_id
user: Optional[User] = None
if payload.telegram_id is not None:
user = await get_user_by_telegram_id(db, payload.telegram_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
if target_user_id and target_user_id != user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Provided user_id does not match telegram_id",
)
target_user_id = user.id
if target_user_id is not None:
if user is None:
user = await db.get(User, target_user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
recipients[target_user_id] = user
if not recipients:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Пустая аудитория: укажите target или конкретного пользователя",
)
if payload.subscription_id is not None:
if len(recipients) > 1:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"subscription_id можно использовать только при отправке одному пользователю",
)
sole_user = next(iter(recipients.values()))
subscription = await db.get(Subscription, payload.subscription_id)
if not subscription:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
if subscription.user_id != sole_user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Subscription does not belong to the user",
)
created_offers = 0
for user in recipients.values():
offer = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=payload.subscription_id,
notification_type=payload.notification_type.strip(),
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
valid_hours=payload.valid_hours,
effect_type=payload.effect_type,
extra_data=payload.extra_data,
)
if offer:
created_offers += 1
return PromoOfferBroadcastResponse(
created_offers=created_offers,
user_ids=list(recipients.keys()),
target=payload.target,
)
@router.get("/logs", response_model=PromoOfferLogListResponse)
async def get_promo_offer_logs(
_: Any = Security(require_api_token),
+19 -2
View File
@@ -93,6 +93,19 @@ def _serialize_node(node_data: Dict[str, Any]) -> RemnaWaveNode:
users_online=node_data.get("users_online"),
traffic_used_bytes=node_data.get("traffic_used_bytes"),
traffic_limit_bytes=node_data.get("traffic_limit_bytes"),
last_status_change=_parse_last_updated(node_data.get("last_status_change")),
last_status_message=node_data.get("last_status_message"),
xray_uptime=node_data.get("xray_uptime"),
is_traffic_tracking_active=bool(node_data.get("is_traffic_tracking_active", False)),
traffic_reset_day=node_data.get("traffic_reset_day"),
notify_percent=node_data.get("notify_percent"),
consumption_multiplier=float(node_data.get("consumption_multiplier", 1.0)),
cpu_count=node_data.get("cpu_count"),
cpu_model=node_data.get("cpu_model"),
total_ram=node_data.get("total_ram"),
created_at=_parse_last_updated(node_data.get("created_at")),
updated_at=_parse_last_updated(node_data.get("updated_at")),
provider_uuid=node_data.get("provider_uuid"),
)
@@ -291,9 +304,13 @@ async def create_squad(
service = _get_service()
_ensure_service_configured(service)
success = await service.create_squad(payload.name, payload.inbound_uuids)
squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids)
success = squad_uuid is not None
detail = "Сквад успешно создан" if success else "Не удалось создать сквад"
return RemnaWaveOperationResponse(success=success, detail=detail)
data = {"uuid": squad_uuid} if success else None
return RemnaWaveOperationResponse(success=success, detail=detail, data=data)
@router.patch("/squads/{squad_uuid}", response_model=RemnaWaveOperationResponse)
+214 -39
View File
@@ -2,6 +2,11 @@ from __future__ import annotations
from datetime import datetime
from app.database.crud.referral import get_referral_statistics
from app.database.crud.subscription import get_subscriptions_statistics, get_trial_statistics
from app.database.crud.transaction import get_transactions_statistics
from app.database.crud.user import get_users_statistics
from fastapi import APIRouter, Depends, Security
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,43 +27,11 @@ from ..dependencies import get_db_session, require_api_token
router = APIRouter()
@router.get(
"/overview",
summary="Общая статистика",
response_description="Агрегированные показатели пользователей, подписок, саппорта и платежей",
responses={
200: {
"content": {
"application/json": {
"example": {
"users": {
"total": 12345,
"active": 9876,
"blocked": 321,
"balance_kopeks": 1234567,
"balance_rubles": 12345.67,
},
"subscriptions": {
"active": 4321,
"expired": 210,
},
"support": {
"open_tickets": 42,
},
"payments": {
"today_kopeks": 654321,
"today_rubles": 6543.21,
},
}
}
}
}
},
)
async def stats_overview(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> dict[str, object]:
def _kopeks_to_rubles(value: int | float | None) -> float:
return round((value or 0) / 100, 2)
async def _get_overview(db: AsyncSession) -> dict[str, object]:
total_users = await db.scalar(select(func.count()).select_from(User)) or 0
active_users = await db.scalar(
select(func.count()).select_from(User).where(User.status == UserStatus.ACTIVE.value)
@@ -103,7 +76,7 @@ async def stats_overview(
"active": active_users,
"blocked": blocked_users,
"balance_kopeks": int(total_balance_kopeks),
"balance_rubles": round(total_balance_kopeks / 100, 2),
"balance_rubles": _kopeks_to_rubles(total_balance_kopeks),
},
"subscriptions": {
"active": active_subscriptions,
@@ -114,6 +87,208 @@ async def stats_overview(
},
"payments": {
"today_kopeks": int(today_transactions),
"today_rubles": round(today_transactions / 100, 2),
"today_rubles": _kopeks_to_rubles(today_transactions),
},
}
@router.get(
"/overview",
summary="Общая статистика",
response_description="Агрегированные показатели пользователей, подписок, саппорта и платежей",
responses={
200: {
"content": {
"application/json": {
"example": {
"users": {
"total": 12345,
"active": 9876,
"blocked": 321,
"balance_kopeks": 1234567,
"balance_rubles": 12345.67,
},
"subscriptions": {
"active": 4321,
"expired": 210,
},
"support": {
"open_tickets": 42,
},
"payments": {
"today_kopeks": 654321,
"today_rubles": 6543.21,
},
}
}
}
}
},
)
async def stats_overview(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> dict[str, object]:
return await _get_overview(db)
@router.get(
"/full",
summary="Полная статистика",
response_description="Расширенные показатели пользователей, подписок, платежей и рефералов",
responses={
200: {
"content": {
"application/json": {
"example": {
"overview": {
"users": {
"total": 12345,
"active": 9876,
"blocked": 321,
"balance_kopeks": 1234567,
"balance_rubles": 12345.67,
},
"subscriptions": {
"active": 4321,
"expired": 210,
},
"support": {
"open_tickets": 42,
},
"payments": {
"today_kopeks": 654321,
"today_rubles": 6543.21,
},
},
"users": {
"total_users": 12345,
"active_users": 9876,
"blocked_users": 321,
"new_today": 12,
"new_week": 345,
"new_month": 1234,
},
"subscriptions": {
"total_subscriptions": 9876,
"active_subscriptions": 8765,
"trial_subscriptions": 321,
"paid_subscriptions": 8444,
"purchased_today": 12,
"purchased_week": 210,
"purchased_month": 765,
"trial_to_paid_conversion": 42.5,
"renewals_count": 123,
"trial_statistics": {
"used_trials": 555,
"active_trials": 210,
"resettable_trials": 42,
},
},
"transactions": {
"period": {
"start_date": "2024-06-01T00:00:00Z",
"end_date": "2024-06-30T23:59:59Z",
},
"totals": {
"income_kopeks": 1234567,
"income_rubles": 12345.67,
"expenses_kopeks": 21000,
"expenses_rubles": 210,
"profit_kopeks": 1213567,
"profit_rubles": 12135.67,
"subscription_income_kopeks": 987654,
"subscription_income_rubles": 9876.54,
},
"today": {
"transactions_count": 42,
"income_kopeks": 654321,
"income_rubles": 6543.21,
},
"by_type": {
"deposit": {"count": 123, "amount": 1234567},
"withdrawal": {"count": 10, "amount": 21000},
},
"by_payment_method": {
"card": {"count": 100, "amount": 1000000}
},
},
"referrals": {
"users_with_referrals": 4321,
"active_referrers": 123,
"total_paid_kopeks": 765432,
"total_paid_rubles": 7654.32,
"today_earnings_kopeks": 12345,
"today_earnings_rubles": 123.45,
"week_earnings_kopeks": 23456,
"week_earnings_rubles": 234.56,
"month_earnings_kopeks": 34567,
"month_earnings_rubles": 345.67,
"top_referrers": [
{
"user_id": 123456789,
"display_name": "@testuser",
"username": "testuser",
"telegram_id": 123456789,
"total_earned_kopeks": 54321,
"referrals_count": 42,
}
],
},
}
}
}
}
},
)
async def stats_full(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> dict[str, object]:
overview = await _get_overview(db)
users_stats = await get_users_statistics(db)
subscriptions_stats = await get_subscriptions_statistics(db)
trial_stats = await get_trial_statistics(db)
transactions_stats = await get_transactions_statistics(db)
referral_stats = await get_referral_statistics(db)
transactions_totals = transactions_stats.get("totals", {})
transactions_today = transactions_stats.get("today", {})
transactions_totals = {
**transactions_totals,
"income_rubles": _kopeks_to_rubles(transactions_totals.get("income_kopeks")),
"expenses_rubles": _kopeks_to_rubles(transactions_totals.get("expenses_kopeks")),
"profit_rubles": _kopeks_to_rubles(transactions_totals.get("profit_kopeks")),
"subscription_income_rubles": _kopeks_to_rubles(
transactions_totals.get("subscription_income_kopeks")
),
}
transactions_today = {
**transactions_today,
"income_rubles": _kopeks_to_rubles(transactions_today.get("income_kopeks")),
}
referral_stats = {
**referral_stats,
"total_paid_rubles": _kopeks_to_rubles(referral_stats.get("total_paid_kopeks")),
"today_earnings_rubles": _kopeks_to_rubles(
referral_stats.get("today_earnings_kopeks")
),
"week_earnings_rubles": _kopeks_to_rubles(referral_stats.get("week_earnings_kopeks")),
"month_earnings_rubles": _kopeks_to_rubles(referral_stats.get("month_earnings_kopeks")),
}
return {
"overview": overview,
"users": users_stats,
"subscriptions": {**subscriptions_stats, "trial_statistics": trial_stats},
"transactions": {
**transactions_stats,
"totals": transactions_totals,
"today": transactions_today,
},
"referrals": referral_stats,
}
+63 -3
View File
@@ -1,9 +1,9 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, ClassVar, Dict, List, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, validator
class PromoOfferUserInfo(BaseModel):
@@ -50,7 +50,8 @@ class PromoOfferListResponse(BaseModel):
class PromoOfferCreateRequest(BaseModel):
user_id: int
user_id: Optional[int] = Field(None, ge=1)
telegram_id: Optional[int] = Field(None, ge=1)
notification_type: str = Field(..., min_length=1)
valid_hours: int = Field(..., ge=1, description="Срок действия предложения в часах")
discount_percent: int = Field(0, ge=0)
@@ -60,6 +61,65 @@ class PromoOfferCreateRequest(BaseModel):
extra_data: Dict[str, Any] = Field(default_factory=dict)
class PromoOfferBroadcastRequest(PromoOfferCreateRequest):
target: Optional[str] = Field(
None,
description=(
"Категория пользователей для рассылки. Поддерживает те же сегменты, что "
"и API рассылок (all, active, trial, custom_today и т.д.)."
),
)
_ALLOWED_TARGETS: ClassVar[set[str]] = {
"all",
"active",
"trial",
"no",
"expiring",
"expired",
"active_zero",
"trial_zero",
"zero",
}
_CUSTOM_TARGETS: ClassVar[set[str]] = {
"today",
"week",
"month",
"active_today",
"inactive_week",
"inactive_month",
"referrals",
"direct",
}
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
"no_sub": "no",
}
@validator("target")
def validate_target(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
normalized = value.strip().lower()
normalized = cls._TARGET_ALIASES.get(normalized, normalized)
if normalized in cls._ALLOWED_TARGETS:
return normalized
if normalized.startswith("custom_"):
criteria = normalized[len("custom_"):]
if criteria in cls._CUSTOM_TARGETS:
return normalized
raise ValueError("Unsupported target value")
class PromoOfferBroadcastResponse(BaseModel):
created_offers: int
user_ids: List[int]
target: Optional[str] = None
class PromoOfferTemplateResponse(BaseModel):
id: int
name: str
+13
View File
@@ -32,6 +32,19 @@ class RemnaWaveNode(BaseModel):
users_online: Optional[int] = None
traffic_used_bytes: Optional[int] = None
traffic_limit_bytes: Optional[int] = None
last_status_change: Optional[datetime] = None
last_status_message: Optional[str] = None
xray_uptime: Optional[str] = None
is_traffic_tracking_active: bool = False
traffic_reset_day: Optional[int] = None
notify_percent: Optional[int] = None
consumption_multiplier: float = 1.0
cpu_count: Optional[int] = None
cpu_model: Optional[str] = None
total_ram: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
provider_uuid: Optional[str] = None
class RemnaWaveNodeListResponse(BaseModel):
+14 -6
View File
@@ -13,6 +13,7 @@ from aiogram import Dispatcher
from app.config import settings
from app.services.payment_service import PaymentService
from app.webapi.app import create_web_api_app
from app.webapi.docs import add_redoc_endpoint
from . import payments
from . import telegram
@@ -47,12 +48,19 @@ def _create_base_app() -> FastAPI:
app = create_web_api_app()
else:
app = FastAPI(
title="Bedolaga Unified Server",
version=settings.WEB_API_VERSION,
docs_url=docs_config.get("docs_url"),
redoc_url=docs_config.get("redoc_url"),
openapi_url=docs_config.get("openapi_url"),
)
title="Bedolaga Unified Server",
version=settings.WEB_API_VERSION,
docs_url=docs_config.get("docs_url"),
redoc_url=None,
openapi_url=docs_config.get("openapi_url"),
)
add_redoc_endpoint(
app,
redoc_url=docs_config.get("redoc_url"),
openapi_url=docs_config.get("openapi_url"),
title="Bedolaga Unified Server",
)
_attach_docs_alias(app, app.docs_url)
return app
+1 -1
View File
@@ -128,7 +128,7 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
| `PATCH` | `/promo-groups/{id}` | Обновить промо-группу.
| `DELETE` | `/promo-groups/{id}` | Удалить промо-группу.
| `GET` | `/promo-offers` | Список промо-предложений с фильтрами по пользователю, статусу и типу уведомления.
| `POST` | `/promo-offers` | Создать или обновить персональное промо-предложение пользователю.
| `POST` | `/promo-offers` | Создать или обновить персональное промо-предложение пользователю. ID может быть как внутренним (user.id), так и Telegram ID (user.telegram_id).
| `GET` | `/promo-offers/{id}` | Детали конкретного промо-предложения.
| `GET` | `/promo-offers/templates` | Список шаблонов промо-предложений.
| `GET` | `/promo-offers/templates/{id}` | Получить данные шаблона промо-предложения.
+10 -1
View File
@@ -133,7 +133,6 @@ async def test_unified_app_docs_enabled_with_alias(monkeypatch: pytest.MonkeyPat
app = _build_unified_app(monkeypatch, docs_enabled=True)
assert app.docs_url == "/docs"
assert app.redoc_url == "/redoc"
assert app.openapi_url == "/openapi.json"
alias_route = next(
@@ -143,6 +142,16 @@ async def test_unified_app_docs_enabled_with_alias(monkeypatch: pytest.MonkeyPat
assert alias_route is not None
assert getattr(alias_route, "include_in_schema", True) is False
redoc_route = next(
(route for route in app.routes if getattr(route, "path", None) == "/redoc"),
None,
)
assert redoc_route is not None
assert getattr(redoc_route, "include_in_schema", True) is False
response = await alias_route.endpoint() # type: ignore[func-returns-value]
assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT
assert response.headers["location"] == "/docs"
redoc_response = await redoc_route.endpoint() # type: ignore[func-returns-value]
assert b"ReDoc" in redoc_response.body # type: ignore[attr-defined]