Compare commits
220 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 879607982e | |||
| 5482b31fcc | |||
| f5be0e6807 | |||
| c3a60871c9 | |||
| 2628444866 | |||
| c1a7360c66 | |||
| b8c2bdc9e9 | |||
| 573735ffa9 | |||
| 9b3a9883b0 | |||
| 8cc54d376a | |||
| 26910dbbcc | |||
| b131fea7d1 | |||
| 84bc36d855 | |||
| ea5bd22c28 | |||
| e9f1064d73 | |||
| f3cba203dd | |||
| 0ff15850b7 | |||
| 383563c399 | |||
| 40bbb54f56 | |||
| a09eafb9c1 | |||
| 98b15d5c82 | |||
| b5cb547fe2 | |||
| a7af39fb34 | |||
| 02fa7de016 | |||
| b14ee9a6a4 | |||
| be2181c4cb | |||
| 6cd65812ac | |||
| b282fd1525 | |||
| 7c38e672e3 | |||
| c2685476b0 | |||
| a80e2176de | |||
| bf4de82442 | |||
| 22c736a41c | |||
| a1c5f9d987 | |||
| 1ca1df1403 | |||
| be3b554cde | |||
| efc8f04836 | |||
| 1cf7138430 | |||
| 64c8145f1a | |||
| dc62ad927d | |||
| 61188d8a97 | |||
| f92d5e996e | |||
| 817fb82476 | |||
| 9635f9464e | |||
| 33e0d58e4d | |||
| 5fb951328d | |||
| 5f5255d107 | |||
| 4bf7f83f1e | |||
| cce6d99a74 | |||
| 4060425ac4 | |||
| 5023bec388 | |||
| e0d90874bd | |||
| dd62ba1efd | |||
| ceb8191b43 | |||
| da2e55bb25 | |||
| 8a3a990e39 | |||
| e3cd2b0b92 | |||
| 1777128590 | |||
| 04a5fe9d77 | |||
| 595a4eb3a3 | |||
| ede9259347 | |||
| d87556fd9f | |||
| 198ce23625 | |||
| 1be83b7b48 | |||
| 2b2b94936f | |||
| 84df8f727e | |||
| 84aaaf6254 | |||
| ce867efaea | |||
| 492d9f5170 | |||
| dcbbf7ba44 | |||
| 04e7f818c3 | |||
| 2713bdf08e | |||
| b2bfaf0e81 | |||
| 25a4be076b | |||
| cbd721a10f | |||
| 3ff8cb5e61 | |||
| 6f144f64b1 | |||
| 93200d1450 | |||
| 3f60533c50 | |||
| 41ab0abac8 | |||
| a5e4fb24ca | |||
| 6d2ce895e4 | |||
| f41a4197b6 | |||
| 200c728342 | |||
| d8571c4f3e | |||
| 233ceaf400 | |||
| e8bca6926a | |||
| 9e8aecae4d | |||
| e75b44695e | |||
| 18784b3ef7 | |||
| 98a241a301 | |||
| 70ee783a7a | |||
| 00cd0bb5b0 | |||
| 02b7365ba3 | |||
| a6da530848 | |||
| f6e6af5e42 | |||
| 6f834aeec0 | |||
| 8bfdde0d51 | |||
| 137ac3c58c | |||
| d7a0d9f7a5 | |||
| 9b477a700d | |||
| 77a9dcf5af | |||
| 1c1f7ade78 | |||
| 4aa593f8c6 | |||
| cefd39f7e9 | |||
| cfeb8fd9a3 | |||
| 241a053252 | |||
| b53726276c | |||
| 7316e4aa48 | |||
| 889629e3c1 | |||
| fbecf5bf7a | |||
| faae87212e | |||
| bfa355e956 | |||
| a2c0001f5a | |||
| 5fa2339018 | |||
| 9ba183b6a6 | |||
| 6d142a7f38 | |||
| 6f157e2923 | |||
| dccb070376 | |||
| f0b70c9424 | |||
| 9a847ec6b6 | |||
| 4a94d6a966 | |||
| ae654452e4 | |||
| 2e71fd89c9 | |||
| caf497ed28 | |||
| bb5dd7176b | |||
| ac83a65d3b | |||
| 147cbfa625 | |||
| 497775c382 | |||
| 3a9b8dc0f8 | |||
| 0fd3452dd6 | |||
| 1d5c277df0 | |||
| 6483efc099 | |||
| 149a9f599d | |||
| 1bd55c4d80 | |||
| ef9abe8f91 | |||
| 461b820ef1 | |||
| 0535353124 | |||
| 19804607ac | |||
| 272de0bd33 | |||
| b53a54a237 | |||
| e57c6d0ade | |||
| 18556f54d3 | |||
| a5c081887e | |||
| 80efd4ab86 | |||
| 3df541689b | |||
| 06c90dc3ca | |||
| d3599a6a0c | |||
| afab5f2515 | |||
| 37e7e74d28 | |||
| 25eff6929b | |||
| 72888ff02d | |||
| 9c572a2771 | |||
| 0f6d6256a2 | |||
| 2191e63803 | |||
| 43d75dc3ec | |||
| 59cae2fde6 | |||
| 4f4c9e5426 | |||
| df03f16a9d | |||
| 57db753218 | |||
| 5d4a2b51ef | |||
| 67c6185f8b | |||
| e3d18f7a1d | |||
| 4bcefaa7b8 | |||
| 9d9551c072 | |||
| 4e6bc5ae71 | |||
| c353c69180 | |||
| 9c0e489f3b | |||
| d4028a9d9f | |||
| 25960c180f | |||
| 926bd0cc55 | |||
| 0d714b4dff | |||
| f5ccc1f103 | |||
| 565e6d963b | |||
| c057d1e3d7 | |||
| 10c3151d9e | |||
| cbf88226b4 | |||
| 4ad53ca762 | |||
| a2ace90898 | |||
| 814e38ef59 | |||
| 871e1ae675 | |||
| 67c973124a | |||
| 42cfc3745d | |||
| ccb4dfa267 | |||
| 8a9606db97 | |||
| c3846a998a | |||
| 0bcaab3c50 | |||
| 6dc5150804 | |||
| 7bba18673a | |||
| de8853bd7c | |||
| 6e938b2693 | |||
| cb41f9263f | |||
| fe3f837a58 | |||
| 65374e38b3 | |||
| 24fd4ef842 | |||
| 5682971722 | |||
| c286c44041 | |||
| 1dc3013376 | |||
| ea61734f3a | |||
| ec521b84e7 | |||
| 62189f8642 | |||
| a8e1085884 | |||
| 2bd76d118a | |||
| b258bd95bd | |||
| ab566986c7 | |||
| 033230f73e | |||
| 063d77928c | |||
| ff71f4b637 | |||
| 88f4b2b2fa | |||
| b64bda64dc | |||
| dfeea949dd | |||
| 54d798f358 | |||
| 4546450587 | |||
| ec6b6e6b09 | |||
| cb5d4df4f0 | |||
| 1a060759f9 | |||
| 44fc2fd771 | |||
| 92dbf3269b | |||
| c6b4de05f3 | |||
| ae3c34c2fe |
@@ -280,11 +280,21 @@ HIDE_SUBSCRIPTION_LINK=false
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
|
||||
# Параметры режима happ_cryptolink
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
|
||||
HAPP_DOWNLOAD_LINK_IOS=
|
||||
HAPP_DOWNLOAD_LINK_ANDROID=
|
||||
HAPP_DOWNLOAD_LINK_MACOS=
|
||||
HAPP_DOWNLOAD_LINK_WINDOWS=
|
||||
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
|
||||
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
|
||||
|
||||
# Пропустить принятие правил использования бота
|
||||
SKIP_RULES_ACCEPT=false
|
||||
# Пропустить запрос реферального кода
|
||||
|
||||
@@ -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.3.9-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.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.3.9-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.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.3.9-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.0-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.9-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.0-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.9-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.0-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.9-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.0-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.3.9"
|
||||
ARG VERSION="v2.4.0"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -254,6 +254,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
# Автоматические отчеты
|
||||
ADMIN_REPORTS_ENABLED=false
|
||||
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
|
||||
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
@@ -501,12 +506,6 @@ PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
PAL24_REQUEST_TIMEOUT=30
|
||||
|
||||
# Настройки PayPalych
|
||||
1. Включите интеграцию (`PAL24_ENABLED=true`) и укажите `PAL24_API_TOKEN`, `PAL24_SHOP_ID`, а также `PAL24_SIGNATURE_TOKEN` для проверки подписи уведомлений.
|
||||
2. Настройте в кабинете PayPalych **Result URL** и success/fail redirect на `https://<ваш-домен>/pal24-webhook`.
|
||||
3. Убедитесь, что порт `PAL24_WEBHOOK_PORT` (по умолчанию `8084`) проброшен через прокси/фаервол.
|
||||
4. Для теста можно отправить postback вручную (пример команды см. ниже в разделе «Проверка PayPalych postback»).
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
@@ -521,11 +520,21 @@ HIDE_SUBSCRIPTION_LINK=false
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
|
||||
# Параметры режима happ_cryptolink
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
|
||||
HAPP_DOWNLOAD_LINK_IOS=
|
||||
HAPP_DOWNLOAD_LINK_ANDROID=
|
||||
HAPP_DOWNLOAD_LINK_MACOS=
|
||||
HAPP_DOWNLOAD_LINK_WINDOWS=
|
||||
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
|
||||
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
|
||||
|
||||
# Пропустить принятие правил использования бота
|
||||
SKIP_RULES_ACCEPT=false
|
||||
# Пропустить запрос реферального кода
|
||||
|
||||
@@ -39,6 +39,7 @@ from app.handlers.admin import (
|
||||
welcome_text as admin_welcome_text,
|
||||
tickets as admin_tickets,
|
||||
reports as admin_reports,
|
||||
bot_configuration as admin_bot_configuration,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -141,6 +142,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_welcome_text.register_welcome_text_handlers(dp)
|
||||
admin_tickets.register_handlers(dp)
|
||||
admin_reports.register_handlers(dp)
|
||||
admin_bot_configuration.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
+38
-3
@@ -53,8 +53,8 @@ class Settings(BaseSettings):
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
REMNAWAVE_API_URL: str
|
||||
REMNAWAVE_API_KEY: str
|
||||
REMNAWAVE_API_URL: str = ""
|
||||
REMNAWAVE_API_KEY: str = ""
|
||||
REMNAWAVE_SECRET_KEY: Optional[str] = None
|
||||
|
||||
REMNAWAVE_USERNAME: Optional[str] = None
|
||||
@@ -68,7 +68,7 @@ class Settings(BaseSettings):
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: str
|
||||
TRIAL_SQUAD_UUID: str = ""
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
|
||||
@@ -209,6 +209,13 @@ class Settings(BaseSettings):
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False
|
||||
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_WINDOWS: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_PC: Optional[str] = None
|
||||
HIDE_SUBSCRIPTION_LINK: bool = False
|
||||
ENABLE_LOGO_MODE: bool = True
|
||||
LOGO_FILE: str = "vpn_logo.png"
|
||||
@@ -543,6 +550,34 @@ class Settings(BaseSettings):
|
||||
def get_cryptobot_invoice_expires_seconds(self) -> int:
|
||||
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
|
||||
|
||||
def is_happ_cryptolink_mode(self) -> bool:
|
||||
return self.CONNECT_BUTTON_MODE == "happ_cryptolink"
|
||||
|
||||
def is_happ_download_button_enabled(self) -> bool:
|
||||
return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED
|
||||
|
||||
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
|
||||
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
|
||||
return template or None
|
||||
|
||||
def get_happ_download_link(self, platform: str) -> Optional[str]:
|
||||
platform_key = platform.lower()
|
||||
|
||||
if platform_key == "pc":
|
||||
platform_key = "windows"
|
||||
|
||||
links = {
|
||||
"ios": (self.HAPP_DOWNLOAD_LINK_IOS or "").strip(),
|
||||
"android": (self.HAPP_DOWNLOAD_LINK_ANDROID or "").strip(),
|
||||
"macos": (self.HAPP_DOWNLOAD_LINK_MACOS or "").strip(),
|
||||
"windows": (
|
||||
(self.HAPP_DOWNLOAD_LINK_WINDOWS or "").strip()
|
||||
or (self.HAPP_DOWNLOAD_LINK_PC or "").strip()
|
||||
),
|
||||
}
|
||||
link = links.get(platform_key)
|
||||
return link if link else None
|
||||
|
||||
def is_maintenance_mode(self) -> bool:
|
||||
return self.MAINTENANCE_MODE
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ async def create_promo_group(
|
||||
device_discount_percent: int,
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
apply_discounts_to_addons: bool = True,
|
||||
) -> PromoGroup:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
|
||||
@@ -76,6 +77,7 @@ async def create_promo_group(
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
period_discounts=normalized_period_discounts or None,
|
||||
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=bool(apply_discounts_to_addons),
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
@@ -84,13 +86,14 @@ async def create_promo_group(
|
||||
await db.refresh(promo_group)
|
||||
|
||||
logger.info(
|
||||
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽",
|
||||
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
|
||||
promo_group.name,
|
||||
promo_group.server_discount_percent,
|
||||
promo_group.traffic_discount_percent,
|
||||
promo_group.device_discount_percent,
|
||||
normalized_period_discounts,
|
||||
(auto_assign_total_spent_kopeks or 0) / 100,
|
||||
"on" if promo_group.apply_discounts_to_addons else "off",
|
||||
)
|
||||
|
||||
return promo_group
|
||||
@@ -106,6 +109,7 @@ async def update_promo_group(
|
||||
device_discount_percent: Optional[int] = None,
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
apply_discounts_to_addons: Optional[bool] = None,
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
@@ -120,6 +124,8 @@ async def update_promo_group(
|
||||
group.period_discounts = normalized_period_discounts or None
|
||||
if auto_assign_total_spent_kopeks is not None:
|
||||
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
|
||||
if apply_discounts_to_addons is not None:
|
||||
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
@@ -965,7 +965,8 @@ async def create_subscription(
|
||||
device_limit: int = 1,
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = ""
|
||||
subscription_url: str = "",
|
||||
subscription_crypto_link: str = ""
|
||||
) -> Subscription:
|
||||
|
||||
if end_date is None:
|
||||
@@ -984,7 +985,8 @@ async def create_subscription(
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
|
||||
async def upsert_system_setting(
|
||||
db: AsyncSession,
|
||||
key: str,
|
||||
value: Optional[str],
|
||||
description: Optional[str] = None,
|
||||
) -> SystemSetting:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = SystemSetting(key=key, value=value, description=description)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.value = value
|
||||
if description is not None:
|
||||
setting.description = description
|
||||
|
||||
await db.flush()
|
||||
return setting
|
||||
|
||||
|
||||
async def delete_system_setting(db: AsyncSession, key: str) -> None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting is not None:
|
||||
await db.delete(setting)
|
||||
await db.flush()
|
||||
|
||||
@@ -292,6 +292,7 @@ class PromoGroup(Base):
|
||||
device_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
period_discounts = Column(JSON, nullable=True, default=dict)
|
||||
auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None)
|
||||
apply_discounts_to_addons = Column(Boolean, nullable=False, default=True)
|
||||
is_default = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
@@ -387,6 +388,7 @@ class User(Base):
|
||||
discount_offers = relationship("DiscountOffer", back_populates="user")
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
||||
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
|
||||
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
|
||||
last_remnawave_sync = Column(DateTime, nullable=True)
|
||||
trojan_password = Column(String(255), nullable=True)
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
@@ -435,7 +437,8 @@ class Subscription(Base):
|
||||
traffic_used_gb = Column(Float, default=0.0)
|
||||
|
||||
subscription_url = Column(String, nullable=True)
|
||||
|
||||
subscription_crypto_link = Column(String, nullable=True)
|
||||
|
||||
device_limit = Column(Integer, default=1)
|
||||
|
||||
connected_squads = Column(JSON, default=list)
|
||||
|
||||
@@ -931,6 +931,54 @@ async def ensure_promo_groups_setup():
|
||||
"Добавлена колонка promo_groups.auto_assign_total_spent_kopeks"
|
||||
)
|
||||
|
||||
addon_discount_column_exists = await check_column_exists(
|
||||
"promo_groups", "apply_discounts_to_addons"
|
||||
)
|
||||
|
||||
if not addon_discount_column_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT 1"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL"
|
||||
)
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT TRUE"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE promo_groups SET apply_discounts_to_addons = TRUE WHERE apply_discounts_to_addons IS NULL"
|
||||
)
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons TINYINT(1) NOT NULL DEFAULT 1"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL"
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"Добавлена колонка promo_groups.apply_discounts_to_addons"
|
||||
)
|
||||
|
||||
column_exists = await check_column_exists("users", "promo_group_id")
|
||||
|
||||
if not column_exists:
|
||||
@@ -977,6 +1025,39 @@ async def ensure_promo_groups_setup():
|
||||
|
||||
logger.info("Добавлена колонка users.auto_promo_group_assigned")
|
||||
|
||||
threshold_column_exists = await check_column_exists(
|
||||
"users", "auto_promo_group_threshold_kopeks"
|
||||
)
|
||||
|
||||
if not threshold_column_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks BIGINT NOT NULL DEFAULT 0"
|
||||
)
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks BIGINT NOT NULL DEFAULT 0"
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Неподдерживаемый тип БД для users.auto_promo_group_threshold_kopeks: {db_type}"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"Добавлена колонка users.auto_promo_group_threshold_kopeks"
|
||||
)
|
||||
|
||||
index_exists = await check_index_exists("users", "ix_users_promo_group_id")
|
||||
|
||||
if not index_exists:
|
||||
@@ -1366,6 +1447,39 @@ async def add_ticket_sla_columns():
|
||||
logger.error(f"Ошибка добавления SLA колонки в tickets: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_subscription_crypto_link_column() -> bool:
|
||||
column_exists = await check_column_exists('subscriptions', 'subscription_crypto_link')
|
||||
if column_exists:
|
||||
logger.info("ℹ️ Колонка subscription_crypto_link уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link TEXT"))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link VARCHAR"))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link VARCHAR(512)"))
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для добавления subscription_crypto_link: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(
|
||||
"UPDATE subscriptions SET subscription_crypto_link = subscription_url "
|
||||
"WHERE subscription_crypto_link IS NULL OR subscription_crypto_link = ''"
|
||||
))
|
||||
|
||||
logger.info("✅ Добавлена колонка subscription_crypto_link в таблицу subscriptions")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления колонки subscription_crypto_link: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -1719,6 +1833,59 @@ async def ensure_server_promo_groups_setup() -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
async def create_system_settings_table() -> bool:
|
||||
table_exists = await check_table_exists("system_settings")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица system_settings уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE system_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key VARCHAR(255) NOT NULL UNIQUE,
|
||||
value TEXT NULL,
|
||||
description TEXT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE system_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
key VARCHAR(255) NOT NULL UNIQUE,
|
||||
value TEXT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE system_settings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
key VARCHAR(255) NOT NULL UNIQUE,
|
||||
value TEXT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица system_settings создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания таблицы system_settings: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -1730,6 +1897,13 @@ async def run_universal_migration():
|
||||
if not referral_migration_success:
|
||||
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===")
|
||||
system_settings_ready = await create_system_settings_table()
|
||||
if system_settings_ready:
|
||||
logger.info("✅ Таблица system_settings готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей system_settings")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===")
|
||||
cryptobot_created = await create_cryptobot_payments_table()
|
||||
if cryptobot_created:
|
||||
@@ -1799,6 +1973,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ CRYPTO LINK ДЛЯ ПОДПИСОК ===")
|
||||
crypto_link_added = await add_subscription_crypto_link_column()
|
||||
if crypto_link_added:
|
||||
logger.info("✅ Колонка subscription_crypto_link готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -1954,7 +2135,10 @@ async def check_migration_status():
|
||||
"users_promo_group_column": False,
|
||||
"promo_groups_period_discounts_column": False,
|
||||
"promo_groups_auto_assign_column": False,
|
||||
"promo_groups_addon_discount_column": False,
|
||||
"users_auto_promo_group_assigned_column": False,
|
||||
"users_auto_promo_group_threshold_column": False,
|
||||
"subscription_crypto_link_column": False,
|
||||
}
|
||||
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
@@ -1970,7 +2154,10 @@ async def check_migration_status():
|
||||
status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id')
|
||||
status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts')
|
||||
status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks')
|
||||
status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons')
|
||||
status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned')
|
||||
status["users_auto_promo_group_threshold_column"] = await check_column_exists('users', 'auto_promo_group_threshold_kopeks')
|
||||
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
|
||||
|
||||
media_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'has_media') and
|
||||
@@ -2006,7 +2193,10 @@ async def check_migration_status():
|
||||
"users_promo_group_column": "Колонка promo_group_id у пользователей",
|
||||
"promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп",
|
||||
"promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп",
|
||||
"promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп",
|
||||
"users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей",
|
||||
"users_auto_promo_group_threshold_column": "Порог последней авто-промогруппы у пользователей",
|
||||
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
|
||||
Vendored
+18
-2
@@ -58,6 +58,8 @@ class RemnaWaveUser:
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -92,6 +94,8 @@ class SubscriptionInfo:
|
||||
ss_conf_links: Dict[str, str]
|
||||
subscription_url: str
|
||||
happ: Optional[Dict[str, str]]
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
@@ -584,6 +588,10 @@ class RemnaWaveAPI:
|
||||
|
||||
|
||||
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')
|
||||
|
||||
return RemnaWaveUser(
|
||||
uuid=user_data['uuid'],
|
||||
short_uuid=user_data['shortUuid'],
|
||||
@@ -612,7 +620,9 @@ class RemnaWaveAPI:
|
||||
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)
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
|
||||
happ_link=happ_link,
|
||||
happ_crypto_link=happ_crypto_link
|
||||
)
|
||||
|
||||
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
@@ -645,13 +655,19 @@ class RemnaWaveAPI:
|
||||
)
|
||||
|
||||
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
|
||||
happ_data = 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')
|
||||
|
||||
return SubscriptionInfo(
|
||||
is_found=data['isFound'],
|
||||
user=data.get('user'),
|
||||
links=data.get('links', []),
|
||||
ss_conf_links=data.get('ssConfLinks', {}),
|
||||
subscription_url=data.get('subscriptionUrl', ''),
|
||||
happ=data.get('happ')
|
||||
happ=data.get('happ'),
|
||||
happ_link=happ_link,
|
||||
happ_crypto_link=happ_crypto_link
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -28,14 +29,50 @@ from app.utils.pricing_utils import format_period_description
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_discount_line(texts, group) -> str:
|
||||
def _format_discount_lines(texts, group) -> list[str]:
|
||||
return [
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER",
|
||||
"💸 Скидки промогруппы:",
|
||||
),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS",
|
||||
"• Серверы: {percent}%",
|
||||
).format(percent=group.server_discount_percent),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC",
|
||||
"• Трафик: {percent}%",
|
||||
).format(percent=group.traffic_discount_percent),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES",
|
||||
"• Устройства: {percent}%",
|
||||
).format(percent=group.device_discount_percent),
|
||||
]
|
||||
|
||||
|
||||
def _format_addon_discounts_line(texts, group: PromoGroup) -> str:
|
||||
enabled = getattr(group, "apply_discounts_to_addons", True)
|
||||
if enabled:
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED",
|
||||
"🧩 Скидки на доп. услуги: <b>включены</b>",
|
||||
)
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS",
|
||||
"Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
).format(
|
||||
servers=group.server_discount_percent,
|
||||
traffic=group.traffic_discount_percent,
|
||||
devices=group.device_discount_percent,
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED",
|
||||
"🧩 Скидки на доп. услуги: <b>отключены</b>",
|
||||
)
|
||||
|
||||
|
||||
def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str:
|
||||
enabled = getattr(group, "apply_discounts_to_addons", True)
|
||||
if enabled:
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE",
|
||||
"🧩 Отключить скидки на доп. услуги",
|
||||
)
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE",
|
||||
"🧩 Включить скидки на доп. услуги",
|
||||
)
|
||||
|
||||
|
||||
@@ -254,11 +291,10 @@ def _build_edit_menu_content(
|
||||
"✏️ Настройки промогруппы «{name}»",
|
||||
).format(name=group.name)
|
||||
|
||||
lines = [
|
||||
header,
|
||||
_format_discount_line(texts, group),
|
||||
_format_auto_assign_line(texts, group),
|
||||
]
|
||||
lines = [header]
|
||||
lines.extend(_format_discount_lines(texts, group))
|
||||
lines.append(_format_addon_discounts_line(texts, group))
|
||||
lines.append(_format_auto_assign_line(texts, group))
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, language)
|
||||
lines.extend(period_lines)
|
||||
@@ -318,6 +354,12 @@ def _build_edit_menu_content(
|
||||
callback_data=f"promo_group_edit_field_{group.id}_periods",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=_get_addon_discounts_button_text(texts, group),
|
||||
callback_data=f"promo_group_toggle_addons_{group.id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
@@ -362,8 +404,23 @@ async def _send_edit_menu_after_update(
|
||||
menu_text, keyboard = _build_edit_menu_content(texts, group, language)
|
||||
parts = [part for part in [success_message, menu_text] if part]
|
||||
|
||||
text = "\n\n".join(parts)
|
||||
|
||||
from_user = getattr(message, "from_user", None)
|
||||
|
||||
if getattr(from_user, "is_bot", False):
|
||||
try:
|
||||
await message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
|
||||
await message.answer(
|
||||
"\n\n".join(parts),
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -398,13 +455,15 @@ async def show_promo_groups_menu(
|
||||
)
|
||||
group_lines = [
|
||||
f"{'⭐' if group.is_default else '🎯'} <b>{group.name}</b>{default_suffix}",
|
||||
_format_discount_line(texts, group),
|
||||
_format_auto_assign_line(texts, group),
|
||||
]
|
||||
group_lines.extend(_format_discount_lines(texts, group))
|
||||
group_lines.append(_format_auto_assign_line(texts, group))
|
||||
group_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
]
|
||||
).format(count=member_count)
|
||||
)
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
|
||||
group_lines.extend(period_lines)
|
||||
@@ -472,14 +531,16 @@ async def show_promo_group_details(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE",
|
||||
"💳 <b>Промогруппа:</b> {name}",
|
||||
).format(name=group.name),
|
||||
_format_discount_line(texts, group),
|
||||
_format_auto_assign_line(texts, group),
|
||||
).format(name=group.name)
|
||||
]
|
||||
lines.extend(_format_discount_lines(texts, group))
|
||||
lines.append(_format_auto_assign_line(texts, group))
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
]
|
||||
).format(count=member_count)
|
||||
)
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
|
||||
lines.extend(period_lines)
|
||||
@@ -1192,6 +1253,45 @@ async def delete_promo_group_confirmed(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_promo_group_addon_discounts(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
new_value = not getattr(group, "apply_discounts_to_addons", True)
|
||||
|
||||
group = await update_promo_group(
|
||||
db,
|
||||
group,
|
||||
apply_discounts_to_addons=new_value,
|
||||
)
|
||||
|
||||
status_text = texts.t(
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED"
|
||||
if new_value
|
||||
else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED",
|
||||
"🧩 Скидки на докупку доп. услуг {status}.",
|
||||
).format(status="<b>включены</b>" if new_value else "<b>отключены</b>")
|
||||
|
||||
await _send_edit_menu_after_update(
|
||||
callback.message,
|
||||
texts,
|
||||
group,
|
||||
db_user.language,
|
||||
status_text,
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups")
|
||||
dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_"))
|
||||
@@ -1200,6 +1300,10 @@ def register_handlers(dp: Dispatcher):
|
||||
prompt_edit_promo_group_field,
|
||||
F.data.startswith("promo_group_edit_field_"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_promo_group_addon_discounts,
|
||||
F.data.startswith("promo_group_toggle_addons_"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_edit_promo_group,
|
||||
F.data.regexp(r"^promo_group_edit_\d+$"),
|
||||
|
||||
@@ -30,14 +30,22 @@ async def show_remnawave_menu(
|
||||
):
|
||||
remnawave_service = RemnaWaveService()
|
||||
connection_test = await remnawave_service.test_api_connection()
|
||||
|
||||
status_emoji = "✅" if connection_test["status"] == "connected" else "❌"
|
||||
|
||||
|
||||
status = connection_test.get("status")
|
||||
if status == "connected":
|
||||
status_emoji = "✅"
|
||||
elif status == "not_configured":
|
||||
status_emoji = "ℹ️"
|
||||
else:
|
||||
status_emoji = "❌"
|
||||
|
||||
api_url_display = settings.REMNAWAVE_API_URL or "—"
|
||||
|
||||
text = f"""
|
||||
🖥️ <b>Управление Remnawave</b>
|
||||
|
||||
📡 <b>Соединение:</b> {status_emoji} {connection_test["message"]}
|
||||
🌐 <b>URL:</b> <code>{settings.REMNAWAVE_API_URL}</code>
|
||||
📡 <b>Соединение:</b> {status_emoji} {connection_test.get("message", "Нет данных")}
|
||||
🌐 <b>URL:</b> <code>{api_url_display}</code>
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
@@ -194,7 +202,7 @@ async def show_traffic_stats(
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
try:
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
bandwidth_stats = await api.get_bandwidth_stats()
|
||||
|
||||
realtime_usage = await api.get_nodes_realtime_usage()
|
||||
|
||||
@@ -1988,7 +1988,7 @@ async def toggle_user_server(
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
active_internal_squads=current_squads,
|
||||
@@ -2332,7 +2332,7 @@ async def reset_user_devices(
|
||||
return
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
success = await api.reset_user_devices(user.remnawave_uuid)
|
||||
|
||||
if success:
|
||||
@@ -2372,7 +2372,7 @@ async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, adm
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
hwid_device_limit=devices,
|
||||
@@ -2414,7 +2414,7 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int,
|
||||
from app.external.remnawave_api import TrafficLimitStrategy
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
|
||||
@@ -2873,7 +2873,7 @@ async def admin_buy_subscription_execute(
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
if target_user.remnawave_uuid:
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
remnawave_user = await api.update_user(
|
||||
uuid=target_user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
@@ -2890,7 +2890,7 @@ async def admin_buy_subscription_execute(
|
||||
)
|
||||
else:
|
||||
username = f"user_{target_user.telegram_id}"
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
remnawave_user = await api.create_user(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
|
||||
@@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
|
||||
+30
-1
@@ -17,7 +17,7 @@ from app.database.crud.campaign import (
|
||||
get_campaign_by_start_parameter,
|
||||
get_campaign_by_id,
|
||||
)
|
||||
from app.database.models import UserStatus
|
||||
from app.database.models import UserStatus, SubscriptionStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
|
||||
)
|
||||
@@ -25,6 +25,7 @@ from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
|
||||
@@ -1141,6 +1142,34 @@ async def required_sub_channel_check(
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
if user and user.subscription:
|
||||
subscription = user.subscription
|
||||
if (
|
||||
subscription.is_trial
|
||||
and subscription.status == SubscriptionStatus.DISABLED.value
|
||||
):
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
logger.info(
|
||||
"✅ Триальная подписка пользователя %s восстановлена после подтверждения подписки на канал",
|
||||
user.telegram_id,
|
||||
)
|
||||
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
if user.remnawave_uuid:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
"❌ Ошибка обновления RemnaWave при восстановлении подписки пользователя %s: %s",
|
||||
user.telegram_id if user else query.from_user.id,
|
||||
api_error,
|
||||
)
|
||||
|
||||
await query.answer(
|
||||
texts.t("CHANNEL_SUBSCRIBE_THANKS", "✅ Спасибо за подписку"),
|
||||
show_alert=True,
|
||||
|
||||
+765
-130
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,9 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
|
||||
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🧩 Конфигурация бота", callback_data="admin_bot_config"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_MONITORING_SETTINGS", "⚙️ Настройки мониторинга"),
|
||||
|
||||
+226
-29
@@ -8,7 +8,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
from app.utils.pricing_utils import format_period_description, apply_percentage_discount
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -87,7 +91,7 @@ def get_main_menu_keyboard(
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
subscription_url = getattr(subscription, "subscription_url", None)
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
|
||||
def _fallback_connect_button() -> InlineKeyboardButton:
|
||||
return InlineKeyboardButton(
|
||||
@@ -96,11 +100,11 @@ def get_main_menu_keyboard(
|
||||
)
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
if subscription_url:
|
||||
if subscription_link:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
web_app=types.WebAppInfo(url=subscription_url)
|
||||
web_app=types.WebAppInfo(url=subscription_link)
|
||||
)
|
||||
])
|
||||
else:
|
||||
@@ -113,11 +117,21 @@ def get_main_menu_keyboard(
|
||||
)
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
if subscription_url:
|
||||
if subscription_link:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=subscription_url
|
||||
url=subscription_link
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([_fallback_connect_button()])
|
||||
elif connect_mode == "happ_cryptolink":
|
||||
if subscription_link:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="open_subscription_link",
|
||||
)
|
||||
])
|
||||
else:
|
||||
@@ -125,6 +139,10 @@ def get_main_menu_keyboard(
|
||||
else:
|
||||
keyboard.append([_fallback_connect_button()])
|
||||
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
keyboard.append(happ_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
@@ -227,6 +245,95 @@ def get_main_menu_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]:
|
||||
if not settings.is_happ_download_button_enabled():
|
||||
return None
|
||||
|
||||
return [
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("HAPP_DOWNLOAD_BUTTON", "⬇️ Скачать Happ"),
|
||||
callback_data="subscription_happ_download"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def get_happ_cryptolink_keyboard(
|
||||
subscription_link: str,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
redirect_link: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
final_redirect_link = redirect_link or get_happ_cryptolink_redirect_link(subscription_link)
|
||||
|
||||
buttons: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
if final_redirect_link:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=final_redirect_link,
|
||||
)
|
||||
])
|
||||
|
||||
buttons.extend([
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"),
|
||||
callback_data="happ_download_ios",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"),
|
||||
callback_data="happ_download_android",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"),
|
||||
callback_data="happ_download_macos",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"),
|
||||
callback_data="happ_download_windows",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_happ_download_platform_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), callback_data="happ_download_ios")],
|
||||
[InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), callback_data="happ_download_android")],
|
||||
[InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), callback_data="happ_download_macos")],
|
||||
[InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), callback_data="happ_download_windows")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="happ_download_close")],
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_happ_download_link_keyboard(language: str, link: str) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_OPEN_LINK", "🔗 Открыть ссылку"), url=link)],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="happ_download_back")],
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_back_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -323,14 +430,15 @@ def get_subscription_keyboard(
|
||||
keyboard = []
|
||||
|
||||
if has_subscription:
|
||||
if subscription and subscription.subscription_url:
|
||||
subscription_link = get_display_subscription_link(subscription) if subscription else None
|
||||
if subscription_link:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
web_app=types.WebAppInfo(url=subscription.subscription_url)
|
||||
web_app=types.WebAppInfo(url=subscription_link)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
@@ -347,12 +455,34 @@ def get_subscription_keyboard(
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)
|
||||
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)
|
||||
])
|
||||
elif connect_mode == "happ_cryptolink":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="open_subscription_link",
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")
|
||||
])
|
||||
elif settings.CONNECT_BUTTON_MODE == "miniapp_custom":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")
|
||||
])
|
||||
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
keyboard.append(happ_row)
|
||||
|
||||
if not is_trial:
|
||||
keyboard.append([
|
||||
@@ -993,7 +1123,11 @@ def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
|
||||
def get_add_traffic_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
subscription_end_date: datetime = None,
|
||||
discount_percent: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
from app.config import settings
|
||||
texts = get_texts(language)
|
||||
@@ -1025,8 +1159,13 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_
|
||||
for package in enabled_packages:
|
||||
gb = package['gb']
|
||||
price_per_month = package['price']
|
||||
total_price = price_per_month * months_multiplier
|
||||
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
price_per_month,
|
||||
discount_percent,
|
||||
)
|
||||
total_price = discounted_per_month * months_multiplier
|
||||
total_discount = discount_per_month * months_multiplier
|
||||
|
||||
if gb == 0:
|
||||
if language == "ru":
|
||||
text = f"♾️ Безлимитный трафик - {total_price//100} ₽{period_text}"
|
||||
@@ -1037,7 +1176,10 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_
|
||||
text = f"📊 +{gb} ГБ трафика - {total_price//100} ₽{period_text}"
|
||||
else:
|
||||
text = f"📊 +{gb} GB traffic - {total_price//100} ₽{period_text}"
|
||||
|
||||
|
||||
if discount_percent > 0 and total_discount > 0:
|
||||
text += f" (скидка {discount_percent}%: -{total_discount//100}₽)"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
|
||||
])
|
||||
@@ -1051,7 +1193,12 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
|
||||
def get_change_devices_keyboard(
|
||||
current_devices: int,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
subscription_end_date: datetime = None,
|
||||
discount_percent: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
from app.config import settings
|
||||
texts = get_texts(language)
|
||||
@@ -1088,8 +1235,17 @@ def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LA
|
||||
|
||||
if chargeable_devices > 0:
|
||||
price_per_month = chargeable_devices * device_price_per_month
|
||||
total_price = price_per_month * months_multiplier
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
price_per_month,
|
||||
discount_percent,
|
||||
)
|
||||
total_price = discounted_per_month * months_multiplier
|
||||
price_text = f" (+{total_price//100}₽{period_text})"
|
||||
if discount_percent > 0 and discount_per_month * months_multiplier > 0:
|
||||
price_text += (
|
||||
f" (скидка {discount_percent}%:"
|
||||
f" -{(discount_per_month * months_multiplier)//100}₽)"
|
||||
)
|
||||
action_text = ""
|
||||
else:
|
||||
price_text = " (бесплатно)"
|
||||
@@ -1166,7 +1322,8 @@ def get_manage_countries_keyboard(
|
||||
selected: List[str],
|
||||
current_subscription_countries: List[str],
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
subscription_end_date: datetime = None
|
||||
subscription_end_date: datetime = None,
|
||||
discount_percent: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
@@ -1181,10 +1338,18 @@ def get_manage_countries_keyboard(
|
||||
total_cost = 0
|
||||
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
|
||||
uuid = country['uuid']
|
||||
name = country['name']
|
||||
price_per_month = country['price_kopeks']
|
||||
|
||||
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
price_per_month,
|
||||
discount_percent,
|
||||
)
|
||||
|
||||
if uuid in current_subscription_countries:
|
||||
if uuid in selected:
|
||||
icon = "✅"
|
||||
@@ -1193,17 +1358,31 @@ def get_manage_countries_keyboard(
|
||||
else:
|
||||
if uuid in selected:
|
||||
icon = "➕"
|
||||
total_cost += price_per_month * months_multiplier
|
||||
total_cost += discounted_per_month * months_multiplier
|
||||
else:
|
||||
icon = "⚪"
|
||||
|
||||
|
||||
if uuid not in current_subscription_countries and uuid in selected:
|
||||
total_price = price_per_month * months_multiplier
|
||||
total_price = discounted_per_month * months_multiplier
|
||||
if months_multiplier > 1:
|
||||
price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)"
|
||||
logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽")
|
||||
price_text = (
|
||||
f" ({discounted_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)"
|
||||
)
|
||||
logger.info(
|
||||
"🔍 Сервер %s: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)",
|
||||
name,
|
||||
discounted_per_month / 100,
|
||||
months_multiplier,
|
||||
total_price / 100,
|
||||
(discount_per_month * months_multiplier) / 100,
|
||||
)
|
||||
else:
|
||||
price_text = f" ({total_price//100}₽)"
|
||||
if discount_percent > 0 and discount_per_month * months_multiplier > 0:
|
||||
price_text += (
|
||||
f" (скидка {discount_percent}%:"
|
||||
f" -{(discount_per_month * months_multiplier)//100}₽)"
|
||||
)
|
||||
display_name = f"{icon} {name}{price_text}"
|
||||
else:
|
||||
display_name = f"{icon} {name}"
|
||||
@@ -1288,9 +1467,18 @@ def get_connection_guide_keyboard(
|
||||
if app_buttons:
|
||||
keyboard.append(app_buttons)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url)
|
||||
])
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
copy_button = InlineKeyboardButton(
|
||||
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
|
||||
callback_data="open_subscription_link",
|
||||
)
|
||||
else:
|
||||
copy_button = InlineKeyboardButton(
|
||||
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
|
||||
url=subscription_url,
|
||||
)
|
||||
|
||||
keyboard.append([copy_button])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
@@ -1361,9 +1549,18 @@ def get_specific_app_keyboard(
|
||||
if app_buttons:
|
||||
keyboard.append(app_buttons)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url)
|
||||
])
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
copy_button = InlineKeyboardButton(
|
||||
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
|
||||
callback_data="open_subscription_link",
|
||||
)
|
||||
else:
|
||||
copy_button = InlineKeyboardButton(
|
||||
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
|
||||
url=subscription_url,
|
||||
)
|
||||
|
||||
keyboard.append([copy_button])
|
||||
|
||||
if 'additionalAfterAddSubscriptionStep' in app and 'buttons' in app['additionalAfterAddSubscriptionStep']:
|
||||
for button in app['additionalAfterAddSubscriptionStep']['buttons']:
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
"CONFIRM": "✅ Confirm",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Confirm change",
|
||||
"CONNECT_BUTTON": "🔗 Connect",
|
||||
"HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ",
|
||||
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Download Happ</b>\nChoose your device:",
|
||||
"HAPP_PLATFORM_IOS": "🍎 iOS",
|
||||
"HAPP_PLATFORM_ANDROID": "🤖 Android",
|
||||
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
|
||||
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
|
||||
"HAPP_PLATFORM_PC": "💻 PC",
|
||||
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:",
|
||||
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured",
|
||||
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link",
|
||||
"CONTINUE": "➡️ Continue",
|
||||
"CONTINUE_BUTTON": "➡️ Continue",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link",
|
||||
@@ -127,7 +137,16 @@
|
||||
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Promo group discounts:",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Servers: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Traffic: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Devices: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Add-on discounts: <b>enabled</b>",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Add-on discounts: <b>disabled</b>",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Add-on purchase discounts are <b>enabled</b>.",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Add-on purchase discounts are <b>disabled</b>.",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
|
||||
@@ -14,7 +14,16 @@
|
||||
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Скидки промогруппы:",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Серверы: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Трафик: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Устройства: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Скидки на доп. услуги: <b>включены</b>",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Скидки на доп. услуги: <b>отключены</b>",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Скидки на докупку доп. услуг <b>включены</b>.",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Скидки на докупку доп. услуг <b>отключены</b>.",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
@@ -99,6 +108,16 @@
|
||||
"CONFIRM": "✅ Подтвердить",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение",
|
||||
"CONNECT_BUTTON": "🔗 Подключиться",
|
||||
"HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ",
|
||||
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
|
||||
"HAPP_PLATFORM_IOS": "🍎 iOS",
|
||||
"HAPP_PLATFORM_ANDROID": "🤖 Android",
|
||||
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
|
||||
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
|
||||
"HAPP_PLATFORM_PC": "💻 ПК",
|
||||
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:",
|
||||
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена",
|
||||
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку",
|
||||
"CONTACT_SUPPORT": "💬 Написать в поддержку",
|
||||
"CONTINUE": "➡️ Продолжить",
|
||||
"CONTINUE_BUTTON": "✅ Продолжить",
|
||||
|
||||
@@ -23,7 +23,7 @@ async def _refresh_remnawave_description(
|
||||
) -> None:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
await api.update_user(uuid=remnawave_uuid, description=description)
|
||||
logger.info(
|
||||
f"✅ [Middleware] Описание пользователя {telegram_id} обновлено в RemnaWave"
|
||||
|
||||
@@ -7,10 +7,15 @@ from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -95,11 +100,14 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
elif member.status in self.BAD_MEMBER_STATUS:
|
||||
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
|
||||
|
||||
|
||||
if telegram_id:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
|
||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||
return
|
||||
|
||||
return
|
||||
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
else:
|
||||
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
||||
@@ -120,6 +128,53 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
||||
return await handler(event, data)
|
||||
|
||||
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
||||
async for db in get_db():
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user or not user.subscription:
|
||||
logger.debug(
|
||||
"⚠️ Пользователь %s отсутствует или не имеет подписки — пропускаем деактивацию",
|
||||
telegram_id,
|
||||
)
|
||||
break
|
||||
|
||||
subscription = user.subscription
|
||||
if (not subscription.is_trial or
|
||||
subscription.status != SubscriptionStatus.ACTIVE.value):
|
||||
logger.debug(
|
||||
"ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)",
|
||||
telegram_id,
|
||||
subscription.is_trial,
|
||||
subscription.status,
|
||||
)
|
||||
break
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info(
|
||||
"🚫 Триальная подписка пользователя %s отключена после отписки от канала",
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
if user.remnawave_uuid:
|
||||
service = SubscriptionService()
|
||||
try:
|
||||
await service.disable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
"❌ Не удалось отключить пользователя RemnaWave %s: %s",
|
||||
user.remnawave_uuid,
|
||||
api_error,
|
||||
)
|
||||
except Exception as db_error:
|
||||
logger.error(
|
||||
"❌ Ошибка деактивации подписки пользователя %s после отписки: %s",
|
||||
telegram_id,
|
||||
db_error,
|
||||
)
|
||||
finally:
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
|
||||
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional, Set
|
||||
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy import select, and_, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -181,6 +182,7 @@ class MonitoringService:
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
await self._check_trial_inactivity_notifications(db)
|
||||
await self._check_trial_channel_subscriptions(db)
|
||||
await self._check_expired_subscription_followups(db)
|
||||
await self._process_autopayments(db)
|
||||
await self._cleanup_inactive_users(db)
|
||||
@@ -279,6 +281,7 @@ class MonitoringService:
|
||||
)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
await db.commit()
|
||||
|
||||
status_text = "активным" if is_active else "истёкшим"
|
||||
@@ -455,6 +458,145 @@ class MonitoringService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}")
|
||||
|
||||
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
|
||||
if not settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
return
|
||||
|
||||
channel_id = settings.CHANNEL_SUB_ID
|
||||
if not channel_id:
|
||||
return
|
||||
|
||||
if not self.bot:
|
||||
logger.debug("⚠️ Пропускаем проверку подписки на канал — бот недоступен")
|
||||
return
|
||||
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.end_date > now,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
if not subscriptions:
|
||||
return
|
||||
|
||||
disabled_count = 0
|
||||
restored_count = 0
|
||||
|
||||
for subscription in subscriptions:
|
||||
user = subscription.user
|
||||
if not user or not user.telegram_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
member = await self.bot.get_chat_member(channel_id, user.telegram_id)
|
||||
member_status = member.status
|
||||
is_member = member_status in (
|
||||
ChatMemberStatus.MEMBER,
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR,
|
||||
)
|
||||
except TelegramForbiddenError as error:
|
||||
logger.error(
|
||||
"❌ Не удалось проверить подписку пользователя %s на канал %s: бот заблокирован (%s)",
|
||||
user.telegram_id,
|
||||
channel_id,
|
||||
error,
|
||||
)
|
||||
continue
|
||||
except TelegramBadRequest as error:
|
||||
logger.error(
|
||||
"❌ Ошибка Telegram при проверке подписки пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
continue
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"❌ Неожиданная ошибка при проверке подписки пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
continue
|
||||
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and not is_member:
|
||||
subscription = await deactivate_subscription(db, subscription)
|
||||
disabled_count += 1
|
||||
logger.info(
|
||||
"🚫 Триальная подписка пользователя %s (ID %s) отключена из-за отписки от канала",
|
||||
user.telegram_id,
|
||||
subscription.id,
|
||||
)
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
"❌ Не удалось отключить пользователя RemnaWave %s: %s",
|
||||
user.remnawave_uuid,
|
||||
api_error,
|
||||
)
|
||||
elif subscription.status == SubscriptionStatus.DISABLED.value and is_member:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
restored_count += 1
|
||||
|
||||
logger.info(
|
||||
"✅ Триальная подписка пользователя %s (ID %s) восстановлена после повторной подписки на канал",
|
||||
user.telegram_id,
|
||||
subscription.id,
|
||||
)
|
||||
|
||||
try:
|
||||
if user.remnawave_uuid:
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
else:
|
||||
await self.subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
"❌ Не удалось обновить RemnaWave пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
api_error,
|
||||
)
|
||||
|
||||
if disabled_count or restored_count:
|
||||
await self._log_monitoring_event(
|
||||
db,
|
||||
"trial_channel_subscription_check",
|
||||
(
|
||||
"Проверено {total} триальных подписок: отключено {disabled}, "
|
||||
"восстановлено {restored}"
|
||||
).format(
|
||||
total=len(subscriptions),
|
||||
disabled=disabled_count,
|
||||
restored=restored_count,
|
||||
),
|
||||
{
|
||||
"checked": len(subscriptions),
|
||||
"disabled": disabled_count,
|
||||
"restored": restored_count,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка проверки подписки на канал для триальных пользователей: {error}")
|
||||
|
||||
async def _check_expired_subscription_followups(self, db: AsyncSession):
|
||||
if not NotificationSettingsService.are_notifications_globally_enabled():
|
||||
return
|
||||
|
||||
@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
async def _get_best_group_for_spending(
|
||||
db: AsyncSession,
|
||||
total_spent_kopeks: int,
|
||||
min_threshold_kopeks: int = 0,
|
||||
) -> Optional[PromoGroup]:
|
||||
if total_spent_kopeks <= 0:
|
||||
return None
|
||||
@@ -28,7 +29,11 @@ async def _get_best_group_for_spending(
|
||||
|
||||
for group in groups:
|
||||
threshold = group.auto_assign_total_spent_kopeks or 0
|
||||
if threshold and total_spent_kopeks >= threshold:
|
||||
if (
|
||||
threshold
|
||||
and total_spent_kopeks >= threshold
|
||||
and threshold > min_threshold_kopeks
|
||||
):
|
||||
return group
|
||||
|
||||
return None
|
||||
@@ -47,12 +52,29 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
if total_spent <= 0:
|
||||
return None
|
||||
|
||||
target_group = await _get_best_group_for_spending(db, total_spent)
|
||||
previous_threshold = user.auto_promo_group_threshold_kopeks or 0
|
||||
|
||||
target_group = await _get_best_group_for_spending(
|
||||
db,
|
||||
total_spent,
|
||||
min_threshold_kopeks=previous_threshold,
|
||||
)
|
||||
if not target_group:
|
||||
return None
|
||||
|
||||
try:
|
||||
previous_group_id = user.promo_group_id
|
||||
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
|
||||
|
||||
if target_threshold <= previous_threshold:
|
||||
logger.debug(
|
||||
"Порог промогруппы '%s' (%s) не превышает ранее назначенный (%s) для пользователя %s",
|
||||
target_group.name,
|
||||
target_threshold,
|
||||
previous_threshold,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if user.auto_promo_group_assigned and target_group.id == previous_group_id:
|
||||
logger.debug(
|
||||
@@ -60,9 +82,15 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
)
|
||||
if target_threshold > previous_threshold:
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return target_group
|
||||
|
||||
user.auto_promo_group_assigned = True
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Dict, List, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete
|
||||
@@ -20,17 +21,56 @@ from app.database.models import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemnaWaveConfigurationError(Exception):
|
||||
"""Raised when RemnaWave API configuration is missing."""
|
||||
|
||||
|
||||
class RemnaWaveService:
|
||||
|
||||
|
||||
def __init__(self):
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
base_url = (auth_params.get("base_url") or "").strip()
|
||||
api_key = (auth_params.get("api_key") or "").strip()
|
||||
|
||||
self._config_error: Optional[str] = None
|
||||
|
||||
if not base_url:
|
||||
self._config_error = "REMNAWAVE_API_URL не настроен"
|
||||
elif not api_key:
|
||||
self._config_error = "REMNAWAVE_API_KEY не настроен"
|
||||
|
||||
self.api: Optional[RemnaWaveAPI]
|
||||
if self._config_error:
|
||||
self.api = None
|
||||
else:
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=auth_params.get("secret_key"),
|
||||
username=auth_params.get("username"),
|
||||
password=auth_params.get("password")
|
||||
)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self._config_error is None
|
||||
|
||||
@property
|
||||
def configuration_error(self) -> Optional[str]:
|
||||
return self._config_error
|
||||
|
||||
def _ensure_configured(self) -> None:
|
||||
if not self.is_configured or self.api is None:
|
||||
raise RemnaWaveConfigurationError(
|
||||
self._config_error or "RemnaWave API не настроен"
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_api_client(self):
|
||||
self._ensure_configured()
|
||||
assert self.api is not None
|
||||
async with self.api as api:
|
||||
yield api
|
||||
|
||||
def _parse_remnawave_date(self, date_str: str) -> datetime:
|
||||
if not date_str:
|
||||
@@ -62,7 +102,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_system_statistics(self) -> Dict[str, Any]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
logger.info("Получение системной статистики RemnaWave...")
|
||||
|
||||
try:
|
||||
@@ -255,7 +295,7 @@ class RemnaWaveService:
|
||||
async def get_all_nodes(self) -> List[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
result = []
|
||||
@@ -284,7 +324,7 @@ class RemnaWaveService:
|
||||
async def test_connection(self) -> bool:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
stats = await api.get_system_stats()
|
||||
logger.info("✅ Соединение с Remnawave API работает")
|
||||
return True
|
||||
@@ -295,7 +335,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
node = await api.get_node_by_uuid(node_uuid)
|
||||
|
||||
if not node:
|
||||
@@ -321,7 +361,7 @@ class RemnaWaveService:
|
||||
|
||||
async def manage_node(self, node_uuid: str, action: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
if action == "enable":
|
||||
await api.enable_node(node_uuid)
|
||||
elif action == "disable":
|
||||
@@ -340,7 +380,7 @@ class RemnaWaveService:
|
||||
|
||||
async def restart_all_nodes(self) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
result = await api.restart_all_nodes()
|
||||
|
||||
if result:
|
||||
@@ -354,7 +394,7 @@ class RemnaWaveService:
|
||||
|
||||
async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'inbounds': inbound_uuids
|
||||
@@ -368,7 +408,7 @@ class RemnaWaveService:
|
||||
async def get_all_squads(self) -> List[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
squads = await api.get_internal_squads()
|
||||
|
||||
result = []
|
||||
@@ -390,7 +430,7 @@ class RemnaWaveService:
|
||||
|
||||
async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
squad = await api.create_internal_squad(name, inbounds)
|
||||
|
||||
logger.info(f"✅ Создан новый сквад: {name}")
|
||||
@@ -402,7 +442,7 @@ class RemnaWaveService:
|
||||
|
||||
async def update_squad(self, uuid: str, name: str = None, inbounds: List[str] = None) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
await api.update_internal_squad(uuid, name, inbounds)
|
||||
|
||||
logger.info(f"✅ Обновлен сквад {uuid}")
|
||||
@@ -414,7 +454,7 @@ class RemnaWaveService:
|
||||
|
||||
async def delete_squad(self, uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
result = await api.delete_internal_squad(uuid)
|
||||
|
||||
if result:
|
||||
@@ -432,7 +472,7 @@ class RemnaWaveService:
|
||||
|
||||
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
panel_users = []
|
||||
start = 0
|
||||
size = 100
|
||||
@@ -458,6 +498,7 @@ class RemnaWaveService:
|
||||
'usedTrafficBytes': user_obj.used_traffic_bytes,
|
||||
'hwidDeviceLimit': user_obj.hwid_device_limit,
|
||||
'subscriptionUrl': user_obj.subscription_url,
|
||||
'subscriptionCryptoLink': user_obj.happ_crypto_link,
|
||||
'activeInternalSquads': user_obj.active_internal_squads
|
||||
}
|
||||
panel_users.append(user_dict)
|
||||
@@ -549,7 +590,7 @@ class RemnaWaveService:
|
||||
|
||||
if db_user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
devices_reset = await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
if devices_reset:
|
||||
logger.info(f"🔧 Сброшены HWID устройства для пользователя {telegram_id}")
|
||||
@@ -581,6 +622,7 @@ class RemnaWaveService:
|
||||
subscription.autopay_enabled = False
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.subscription_url = ""
|
||||
subscription.subscription_crypto_link = ""
|
||||
|
||||
db_user.remnawave_uuid = None
|
||||
|
||||
@@ -637,14 +679,18 @@ class RemnaWaveService:
|
||||
subscription_data = {
|
||||
'user_id': user.id,
|
||||
'status': status.value,
|
||||
'is_trial': False,
|
||||
'is_trial': False,
|
||||
'end_date': expire_at,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'traffic_used_gb': traffic_used_gb,
|
||||
'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1,
|
||||
'connected_squads': squad_uuids,
|
||||
'remnawave_short_uuid': panel_user.get('shortUuid'),
|
||||
'subscription_url': panel_user.get('subscriptionUrl', '')
|
||||
'subscription_url': panel_user.get('subscriptionUrl', ''),
|
||||
'subscription_crypto_link': (
|
||||
panel_user.get('subscriptionCryptoLink')
|
||||
or (panel_user.get('happ') or {}).get('cryptoLink', '')
|
||||
)
|
||||
}
|
||||
|
||||
subscription = await create_subscription(db, **subscription_data)
|
||||
@@ -667,7 +713,11 @@ class RemnaWaveService:
|
||||
device_limit=1,
|
||||
connected_squads=[],
|
||||
remnawave_short_uuid=panel_user.get('shortUuid'),
|
||||
subscription_url=panel_user.get('subscriptionUrl', '')
|
||||
subscription_url=panel_user.get('subscriptionUrl', ''),
|
||||
subscription_crypto_link=(
|
||||
panel_user.get('subscriptionCryptoLink')
|
||||
or (panel_user.get('happ') or {}).get('cryptoLink', '')
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}")
|
||||
except Exception as basic_error:
|
||||
@@ -733,6 +783,13 @@ class RemnaWaveService:
|
||||
panel_url = panel_user.get('subscriptionUrl', '')
|
||||
if not subscription.subscription_url or subscription.subscription_url != panel_url:
|
||||
subscription.subscription_url = panel_url
|
||||
|
||||
panel_crypto_link = (
|
||||
panel_user.get('subscriptionCryptoLink')
|
||||
or (panel_user.get('happ') or {}).get('cryptoLink', '')
|
||||
)
|
||||
if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link:
|
||||
subscription.subscription_crypto_link = panel_crypto_link
|
||||
|
||||
active_squads = panel_user.get('activeInternalSquads', [])
|
||||
squad_uuids = []
|
||||
@@ -763,7 +820,7 @@ class RemnaWaveService:
|
||||
|
||||
users = await get_users_list(db, offset=0, limit=10000)
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
for user in users:
|
||||
if not user.subscription:
|
||||
continue
|
||||
@@ -825,7 +882,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_user_traffic_stats(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
users = await api.get_user_by_telegram_id(telegram_id)
|
||||
|
||||
if not users:
|
||||
@@ -848,17 +905,23 @@ class RemnaWaveService:
|
||||
return None
|
||||
|
||||
async def test_api_connection(self) -> Dict[str, Any]:
|
||||
if not self.is_configured:
|
||||
return {
|
||||
"status": "not_configured",
|
||||
"message": self.configuration_error or "RemnaWave API не настроен",
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
}
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
system_stats = await api.get_system_stats()
|
||||
|
||||
|
||||
return {
|
||||
"status": "connected",
|
||||
"message": "Подключение успешно",
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
"system_info": system_stats
|
||||
}
|
||||
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -866,6 +929,12 @@ class RemnaWaveService:
|
||||
"status_code": e.status_code,
|
||||
"api_url": settings.REMNAWAVE_API_URL
|
||||
}
|
||||
except RemnaWaveConfigurationError as e:
|
||||
return {
|
||||
"status": "not_configured",
|
||||
"message": str(e),
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -875,7 +944,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
usage_data = await api.get_nodes_realtime_usage()
|
||||
return usage_data
|
||||
|
||||
@@ -885,7 +954,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_squad_details(self, squad_uuid: str) -> Optional[Dict]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
squad = await api.get_internal_squad_by_uuid(squad_uuid)
|
||||
if squad:
|
||||
return {
|
||||
@@ -902,7 +971,7 @@ class RemnaWaveService:
|
||||
|
||||
async def add_all_users_to_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
response = await api._make_request('POST', f'/api/internal-squads/{squad_uuid}/bulk-actions/add-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
@@ -911,7 +980,7 @@ class RemnaWaveService:
|
||||
|
||||
async def remove_all_users_from_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
response = await api._make_request('DELETE', f'/api/internal-squads/{squad_uuid}/bulk-actions/remove-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
@@ -920,7 +989,7 @@ class RemnaWaveService:
|
||||
|
||||
async def delete_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
response = await api.delete_internal_squad(squad_uuid)
|
||||
return response
|
||||
except Exception as e:
|
||||
@@ -929,7 +998,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_all_inbounds(self) -> List[Dict]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
response = await api._make_request('GET', '/api/config-profiles/inbounds')
|
||||
inbounds_data = response.get('response', {}).get('inbounds', [])
|
||||
|
||||
@@ -950,7 +1019,7 @@ class RemnaWaveService:
|
||||
|
||||
async def rename_squad(self, squad_uuid: str, new_name: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'name': new_name
|
||||
@@ -963,7 +1032,7 @@ class RemnaWaveService:
|
||||
|
||||
async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
squad = await api.create_internal_squad(name, inbound_uuids)
|
||||
return squad is not None
|
||||
except Exception as e:
|
||||
@@ -972,7 +1041,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
start_str = start_date.isoformat() + "Z"
|
||||
end_str = end_date.isoformat() + "Z"
|
||||
|
||||
@@ -1050,7 +1119,7 @@ class RemnaWaveService:
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
devices_reset = await api.reset_user_devices(user.remnawave_uuid)
|
||||
if devices_reset:
|
||||
logger.info(f"🔧 Сброшены HWID устройства для {user.telegram_id}")
|
||||
@@ -1113,6 +1182,7 @@ class RemnaWaveService:
|
||||
user.subscription.autopay_days_before = 3
|
||||
user.subscription.remnawave_short_uuid = None
|
||||
user.subscription.subscription_url = ""
|
||||
user.subscription.subscription_crypto_link = ""
|
||||
user.subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
@@ -1136,7 +1206,7 @@ class RemnaWaveService:
|
||||
|
||||
logger.info("🧹 Начинаем усиленную очистку неактуальных подписок...")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
panel_users_data = await api._make_request('GET', '/api/users')
|
||||
panel_users = panel_users_data['response']['users']
|
||||
|
||||
@@ -1200,7 +1270,7 @@ class RemnaWaveService:
|
||||
|
||||
logger.info("🔄 Начинаем синхронизацию статусов подписок...")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
panel_users_data = await api._make_request('GET', '/api/users')
|
||||
panel_users = panel_users_data['response']['users']
|
||||
|
||||
@@ -1290,11 +1360,12 @@ class RemnaWaveService:
|
||||
|
||||
if not subscription.remnawave_short_uuid and user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
rw_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
if rw_user:
|
||||
subscription.remnawave_short_uuid = rw_user.short_uuid
|
||||
subscription.subscription_url = rw_user.subscription_url
|
||||
subscription.subscription_crypto_link = rw_user.happ_crypto_link
|
||||
logger.info(f"🔧 Восстановлены данные Remnawave для {user.telegram_id}")
|
||||
issues_fixed += 1
|
||||
except Exception as rw_error:
|
||||
@@ -1538,7 +1609,7 @@ class RemnaWaveService:
|
||||
try:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
api_available = True
|
||||
|
||||
@@ -38,6 +38,26 @@ def _resolve_discount_percent(
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_addon_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
group = promo_group or (getattr(user, "promo_group", None) if user else None)
|
||||
|
||||
if group is not None and not getattr(group, "apply_discounts_to_addons", True):
|
||||
return 0
|
||||
|
||||
return _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
category,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
def get_traffic_reset_strategy():
|
||||
from app.config import settings
|
||||
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
|
||||
@@ -130,7 +150,8 @@ class SubscriptionService:
|
||||
)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
user.remnawave_uuid = updated_user.uuid
|
||||
|
||||
await db.commit()
|
||||
@@ -190,6 +211,7 @@ class SubscriptionService:
|
||||
)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
await db.commit()
|
||||
|
||||
status_text = "активным" if is_actually_active else "истёкшим"
|
||||
@@ -233,6 +255,7 @@ class SubscriptionService:
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}")
|
||||
@@ -534,6 +557,7 @@ class SubscriptionService:
|
||||
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.subscription_url = ""
|
||||
subscription.subscription_crypto_link = ""
|
||||
subscription.connected_squads = []
|
||||
|
||||
user.remnawave_uuid = None
|
||||
@@ -854,7 +878,7 @@ class SubscriptionService:
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
traffic_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
@@ -877,7 +901,7 @@ class SubscriptionService:
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
devices_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
@@ -904,7 +928,7 @@ class SubscriptionService:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available:
|
||||
server_price_per_month = server.price_kopeks
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
servers_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import Settings, settings
|
||||
from app.database.crud.system_setting import (
|
||||
delete_system_setting,
|
||||
upsert_system_setting,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _title_from_key(key: str) -> str:
|
||||
parts = key.split("_")
|
||||
if not parts:
|
||||
return key
|
||||
return " ".join(part.capitalize() for part in parts)
|
||||
|
||||
|
||||
def _truncate(value: str, max_len: int = 60) -> str:
|
||||
value = value.strip()
|
||||
if len(value) <= max_len:
|
||||
return value
|
||||
return value[: max_len - 1] + "…"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SettingDefinition:
|
||||
key: str
|
||||
category_key: str
|
||||
category_label: str
|
||||
python_type: Type[Any]
|
||||
type_label: str
|
||||
is_optional: bool
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return _title_from_key(self.key)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChoiceOption:
|
||||
value: Any
|
||||
label: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class BotConfigurationService:
|
||||
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
|
||||
|
||||
CATEGORY_TITLES: Dict[str, str] = {
|
||||
"SUPPORT": "Поддержка",
|
||||
"ADMIN_NOTIFICATIONS": "Уведомления администраторов",
|
||||
"ADMIN_REPORTS": "Автоотчеты",
|
||||
"CHANNEL": "Обязательная подписка на канал",
|
||||
"DATABASE": "База данных",
|
||||
"POSTGRES": "PostgreSQL",
|
||||
"SQLITE": "SQLite",
|
||||
"REDIS": "Redis",
|
||||
"REMNAWAVE": "Remnawave API",
|
||||
"TRIAL": "Триал подписка",
|
||||
"PAID_SUBSCRIPTION": "Платная подписка",
|
||||
"SUBSCRIPTIONS_GLOBAL": "Глобальные параметры подписок",
|
||||
"TRAFFIC": "Настройки трафика",
|
||||
"PERIODS": "Периоды подписки",
|
||||
"SUBSCRIPTION_PRICES": "Цены подписки",
|
||||
"TRAFFIC_PACKAGES": "Пакеты трафика",
|
||||
"DISCOUNTS": "Скидки промогрупп",
|
||||
"REFERRAL": "Реферальная система",
|
||||
"AUTOPAY": "Автопродление",
|
||||
"TELEGRAM": "Telegram Stars",
|
||||
"TRIBUTE": "Tribute",
|
||||
"YOOKASSA": "YooKassa",
|
||||
"CRYPTOBOT": "CryptoBot",
|
||||
"MULENPAY": "MulenPay",
|
||||
"PAL24": "PayPalych / Pal24",
|
||||
"PAYMENT": "Описания платежей",
|
||||
"INTERFACE_BRANDING": "Брендинг и логотип",
|
||||
"INTERFACE_SUBSCRIPTION": "Блок подписки",
|
||||
"CONNECT_BUTTON": "Кнопка «Подключиться»",
|
||||
"HAPP": "Happ CryptoLink",
|
||||
"SKIP": "Пропуски onboarding",
|
||||
"MONITORING": "Мониторинг",
|
||||
"NOTIFICATIONS": "Уведомления",
|
||||
"SERVER": "Статус серверов",
|
||||
"MAINTENANCE": "Технические работы",
|
||||
"LOCALIZATION": "Локализация",
|
||||
"ADDITIONAL": "Дополнительные настройки",
|
||||
"BACKUP": "Бекапы",
|
||||
"VERSION": "Проверка обновлений",
|
||||
"LOG": "Логирование",
|
||||
"WEBHOOK": "Вебхуки",
|
||||
"DEBUG": "Режим разработки",
|
||||
}
|
||||
|
||||
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
|
||||
"DATABASE_URL": "DATABASE",
|
||||
"DATABASE_MODE": "DATABASE",
|
||||
"LOCALES_PATH": "DATABASE",
|
||||
"DEFAULT_DEVICE_LIMIT": "PAID_SUBSCRIPTION",
|
||||
"DEFAULT_TRAFFIC_LIMIT_GB": "PAID_SUBSCRIPTION",
|
||||
"MAX_DEVICES_LIMIT": "PAID_SUBSCRIPTION",
|
||||
"PRICE_PER_DEVICE": "PAID_SUBSCRIPTION",
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": "SUBSCRIPTIONS_GLOBAL",
|
||||
"TRAFFIC_SELECTION_MODE": "TRAFFIC",
|
||||
"FIXED_TRAFFIC_LIMIT_GB": "TRAFFIC",
|
||||
"AVAILABLE_SUBSCRIPTION_PERIODS": "PERIODS",
|
||||
"AVAILABLE_RENEWAL_PERIODS": "PERIODS",
|
||||
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_14_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_30_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_60_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_TRAFFIC_5GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_10GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_25GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_50GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_100GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_250GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_500GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_1000GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_UNLIMITED": "TRAFFIC_PACKAGES",
|
||||
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "DISCOUNTS",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "DISCOUNTS",
|
||||
"REFERRED_USER_REWARD": "REFERRAL",
|
||||
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
|
||||
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
|
||||
"TRIAL_WARNING_HOURS": "NOTIFICATIONS",
|
||||
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
|
||||
"MONITORING_LOGS_RETENTION_DAYS": "MONITORING",
|
||||
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
|
||||
"LOGO_FILE": "INTERFACE_BRANDING",
|
||||
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
|
||||
"CONNECT_BUTTON_MODE": "CONNECT_BUTTON",
|
||||
"MINIAPP_CUSTOM_URL": "CONNECT_BUTTON",
|
||||
"APP_CONFIG_PATH": "ADDITIONAL",
|
||||
"ENABLE_DEEP_LINKS": "ADDITIONAL",
|
||||
"APP_CONFIG_CACHE_TTL": "ADDITIONAL",
|
||||
"DEFAULT_LANGUAGE": "LOCALIZATION",
|
||||
"AVAILABLE_LANGUAGES": "LOCALIZATION",
|
||||
"PAYMENT_SERVICE_NAME": "PAYMENT",
|
||||
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"INACTIVE_USER_DELETE_MONTHS": "MONITORING",
|
||||
}
|
||||
|
||||
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
|
||||
"SUPPORT_": "SUPPORT",
|
||||
"ADMIN_NOTIFICATIONS": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_REPORTS": "ADMIN_REPORTS",
|
||||
"CHANNEL_": "CHANNEL",
|
||||
"POSTGRES_": "POSTGRES",
|
||||
"SQLITE_": "SQLITE",
|
||||
"REDIS_": "REDIS",
|
||||
"REMNAWAVE": "REMNAWAVE",
|
||||
"TRIAL_": "TRIAL",
|
||||
"TRAFFIC_PACKAGES": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC": "TRAFFIC_PACKAGES",
|
||||
"TRAFFIC_": "TRAFFIC",
|
||||
"REFERRAL_": "REFERRAL",
|
||||
"AUTOPAY_": "AUTOPAY",
|
||||
"TELEGRAM_STARS": "TELEGRAM",
|
||||
"TRIBUTE_": "TRIBUTE",
|
||||
"YOOKASSA_": "YOOKASSA",
|
||||
"CRYPTOBOT_": "CRYPTOBOT",
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
"PAYMENT_": "PAYMENT",
|
||||
"CONNECT_BUTTON_HAPP": "HAPP",
|
||||
"HAPP_": "HAPP",
|
||||
"SKIP_": "SKIP",
|
||||
"MONITORING_": "MONITORING",
|
||||
"NOTIFICATION_": "NOTIFICATIONS",
|
||||
"SERVER_STATUS": "SERVER",
|
||||
"MAINTENANCE_": "MAINTENANCE",
|
||||
"VERSION_CHECK": "VERSION",
|
||||
"BACKUP_": "BACKUP",
|
||||
"WEBHOOK_": "WEBHOOK",
|
||||
"LOG_": "LOG",
|
||||
"DEBUG": "DEBUG",
|
||||
}
|
||||
|
||||
CHOICES: Dict[str, List[ChoiceOption]] = {
|
||||
"DATABASE_MODE": [
|
||||
ChoiceOption("auto", "🤖 Авто"),
|
||||
ChoiceOption("postgresql", "🐘 PostgreSQL"),
|
||||
ChoiceOption("sqlite", "💾 SQLite"),
|
||||
],
|
||||
"REMNAWAVE_AUTH_TYPE": [
|
||||
ChoiceOption("api_key", "🔑 API Key"),
|
||||
ChoiceOption("basic_auth", "🧾 Basic Auth"),
|
||||
],
|
||||
"REMNAWAVE_USER_DELETE_MODE": [
|
||||
ChoiceOption("delete", "🗑 Удалять"),
|
||||
ChoiceOption("disable", "🚫 Деактивировать"),
|
||||
],
|
||||
"TRAFFIC_SELECTION_MODE": [
|
||||
ChoiceOption("selectable", "📦 Выбор пакетов"),
|
||||
ChoiceOption("fixed", "📏 Фиксированный лимит"),
|
||||
],
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": [
|
||||
ChoiceOption("NO_RESET", "♾️ Без сброса"),
|
||||
ChoiceOption("DAY", "📅 Ежедневно"),
|
||||
ChoiceOption("WEEK", "🗓 Еженедельно"),
|
||||
ChoiceOption("MONTH", "📆 Ежемесячно"),
|
||||
],
|
||||
"SUPPORT_SYSTEM_MODE": [
|
||||
ChoiceOption("tickets", "🎫 Только тикеты"),
|
||||
ChoiceOption("contact", "💬 Только контакт"),
|
||||
ChoiceOption("both", "🔁 Оба варианта"),
|
||||
],
|
||||
"CONNECT_BUTTON_MODE": [
|
||||
ChoiceOption("guide", "📘 Гайд"),
|
||||
ChoiceOption("miniapp_subscription", "🧾 Mini App подписка"),
|
||||
ChoiceOption("miniapp_custom", "🧩 Mini App (ссылка)"),
|
||||
ChoiceOption("link", "🔗 Прямая ссылка"),
|
||||
ChoiceOption("happ_cryptolink", "🪙 Happ CryptoLink"),
|
||||
],
|
||||
"SERVER_STATUS_MODE": [
|
||||
ChoiceOption("disabled", "🚫 Отключено"),
|
||||
ChoiceOption("external_link", "🌐 Внешняя ссылка"),
|
||||
ChoiceOption("external_link_miniapp", "🧭 Mini App ссылка"),
|
||||
ChoiceOption("xray", "📊 XRay Checker"),
|
||||
],
|
||||
"YOOKASSA_PAYMENT_MODE": [
|
||||
ChoiceOption("full_payment", "💳 Полная оплата"),
|
||||
ChoiceOption("partial_payment", "🪙 Частичная оплата"),
|
||||
ChoiceOption("advance", "💼 Аванс"),
|
||||
ChoiceOption("full_prepayment", "📦 Полная предоплата"),
|
||||
ChoiceOption("partial_prepayment", "📦 Частичная предоплата"),
|
||||
ChoiceOption("credit", "💰 Кредит"),
|
||||
ChoiceOption("credit_payment", "💸 Погашение кредита"),
|
||||
],
|
||||
"YOOKASSA_PAYMENT_SUBJECT": [
|
||||
ChoiceOption("commodity", "📦 Товар"),
|
||||
ChoiceOption("excise", "🥃 Подакцизный товар"),
|
||||
ChoiceOption("job", "🛠 Работа"),
|
||||
ChoiceOption("service", "🧾 Услуга"),
|
||||
ChoiceOption("gambling_bet", "🎲 Ставка"),
|
||||
ChoiceOption("gambling_prize", "🏆 Выигрыш"),
|
||||
ChoiceOption("lottery", "🎫 Лотерея"),
|
||||
ChoiceOption("lottery_prize", "🎁 Приз лотереи"),
|
||||
ChoiceOption("intellectual_activity", "🧠 Интеллектуальная деятельность"),
|
||||
ChoiceOption("payment", "💱 Платеж"),
|
||||
ChoiceOption("agent_commission", "🤝 Комиссия агента"),
|
||||
ChoiceOption("composite", "🧩 Композитный"),
|
||||
ChoiceOption("another", "📄 Другое"),
|
||||
],
|
||||
"YOOKASSA_VAT_CODE": [
|
||||
ChoiceOption(1, "1 — НДС не облагается"),
|
||||
ChoiceOption(2, "2 — НДС 0%"),
|
||||
ChoiceOption(3, "3 — НДС 10%"),
|
||||
ChoiceOption(4, "4 — НДС 20%"),
|
||||
ChoiceOption(5, "5 — НДС 10/110"),
|
||||
ChoiceOption(6, "6 — НДС 20/120"),
|
||||
],
|
||||
"MULENPAY_LANGUAGE": [
|
||||
ChoiceOption("ru", "🇷🇺 Русский"),
|
||||
ChoiceOption("en", "🇬🇧 Английский"),
|
||||
],
|
||||
"LOG_LEVEL": [
|
||||
ChoiceOption("DEBUG", "🐞 Debug"),
|
||||
ChoiceOption("INFO", "ℹ️ Info"),
|
||||
ChoiceOption("WARNING", "⚠️ Warning"),
|
||||
ChoiceOption("ERROR", "❌ Error"),
|
||||
ChoiceOption("CRITICAL", "🔥 Critical"),
|
||||
],
|
||||
}
|
||||
|
||||
_definitions: Dict[str, SettingDefinition] = {}
|
||||
_original_values: Dict[str, Any] = settings.model_dump()
|
||||
_overrides_raw: Dict[str, Optional[str]] = {}
|
||||
_callback_tokens: Dict[str, str] = {}
|
||||
_token_to_key: Dict[str, str] = {}
|
||||
_choice_tokens: Dict[str, Dict[Any, str]] = {}
|
||||
_choice_token_lookup: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@classmethod
|
||||
def initialize_definitions(cls) -> None:
|
||||
if cls._definitions:
|
||||
return
|
||||
|
||||
for key, field in Settings.model_fields.items():
|
||||
if key in cls.EXCLUDED_KEYS:
|
||||
continue
|
||||
|
||||
annotation = field.annotation
|
||||
python_type, is_optional = cls._normalize_type(annotation)
|
||||
type_label = cls._type_to_label(python_type, is_optional)
|
||||
|
||||
category_key = cls._resolve_category_key(key)
|
||||
category_label = cls.CATEGORY_TITLES.get(
|
||||
category_key,
|
||||
category_key.capitalize() if category_key else "Прочее",
|
||||
)
|
||||
|
||||
cls._definitions[key] = SettingDefinition(
|
||||
key=key,
|
||||
category_key=category_key or "other",
|
||||
category_label=category_label,
|
||||
python_type=python_type,
|
||||
type_label=type_label,
|
||||
is_optional=is_optional,
|
||||
)
|
||||
|
||||
cls._register_callback_token(key)
|
||||
if key in cls.CHOICES:
|
||||
cls._ensure_choice_tokens(key)
|
||||
|
||||
|
||||
@classmethod
|
||||
def _resolve_category_key(cls, key: str) -> str:
|
||||
override = cls.CATEGORY_KEY_OVERRIDES.get(key)
|
||||
if override:
|
||||
return override
|
||||
|
||||
for prefix, category in sorted(
|
||||
cls.CATEGORY_PREFIX_OVERRIDES.items(), key=lambda item: len(item[0]), reverse=True
|
||||
):
|
||||
if key.startswith(prefix):
|
||||
return category
|
||||
|
||||
if "_" not in key:
|
||||
return key.upper()
|
||||
prefix = key.split("_", 1)[0]
|
||||
return prefix.upper()
|
||||
|
||||
@classmethod
|
||||
def _normalize_type(cls, annotation: Any) -> Tuple[Type[Any], bool]:
|
||||
if annotation is None:
|
||||
return str, True
|
||||
|
||||
origin = get_origin(annotation)
|
||||
if origin is Union:
|
||||
args = [arg for arg in get_args(annotation) if arg is not type(None)]
|
||||
if len(args) == 1:
|
||||
nested_type, nested_optional = cls._normalize_type(args[0])
|
||||
return nested_type, True
|
||||
return str, True
|
||||
|
||||
if annotation in {int, float, bool, str}:
|
||||
return annotation, False
|
||||
|
||||
if annotation in {Optional[int], Optional[float], Optional[bool], Optional[str]}:
|
||||
nested = get_args(annotation)[0]
|
||||
return nested, True
|
||||
|
||||
# Paths, lists, dicts и прочее будем хранить как строки
|
||||
return str, False
|
||||
|
||||
@classmethod
|
||||
def _type_to_label(cls, python_type: Type[Any], is_optional: bool) -> str:
|
||||
base = {
|
||||
bool: "bool",
|
||||
int: "int",
|
||||
float: "float",
|
||||
str: "str",
|
||||
}.get(python_type, "str")
|
||||
return f"optional[{base}]" if is_optional else base
|
||||
|
||||
@classmethod
|
||||
def get_categories(cls) -> List[Tuple[str, str, int]]:
|
||||
cls.initialize_definitions()
|
||||
categories: Dict[str, List[SettingDefinition]] = {}
|
||||
|
||||
for definition in cls._definitions.values():
|
||||
categories.setdefault(definition.category_key, []).append(definition)
|
||||
|
||||
result: List[Tuple[str, str, int]] = []
|
||||
for category_key, items in categories.items():
|
||||
label = items[0].category_label
|
||||
result.append((category_key, label, len(items)))
|
||||
|
||||
result.sort(key=lambda item: item[1])
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_settings_for_category(cls, category_key: str) -> List[SettingDefinition]:
|
||||
cls.initialize_definitions()
|
||||
filtered = [
|
||||
definition
|
||||
for definition in cls._definitions.values()
|
||||
if definition.category_key == category_key
|
||||
]
|
||||
filtered.sort(key=lambda definition: definition.key)
|
||||
return filtered
|
||||
|
||||
@classmethod
|
||||
def get_definition(cls, key: str) -> SettingDefinition:
|
||||
cls.initialize_definitions()
|
||||
return cls._definitions[key]
|
||||
|
||||
@classmethod
|
||||
def has_override(cls, key: str) -> bool:
|
||||
return key in cls._overrides_raw
|
||||
|
||||
@classmethod
|
||||
def get_current_value(cls, key: str) -> Any:
|
||||
return getattr(settings, key)
|
||||
|
||||
@classmethod
|
||||
def get_original_value(cls, key: str) -> Any:
|
||||
return cls._original_values.get(key)
|
||||
|
||||
@classmethod
|
||||
def format_value(cls, value: Any) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
if isinstance(value, bool):
|
||||
return "✅ Да" if value else "❌ Нет"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, (list, dict, tuple, set)):
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def format_value_for_list(cls, key: str) -> str:
|
||||
value = cls.get_current_value(key)
|
||||
formatted = cls.format_value(value)
|
||||
if formatted == "—":
|
||||
return formatted
|
||||
return _truncate(formatted)
|
||||
|
||||
@classmethod
|
||||
def get_choice_options(cls, key: str) -> List[ChoiceOption]:
|
||||
cls.initialize_definitions()
|
||||
return cls.CHOICES.get(key, [])
|
||||
|
||||
@classmethod
|
||||
def has_choices(cls, key: str) -> bool:
|
||||
return bool(cls.get_choice_options(key))
|
||||
|
||||
@classmethod
|
||||
def get_callback_token(cls, key: str) -> str:
|
||||
cls.initialize_definitions()
|
||||
return cls._callback_tokens[key]
|
||||
|
||||
@classmethod
|
||||
def resolve_callback_token(cls, token: str) -> str:
|
||||
cls.initialize_definitions()
|
||||
return cls._token_to_key[token]
|
||||
|
||||
@classmethod
|
||||
def get_choice_token(cls, key: str, value: Any) -> Optional[str]:
|
||||
cls.initialize_definitions()
|
||||
cls._ensure_choice_tokens(key)
|
||||
return cls._choice_tokens.get(key, {}).get(value)
|
||||
|
||||
@classmethod
|
||||
def resolve_choice_token(cls, key: str, token: str) -> Any:
|
||||
cls.initialize_definitions()
|
||||
cls._ensure_choice_tokens(key)
|
||||
return cls._choice_token_lookup.get(key, {})[token]
|
||||
|
||||
@classmethod
|
||||
def _register_callback_token(cls, key: str) -> None:
|
||||
if key in cls._callback_tokens:
|
||||
return
|
||||
|
||||
base = hashlib.blake2s(key.encode("utf-8"), digest_size=6).hexdigest()
|
||||
candidate = base
|
||||
counter = 1
|
||||
while candidate in cls._token_to_key and cls._token_to_key[candidate] != key:
|
||||
suffix = cls._encode_base36(counter)
|
||||
candidate = f"{base}{suffix}"[:16]
|
||||
counter += 1
|
||||
|
||||
cls._callback_tokens[key] = candidate
|
||||
cls._token_to_key[candidate] = key
|
||||
|
||||
@classmethod
|
||||
def _ensure_choice_tokens(cls, key: str) -> None:
|
||||
if key in cls._choice_tokens:
|
||||
return
|
||||
|
||||
options = cls.CHOICES.get(key, [])
|
||||
value_to_token: Dict[Any, str] = {}
|
||||
token_to_value: Dict[str, Any] = {}
|
||||
|
||||
for index, option in enumerate(options):
|
||||
token = cls._encode_base36(index)
|
||||
value_to_token[option.value] = token
|
||||
token_to_value[token] = option.value
|
||||
|
||||
cls._choice_tokens[key] = value_to_token
|
||||
cls._choice_token_lookup[key] = token_to_value
|
||||
|
||||
@staticmethod
|
||||
def _encode_base36(number: int) -> str:
|
||||
if number < 0:
|
||||
raise ValueError("number must be non-negative")
|
||||
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
if number == 0:
|
||||
return "0"
|
||||
result = []
|
||||
while number:
|
||||
number, rem = divmod(number, 36)
|
||||
result.append(alphabet[rem])
|
||||
return "".join(reversed(result))
|
||||
|
||||
@classmethod
|
||||
async def initialize(cls) -> None:
|
||||
cls.initialize_definitions()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(SystemSetting))
|
||||
rows = result.scalars().all()
|
||||
|
||||
overrides: Dict[str, Optional[str]] = {}
|
||||
for row in rows:
|
||||
if row.key in cls._definitions:
|
||||
overrides[row.key] = row.value
|
||||
|
||||
for key, raw_value in overrides.items():
|
||||
try:
|
||||
parsed_value = cls.deserialize_value(key, raw_value)
|
||||
except Exception as error:
|
||||
logger.error("Не удалось применить настройку %s: %s", key, error)
|
||||
continue
|
||||
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, parsed_value)
|
||||
|
||||
@classmethod
|
||||
async def reload(cls) -> None:
|
||||
cls._overrides_raw.clear()
|
||||
await cls.initialize()
|
||||
|
||||
@classmethod
|
||||
def deserialize_value(cls, key: str, raw_value: Optional[str]) -> Any:
|
||||
if raw_value is None:
|
||||
return None
|
||||
|
||||
definition = cls.get_definition(key)
|
||||
python_type = definition.python_type
|
||||
|
||||
if python_type is bool:
|
||||
value_lower = raw_value.strip().lower()
|
||||
if value_lower in {"1", "true", "on", "yes", "да"}:
|
||||
return True
|
||||
if value_lower in {"0", "false", "off", "no", "нет"}:
|
||||
return False
|
||||
raise ValueError(f"Неверное булево значение: {raw_value}")
|
||||
|
||||
if python_type is int:
|
||||
return int(raw_value)
|
||||
|
||||
if python_type is float:
|
||||
return float(raw_value)
|
||||
|
||||
return raw_value
|
||||
|
||||
@classmethod
|
||||
def serialize_value(cls, key: str, value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
definition = cls.get_definition(key)
|
||||
python_type = definition.python_type
|
||||
|
||||
if python_type is bool:
|
||||
return "true" if value else "false"
|
||||
if python_type in {int, float}:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def parse_user_value(cls, key: str, user_input: str) -> Any:
|
||||
definition = cls.get_definition(key)
|
||||
text = (user_input or "").strip()
|
||||
|
||||
if text.lower() in {"отмена", "cancel"}:
|
||||
raise ValueError("Ввод отменен пользователем")
|
||||
|
||||
if definition.is_optional and text.lower() in {"none", "null", "пусто", ""}:
|
||||
return None
|
||||
|
||||
python_type = definition.python_type
|
||||
|
||||
if python_type is bool:
|
||||
lowered = text.lower()
|
||||
if lowered in {"1", "true", "on", "yes", "да", "вкл", "enable", "enabled"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "off", "no", "нет", "выкл", "disable", "disabled"}:
|
||||
return False
|
||||
raise ValueError("Введите 'true' или 'false' (или 'да'/'нет')")
|
||||
|
||||
if python_type is int:
|
||||
parsed_value: Any = int(text)
|
||||
elif python_type is float:
|
||||
parsed_value = float(text.replace(",", "."))
|
||||
else:
|
||||
parsed_value = text
|
||||
|
||||
choices = cls.get_choice_options(key)
|
||||
if choices:
|
||||
allowed_values = {option.value for option in choices}
|
||||
if python_type is str:
|
||||
lowered_map = {
|
||||
str(option.value).lower(): option.value for option in choices
|
||||
}
|
||||
normalized = lowered_map.get(str(parsed_value).lower())
|
||||
if normalized is not None:
|
||||
parsed_value = normalized
|
||||
elif parsed_value not in allowed_values:
|
||||
readable = ", ".join(
|
||||
f"{option.label} ({cls.format_value(option.value)})" for option in choices
|
||||
)
|
||||
raise ValueError(f"Доступные значения: {readable}")
|
||||
elif parsed_value not in allowed_values:
|
||||
readable = ", ".join(
|
||||
f"{option.label} ({cls.format_value(option.value)})" for option in choices
|
||||
)
|
||||
raise ValueError(f"Доступные значения: {readable}")
|
||||
|
||||
return parsed_value
|
||||
|
||||
@classmethod
|
||||
async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None:
|
||||
raw_value = cls.serialize_value(key, value)
|
||||
await upsert_system_setting(db, key, raw_value)
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, value)
|
||||
|
||||
@classmethod
|
||||
async def reset_value(cls, db: AsyncSession, key: str) -> None:
|
||||
await delete_system_setting(db, key)
|
||||
cls._overrides_raw.pop(key, None)
|
||||
original = cls.get_original_value(key)
|
||||
cls._apply_to_settings(key, original)
|
||||
|
||||
@classmethod
|
||||
def _apply_to_settings(cls, key: str, value: Any) -> None:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except Exception as error:
|
||||
logger.error("Не удалось применить значение %s=%s: %s", key, value, error)
|
||||
|
||||
@classmethod
|
||||
def get_setting_summary(cls, key: str) -> Dict[str, Any]:
|
||||
definition = cls.get_definition(key)
|
||||
current = cls.get_current_value(key)
|
||||
original = cls.get_original_value(key)
|
||||
has_override = cls.has_override(key)
|
||||
|
||||
return {
|
||||
"key": key,
|
||||
"name": definition.display_name,
|
||||
"current": cls.format_value(current),
|
||||
"original": cls.format_value(original),
|
||||
"type": definition.type_label,
|
||||
"category_key": definition.category_key,
|
||||
"category_label": definition.category_label,
|
||||
"has_override": has_override,
|
||||
}
|
||||
|
||||
|
||||
bot_configuration_service = BotConfigurationService
|
||||
|
||||
@@ -360,7 +360,7 @@ class UserService:
|
||||
|
||||
if delete_mode == "delete":
|
||||
# Удаляем пользователя из панели Remnawave
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
delete_success = await api.delete_user(user.remnawave_uuid)
|
||||
if delete_success:
|
||||
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} удален из панели")
|
||||
|
||||
@@ -123,6 +123,10 @@ class AdminTicketStates(StatesGroup):
|
||||
class SupportSettingsStates(StatesGroup):
|
||||
waiting_for_desc = State()
|
||||
|
||||
|
||||
class BotConfigStates(StatesGroup):
|
||||
waiting_for_value = State()
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
confirming_autopay_toggle = State()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, delete
|
||||
from urllib.parse import quote
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.models import Subscription, User
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -93,5 +95,49 @@ async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int:
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок")
|
||||
|
||||
|
||||
return total_deleted
|
||||
|
||||
|
||||
def get_display_subscription_link(subscription: Optional[Subscription]) -> Optional[str]:
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
base_link = getattr(subscription, "subscription_url", None)
|
||||
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
crypto_link = getattr(subscription, "subscription_crypto_link", None)
|
||||
return crypto_link or base_link
|
||||
|
||||
return base_link
|
||||
|
||||
|
||||
def get_happ_cryptolink_redirect_link(subscription_link: Optional[str]) -> Optional[str]:
|
||||
if not subscription_link:
|
||||
return None
|
||||
|
||||
template = settings.get_happ_cryptolink_redirect_template()
|
||||
if not template:
|
||||
return None
|
||||
|
||||
encoded_link = quote(subscription_link, safe="")
|
||||
replacements = {
|
||||
"{subscription_link}": encoded_link,
|
||||
"{link}": encoded_link,
|
||||
"{subscription_link_raw}": subscription_link,
|
||||
"{link_raw}": subscription_link,
|
||||
}
|
||||
|
||||
replaced = False
|
||||
for placeholder, value in replacements.items():
|
||||
if placeholder in template:
|
||||
template = template.replace(placeholder, value)
|
||||
replaced = True
|
||||
|
||||
if replaced:
|
||||
return template
|
||||
|
||||
if template.endswith(("=", "?", "&")):
|
||||
return f"{template}{encoded_link}"
|
||||
|
||||
return f"{template}{encoded_link}"
|
||||
|
||||
+25
-1
@@ -20,6 +20,16 @@
|
||||
"CONFIRM": "✅ Confirm",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Confirm change",
|
||||
"CONNECT_BUTTON": "🔗 Connect",
|
||||
"HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ",
|
||||
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Download Happ</b>\nChoose your device:",
|
||||
"HAPP_PLATFORM_IOS": "🍎 iOS",
|
||||
"HAPP_PLATFORM_ANDROID": "🤖 Android",
|
||||
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
|
||||
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
|
||||
"HAPP_PLATFORM_PC": "💻 PC",
|
||||
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:",
|
||||
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured",
|
||||
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link",
|
||||
"CONTINUE": "➡️ Continue",
|
||||
"CONTINUE_BUTTON": "➡️ Continue",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link",
|
||||
@@ -140,7 +150,16 @@
|
||||
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Promo group discounts:",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Servers: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Traffic: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Devices: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Add-on discounts: <b>enabled</b>",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Add-on discounts: <b>disabled</b>",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Add-on purchase discounts are <b>enabled</b>.",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Add-on purchase discounts are <b>disabled</b>.",
|
||||
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
|
||||
@@ -385,6 +404,7 @@
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Your import link for the VPN app:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device",
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
|
||||
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.",
|
||||
@@ -396,6 +416,10 @@
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Connect via Happ</b>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Open link in Happ</a>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: <code>{subscription_link}</code>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Subscription link:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Step 1 - Install:</b>",
|
||||
|
||||
+25
-1
@@ -16,7 +16,16 @@
|
||||
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Скидки промогруппы:",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Серверы: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Трафик: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Устройства: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Скидки на доп. услуги: <b>включены</b>",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Скидки на доп. услуги: <b>отключены</b>",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Скидки на докупку доп. услуг <b>включены</b>.",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Скидки на докупку доп. услуг <b>отключены</b>.",
|
||||
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
|
||||
@@ -183,6 +192,16 @@
|
||||
"CONFIRM": "✅ Подтвердить",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение",
|
||||
"CONNECT_BUTTON": "🔗 Подключиться",
|
||||
"HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ",
|
||||
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
|
||||
"HAPP_PLATFORM_IOS": "🍎 iOS",
|
||||
"HAPP_PLATFORM_ANDROID": "🤖 Android",
|
||||
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
|
||||
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
|
||||
"HAPP_PLATFORM_PC": "💻 ПК",
|
||||
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:",
|
||||
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена",
|
||||
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку",
|
||||
"CONTACT_SUPPORT": "💬 Написать в поддержку",
|
||||
"CONTINUE": "➡️ Продолжить",
|
||||
"CONTINUE_BUTTON": "✅ Продолжить",
|
||||
@@ -385,6 +404,7 @@
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
|
||||
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
|
||||
@@ -396,6 +416,10 @@
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Подключение через Happ</b>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Открыть ссылку в Happ</a>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: <code>{subscription_link}</code>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Ссылка подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Шаг 1 - Установка:</b>",
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.database.universal_migration import run_universal_migration
|
||||
from app.services.backup_service import backup_service
|
||||
from app.services.reporting_service import reporting_service
|
||||
from app.localization.loader import ensure_locale_templates
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
@@ -85,6 +86,13 @@ async def main():
|
||||
else:
|
||||
logger.info("ℹ️ Миграция пропущена (SKIP_MIGRATION=true)")
|
||||
|
||||
logger.info("⚙️ Загрузка конфигурации из БД...")
|
||||
try:
|
||||
await bot_configuration_service.initialize()
|
||||
logger.info("✅ Конфигурация загружена")
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Не удалось загрузить конфигурацию: {error}")
|
||||
|
||||
logger.info("🤖 Настройка бота...")
|
||||
bot, dp = await setup_bot()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user