diff --git a/app-config.json b/app-config.json
index 4f5a919f..f53cea84 100644
--- a/app-config.json
+++ b/app-config.json
@@ -1,163 +1,233 @@
{
- "ios": [
- {
- "id": "happ",
- "name": "Happ",
- "isFeatured": true,
- "urlScheme": "happ://add/",
- "installationStep": {
- "buttons": [
- {
- "buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
- "buttonText": {
- "en": "App Store [EU]",
- "ru": "App Store [EU]"
- }
- },
- {
- "buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
- "buttonText": {
- "en": "App Store [RU]",
- "ru": "App Store [RU]"
+ "config": {},
+ "platforms": {
+ "ios": [
+ {
+ "id": "happ",
+ "name": "Happ",
+ "isFeatured": true,
+ "urlScheme": "happ://add/",
+ "installationStep": {
+ "buttons": [
+ {
+ "buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
+ "buttonText": {
+ "en": "App Store [EU]",
+ "fa": "App Store [EU]",
+ "ru": "App Store [EU]",
+ "zh": "App Store [EU]"
+ }
+ },
+ {
+ "buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
+ "buttonText": {
+ "en": "App Store [RU]",
+ "fa": "App Store [RU]",
+ "ru": "App Store [RU]",
+ "zh": "App Store [RU]"
+ }
}
+ ],
+ "description": {
+ "en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
+ "fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
+ "ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
+ "zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
+ }
+ },
+ "addSubscriptionStep": {
+ "description": {
+ "en": "Click the button below — the app will open and the subscription will be added automatically",
+ "fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
+ "ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
+ "zh": "点击下方按钮 — 应用将打开并自动添加订阅"
+ }
+ },
+ "connectAndUseStep": {
+ "description": {
+ "en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
+ "fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
+ "ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
+ "zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
- ],
- "description": {
- "en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
- "ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской"
- }
- },
- "addSubscriptionStep": {
- "description": {
- "en": "Click the button below — the app will open and the subscription will be added automatically",
- "ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
- }
- },
- "connectAndUseStep": {
- "description": {
- "en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
- "ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов."
}
}
- }
- ],
- "android": [
- {
- "id": "happ",
- "name": "Happ",
- "isFeatured": true,
- "urlScheme": "happ://add/",
- "installationStep": {
- "buttons": [
- {
- "buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
- "buttonText": {
- "en": "Google Play",
- "ru": "Google Play"
- }
- },
- {
- "buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
- "buttonText": {
- "en": "Download APK",
- "ru": "Скачать APK"
+ ],
+ "android": [
+ {
+ "id": "happ",
+ "name": "Happ",
+ "isFeatured": true,
+ "urlScheme": "happ://add/",
+ "installationStep": {
+ "buttons": [
+ {
+ "buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
+ "buttonText": {
+ "en": "Google Play",
+ "fa": "Google Play",
+ "ru": "Google Play",
+ "zh": "Google Play"
+ }
+ },
+ {
+ "buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
+ "buttonText": {
+ "en": "Download APK",
+ "fa": "دانلود APK",
+ "ru": "Скачать APK",
+ "zh": "下载 APK"
+ }
}
+ ],
+ "description": {
+ "en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
+ "fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
+ "ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
+ "zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
+ }
+ },
+ "addSubscriptionStep": {
+ "description": {
+ "en": "Click the button below to add subscription",
+ "fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
+ "ru": "Нажмите кнопку ниже, чтобы добавить подписку",
+ "zh": "点击下方按钮添加订阅"
+ }
+ },
+ "connectAndUseStep": {
+ "description": {
+ "en": "Open the app and connect to the server",
+ "fa": "برنامه را باز کنید و به سرور متصل شوید",
+ "ru": "Откройте приложение и подключитесь к серверу",
+ "zh": "打开应用并连接到服务器"
}
- ],
- "description": {
- "en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
- "ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает."
- }
- },
- "addSubscriptionStep": {
- "description": {
- "en": "Click the button below to add subscription",
- "ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
- }
- },
- "connectAndUseStep": {
- "description": {
- "en": "Open the app and connect to the server",
- "ru": "Откройте приложение и подключитесь к серверу"
}
}
- }
- ],
- "pc": [
- {
- "id": "happ",
- "name": "Happ",
- "isFeatured": false,
- "urlScheme": "happ://add/",
- "installationStep": {
- "buttons": [
- {
- "buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru",
- "buttonText": {
- "en": "Mac Os",
- "ru": "Mac Os"
- }
- },
- {
- "buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
- "buttonText": {
- "en": "Windows",
- "ru": "Windows"
+ ],
+ "macos": [
+ {
+ "id": "happ",
+ "name": "Happ",
+ "isFeatured": true,
+ "urlScheme": "happ://add/",
+ "installationStep": {
+ "buttons": [
+ {
+ "buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
+ "buttonText": {
+ "en": "App Store [EU]",
+ "fa": "App Store [EU]",
+ "ru": "App Store [EU]",
+ "zh": "App Store [EU]"
+ }
+ },
+ {
+ "buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
+ "buttonText": {
+ "en": "App Store [RU]",
+ "fa": "App Store [RU]",
+ "ru": "App Store [RU]",
+ "zh": "App Store [RU]"
+ }
}
+ ],
+ "description": {
+ "en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
+ "fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
+ "ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
+ "zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
+ }
+ },
+ "addSubscriptionStep": {
+ "description": {
+ "en": "Click the button below — the app will open and the subscription will be added automatically",
+ "fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
+ "ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
+ "zh": "点击下方按钮 — 应用将打开并自动添加订阅"
+ }
+ },
+ "connectAndUseStep": {
+ "description": {
+ "en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
+ "fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
+ "ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
+ "zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
- ],
- "description": {
- "en": "Choose the version for your device, click the button below and install the app.",
- "ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение"
- }
- },
- "addSubscriptionStep": {
- "description": {
- "en": "Click the button below to add subscription",
- "ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
- }
- },
- "connectAndUseStep": {
- "description": {
- "en": "You can select a server",
- "ru": "Выберете локацию, включить VPN"
}
}
- }
- ],
- "tv": [
- {
- "id": "vpn4tv",
- "name": "VPN4TV",
- "isFeatured": true,
- "urlScheme": "",
- "installationStep": {
- "buttons": [
- {
- "buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
- "buttonText": {
- "en": "Google Play",
- "ru": "Google Play"
+ ],
+ "windows": [
+ {
+ "id": "happ",
+ "name": "Happ",
+ "isFeatured": false,
+ "urlScheme": "happ://add/",
+ "installationStep": {
+ "buttons": [
+ {
+ "buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
+ "buttonText": {
+ "en": "Download",
+ "ru": "Скачать"
+ }
}
+ ],
+ "description": {
+ "en": "Download and install Happ.",
+ "ru": "Скачайте и установите Happ"
+ }
+ },
+ "addSubscriptionStep": {
+ "description": {
+ "en": "Click the button below to add subscription",
+ "ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
+ }
+ },
+ "connectAndUseStep": {
+ "description": {
+ "en": "You can select a server",
+ "ru": "Выберете локацию, включить VPN"
}
- ],
- "description": {
- "en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
- "ru": "Откройте страницу в Google Play и установите приложение"
- }
- },
- "addSubscriptionStep": {
- "description": {
- "en": "Click the button below to add subscription",
- "ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
- }
- },
- "connectAndUseStep": {
- "description": {
- "en": "Open the app and connect to the server",
- "ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
}
}
- }
- ]
-}
+ ],
+ "androidTV": [
+ {
+ "id": "vpn4tv",
+ "name": "VPN4TV",
+ "isFeatured": true,
+ "urlScheme": "",
+ "installationStep": {
+ "buttons": [
+ {
+ "buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
+ "buttonText": {
+ "en": "Google Play",
+ "ru": "Google Play"
+ }
+ }
+ ],
+ "description": {
+ "en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
+ "ru": "Откройте страницу в Google Play и установите приложение"
+ }
+ },
+ "addSubscriptionStep": {
+ "description": {
+ "en": "Click the button below to add subscription",
+ "ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
+ }
+ },
+ "connectAndUseStep": {
+ "description": {
+ "en": "Open the app and connect to the server",
+ "ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
+ }
+ }
+ }
+ ],
+ "linux": [],
+ "appleTV": []
+ }
+}
\ No newline at end of file
diff --git a/app/config.py b/app/config.py
index 286434d3..a2cbaf80 100644
--- a/app/config.py
+++ b/app/config.py
@@ -208,6 +208,8 @@ class Settings(BaseSettings):
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
PAL24_REQUEST_TIMEOUT: int = 30
+ PAL24_SBP_BUTTON_TEXT: Optional[str] = None
+ PAL24_CARD_BUTTON_TEXT: Optional[str] = None
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
@@ -399,6 +401,14 @@ class Settings(BaseSettings):
"password": self.REMNAWAVE_PASSWORD,
"auth_type": self.REMNAWAVE_AUTH_TYPE
}
+
+ def get_pal24_sbp_button_text(self, fallback: str) -> str:
+ value = (self.PAL24_SBP_BUTTON_TEXT or "").strip()
+ return value or fallback
+
+ def get_pal24_card_button_text(self, fallback: str) -> str:
+ value = (self.PAL24_CARD_BUTTON_TEXT or "").strip()
+ return value or fallback
def get_remnawave_user_delete_mode(self) -> str:
"""Возвращает режим удаления пользователей: 'delete' или 'disable'"""
diff --git a/app/database/crud/notification.py b/app/database/crud/notification.py
index d59b6190..ccbd8be7 100644
--- a/app/database/crud/notification.py
+++ b/app/database/crud/notification.py
@@ -50,3 +50,17 @@ async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
)
)
await db.commit()
+
+
+async def clear_notification_by_type(
+ db: AsyncSession,
+ subscription_id: int,
+ notification_type: str,
+) -> None:
+ await db.execute(
+ delete(SentNotification).where(
+ SentNotification.subscription_id == subscription_id,
+ SentNotification.notification_type == notification_type,
+ )
+ )
+ await db.commit()
diff --git a/app/database/crud/user.py b/app/database/crud/user.py
index a7fd93d4..98ee1f75 100644
--- a/app/database/crud/user.py
+++ b/app/database/crud/user.py
@@ -2,8 +2,8 @@ import logging
import secrets
import string
from datetime import datetime, timedelta
-from typing import Optional, List
-from sqlalchemy import select, and_, or_, func
+from typing import Optional, List, Dict
+from sqlalchemy import select, and_, or_, func, case, nullslast
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -273,7 +273,11 @@ async def get_users_list(
limit: int = 50,
search: Optional[str] = None,
status: Optional[UserStatus] = None,
- order_by_balance: bool = False
+ order_by_balance: bool = False,
+ order_by_traffic: bool = False,
+ order_by_last_activity: bool = False,
+ order_by_total_spent: bool = False,
+ order_by_purchase_count: bool = False
) -> List[User]:
query = select(User).options(selectinload(User.subscription))
@@ -293,10 +297,71 @@ async def get_users_list(
conditions.append(User.telegram_id == int(search))
query = query.where(or_(*conditions))
-
- # Сортировка по балансу в порядке убывания, если order_by_balance=True
- if order_by_balance:
- query = query.order_by(User.balance_kopeks.desc())
+
+ sort_flags = [
+ order_by_balance,
+ order_by_traffic,
+ order_by_last_activity,
+ order_by_total_spent,
+ order_by_purchase_count,
+ ]
+ if sum(int(flag) for flag in sort_flags) > 1:
+ logger.debug(
+ "Выбрано несколько сортировок пользователей — применяется приоритет: трафик > траты > покупки > баланс > активность"
+ )
+
+ transactions_stats = None
+ if order_by_total_spent or order_by_purchase_count:
+ from app.database.models import Transaction
+
+ transactions_stats = (
+ select(
+ Transaction.user_id.label("user_id"),
+ func.coalesce(
+ func.sum(
+ case(
+ (
+ Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
+ Transaction.amount_kopeks,
+ ),
+ else_=0,
+ )
+ ),
+ 0,
+ ).label("total_spent"),
+ func.coalesce(
+ func.sum(
+ case(
+ (
+ Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
+ 1,
+ ),
+ else_=0,
+ )
+ ),
+ 0,
+ ).label("purchase_count"),
+ )
+ .where(Transaction.is_completed.is_(True))
+ .group_by(Transaction.user_id)
+ .subquery()
+ )
+ query = query.outerjoin(transactions_stats, transactions_stats.c.user_id == User.id)
+
+ if order_by_traffic:
+ traffic_sort = func.coalesce(Subscription.traffic_used_gb, 0.0)
+ query = query.outerjoin(Subscription, Subscription.user_id == User.id)
+ query = query.order_by(traffic_sort.desc(), User.created_at.desc())
+ elif order_by_total_spent:
+ order_column = func.coalesce(transactions_stats.c.total_spent, 0)
+ query = query.order_by(order_column.desc(), User.created_at.desc())
+ elif order_by_purchase_count:
+ order_column = func.coalesce(transactions_stats.c.purchase_count, 0)
+ query = query.order_by(order_column.desc(), User.created_at.desc())
+ elif order_by_balance:
+ query = query.order_by(User.balance_kopeks.desc(), User.created_at.desc())
+ elif order_by_last_activity:
+ query = query.order_by(nullslast(User.last_activity.desc()), User.created_at.desc())
else:
query = query.order_by(User.created_at.desc())
@@ -334,6 +399,62 @@ async def get_users_count(
return result.scalar()
+async def get_users_spending_stats(
+ db: AsyncSession,
+ user_ids: List[int]
+) -> Dict[int, Dict[str, int]]:
+ if not user_ids:
+ return {}
+
+ from app.database.models import Transaction
+
+ stats_query = (
+ select(
+ Transaction.user_id,
+ func.coalesce(
+ func.sum(
+ case(
+ (
+ Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
+ Transaction.amount_kopeks,
+ ),
+ else_=0,
+ )
+ ),
+ 0,
+ ).label("total_spent"),
+ func.coalesce(
+ func.sum(
+ case(
+ (
+ Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
+ 1,
+ ),
+ else_=0,
+ )
+ ),
+ 0,
+ ).label("purchase_count"),
+ )
+ .where(
+ Transaction.user_id.in_(user_ids),
+ Transaction.is_completed.is_(True),
+ )
+ .group_by(Transaction.user_id)
+ )
+
+ result = await db.execute(stats_query)
+ rows = result.all()
+
+ return {
+ row.user_id: {
+ "total_spent": int(row.total_spent or 0),
+ "purchase_count": int(row.purchase_count or 0),
+ }
+ for row in rows
+ }
+
+
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
result = await db.execute(
select(User)
diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py
index 9ea90945..cb0d22de 100644
--- a/app/external/webhook_server.py
+++ b/app/external/webhook_server.py
@@ -1,8 +1,9 @@
+import base64
import hashlib
import hmac
import logging
import json
-from typing import Optional
+from typing import Optional, Iterable
from aiohttp import web
from aiogram import Bot
@@ -144,6 +145,14 @@ class WebhookServer:
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
+ @staticmethod
+ def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]:
+ for header_name in header_names:
+ value = request.headers.get(header_name)
+ if value:
+ return value.strip()
+ return None
+
@staticmethod
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
secret_key = settings.MULENPAY_SECRET_KEY
@@ -151,28 +160,78 @@ class WebhookServer:
logger.error("Mulen Pay secret key is not configured")
return False
- signature = request.headers.get('X-MulenPay-Signature')
+ signature = WebhookServer._extract_mulenpay_header(
+ request,
+ (
+ 'X-MulenPay-Signature',
+ 'X-Mulenpay-Signature',
+ 'X-MULENPAY-SIGNATURE',
+ 'X-MulenPay-Webhook-Signature',
+ 'X-Mulenpay-Webhook-Signature',
+ 'X-MULENPAY-WEBHOOK-SIGNATURE',
+ 'X-Signature',
+ 'Signature',
+ )
+ )
if signature:
- expected_signature = hmac.new(
+ normalized_signature = signature
+ if normalized_signature.lower().startswith('sha256='):
+ normalized_signature = normalized_signature.split('=', 1)[1].strip()
+
+ hmac_digest = hmac.new(
secret_key.encode('utf-8'),
raw_body,
hashlib.sha256,
- ).hexdigest()
+ ).digest()
+ expected_hex_signature = hmac_digest.hex()
+ expected_base64_signature = base64.b64encode(hmac_digest).decode('utf-8').strip()
+ expected_urlsafe_base64_signature = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip()
- if hmac.compare_digest(signature.strip().lower(), expected_signature.lower()):
+ normalized_signature_lower = normalized_signature.lower()
+ if hmac.compare_digest(normalized_signature_lower, expected_hex_signature.lower()):
+ return True
+
+ normalized_signature_no_padding = normalized_signature.rstrip('=')
+ if hmac.compare_digest(normalized_signature_no_padding, expected_base64_signature.rstrip('=')):
+ return True
+
+ if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
return True
logger.error("Неверная подпись Mulen Pay webhook")
return False
authorization_header = request.headers.get('Authorization')
- if authorization_header and authorization_header.startswith('Bearer '):
- token = authorization_header.split(' ', 1)[1].strip()
- if hmac.compare_digest(token, secret_key):
+ if authorization_header:
+ scheme, _, value = authorization_header.partition(' ')
+ scheme_lower = scheme.lower()
+ token = value.strip() if value else scheme.strip()
+
+ if scheme_lower in ('bearer', 'token'):
+ if hmac.compare_digest(token, secret_key):
+ return True
+
+ logger.error("Неверный %s токен Mulen Pay webhook", scheme)
+ return False
+
+ if not value and hmac.compare_digest(token, secret_key):
return True
- logger.error("Неверный Bearer токен Mulen Pay webhook")
- return False
+ fallback_token = WebhookServer._extract_mulenpay_header(
+ request,
+ (
+ 'X-MulenPay-Token',
+ 'X-Mulenpay-Token',
+ 'X-Webhook-Token',
+ )
+ )
+ if fallback_token and hmac.compare_digest(fallback_token, secret_key):
+ return True
+
+ logger.debug(
+ "Mulen Pay webhook headers received: %s",
+ {key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
+ )
logger.error("Отсутствует подпись Mulen Pay webhook")
return False
diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py
index 6e0fadfc..b129ea68 100644
--- a/app/handlers/admin/bot_configuration.py
+++ b/app/handlers/admin/bot_configuration.py
@@ -851,33 +851,79 @@ async def test_payment_provider(
language=language or "ru",
)
- if not payment_result or not payment_result.get("link_url") and not payment_result.get("link_page_url"):
+ if not payment_result:
await callback.answer("❌ Не удалось создать платеж PayPalych", show_alert=True)
await _refresh_markup()
return
- payment_url = payment_result.get("link_page_url") or payment_result.get("link_url")
+ sbp_url = (
+ payment_result.get("sbp_url")
+ or payment_result.get("transfer_url")
+ or payment_result.get("link_url")
+ )
+ card_url = payment_result.get("card_url")
+ fallback_url = payment_result.get("link_page_url") or payment_result.get("link_url")
+
+ if not (sbp_url or card_url or fallback_url):
+ await callback.answer("❌ Не удалось создать платеж PayPalych", show_alert=True)
+ await _refresh_markup()
+ return
+
+ if not sbp_url:
+ sbp_url = fallback_url
+
+ default_sbp_text = texts.t(
+ "PAL24_SBP_PAY_BUTTON",
+ "🏦 Оплатить через PayPalych (СБП)",
+ )
+ sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
+
+ default_card_text = texts.t(
+ "PAL24_CARD_PAY_BUTTON",
+ "💳 Оплатить банковской картой (PayPalych)",
+ )
+ card_button_text = settings.get_pal24_card_button_text(default_card_text)
+
+ pay_rows: list[list[types.InlineKeyboardButton]] = []
+ if sbp_url:
+ pay_rows.append([
+ types.InlineKeyboardButton(
+ text=sbp_button_text,
+ url=sbp_url,
+ )
+ ])
+
+ if card_url and card_url != sbp_url:
+ pay_rows.append([
+ types.InlineKeyboardButton(
+ text=card_button_text,
+ url=card_url,
+ )
+ ])
+
+ if not pay_rows and fallback_url:
+ pay_rows.append([
+ types.InlineKeyboardButton(
+ text=sbp_button_text,
+ url=fallback_url,
+ )
+ ])
+
message_text = (
"🧪 Тестовый платеж PayPalych\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"🆔 Bill ID: {payment_result['bill_id']}"
)
- reply_markup = types.InlineKeyboardMarkup(
- inline_keyboard=[
- [
- types.InlineKeyboardButton(
- text="🏦 Перейти к оплате (СБП)",
- url=payment_url,
- )
- ],
- [
- types.InlineKeyboardButton(
- text="📊 Проверить статус",
- callback_data=f"check_pal24_{payment_result['local_payment_id']}",
- )
- ],
- ]
- )
+ keyboard_rows = pay_rows + [
+ [
+ types.InlineKeyboardButton(
+ text="📊 Проверить статус",
+ callback_data=f"check_pal24_{payment_result['local_payment_id']}",
+ )
+ ],
+ ]
+
+ reply_markup = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer("✅ Ссылка на платеж PayPalych отправлена", show_alert=True)
await _refresh_markup()
diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py
index 3be7d267..c6f2bbe1 100644
--- a/app/handlers/admin/main.py
+++ b/app/handlers/admin/main.py
@@ -71,10 +71,10 @@ async def show_users_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
-
+
await callback.message.edit_text(
- "👥 **Управление пользователями и подписками**\n\n"
- "Выберите нужный раздел:",
+ texts.t("ADMIN_USERS_SUBMENU_TITLE", "👥 **Управление пользователями и подписками**\n\n") +
+ texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
reply_markup=get_admin_users_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -89,10 +89,10 @@ async def show_promo_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
-
+
await callback.message.edit_text(
- "💰 **Промокоды и статистика**\n\n"
- "Выберите нужный раздел:",
+ texts.t("ADMIN_PROMO_SUBMENU_TITLE", "💰 **Промокоды и статистика**\n\n") +
+ texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
reply_markup=get_admin_promo_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -107,10 +107,10 @@ async def show_communications_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
-
+
await callback.message.edit_text(
- "📨 **Коммуникации**\n\n"
- "Управление рассылками и текстами интерфейса:",
+ texts.t("ADMIN_COMMUNICATIONS_SUBMENU_TITLE", "📨 **Коммуникации**\n\n") +
+ texts.t("ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION", "Управление рассылками и текстами интерфейса:"),
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -132,11 +132,15 @@ async def show_support_submenu(
if is_moderator_only:
# Rebuild keyboard to include only tickets and back to main menu
kb = InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
- [InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
+ [InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
await callback.message.edit_text(
- "🛟 **Поддержка**\n\n" + ("Доступ к тикетам." if is_moderator_only else "Управление тикетами и настройками поддержки:"),
+ texts.t("ADMIN_SUPPORT_SUBMENU_TITLE", "🛟 **Поддержка**\n\n") + (
+ texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR", "Доступ к тикетам.")
+ if is_moderator_only
+ else texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION", "Управление тикетами и настройками поддержки:")
+ ),
reply_markup=kb,
parse_mode="Markdown"
)
@@ -149,12 +153,14 @@ async def show_moderator_panel(
db_user: User,
db: AsyncSession
):
+ texts = get_texts(db_user.language)
kb = InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
- [InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")]
+ [InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]
])
await callback.message.edit_text(
- "🧑⚖️ Модерация поддержки\n\nДоступ к тикетам поддержки.",
+ texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑⚖️ Модерация поддержки") + "\n\n" +
+ texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."),
parse_mode="HTML",
reply_markup=kb
)
@@ -168,6 +174,7 @@ async def show_support_audit(
db_user: User,
db: AsyncSession
):
+ texts = get_texts(db_user.language)
# pagination
page = 1
if callback.data.startswith("admin_support_audit_page_"):
@@ -185,18 +192,22 @@ async def show_support_audit(
offset = (page - 1) * per_page
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
- lines = ["🧾 Аудит модераторов", ""]
+ lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 Аудит модераторов"), ""]
if not logs:
- lines.append("Пока пусто")
+ lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто"))
else:
for log in logs:
- role = "Модератор" if getattr(log, 'is_moderator', False) else "Админ"
+ role = (
+ texts.t("ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR", "Модератор")
+ if getattr(log, 'is_moderator', False)
+ else texts.t("ADMIN_SUPPORT_AUDIT_ROLE_ADMIN", "Админ")
+ )
ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else ''
action_map = {
- 'close_ticket': 'Закрытие тикета',
- 'block_user_timed': 'Блокировка (время)',
- 'block_user_perm': 'Блокировка (навсегда)',
- 'unblock_user': 'Снятие блока',
+ 'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
+ 'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
+ 'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
+ 'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
}
action_text = action_map.get(log.action, log.action)
ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else ""
@@ -218,7 +229,7 @@ async def show_support_audit(
kb_rows = []
if nav_row:
kb_rows.append(nav_row)
- kb_rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_support")])
+ kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")])
kb = InlineKeyboardMarkup(inline_keyboard=kb_rows)
await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb)
@@ -233,10 +244,10 @@ async def show_settings_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
-
+
await callback.message.edit_text(
- "⚙️ **Настройки системы**\n\n"
- "Управление Remnawave, мониторингом и другими настройками:",
+ texts.t("ADMIN_SETTINGS_SUBMENU_TITLE", "⚙️ **Настройки системы**\n\n") +
+ texts.t("ADMIN_SETTINGS_SUBMENU_DESCRIPTION", "Управление Remnawave, мониторингом и другими настройками:"),
reply_markup=get_admin_settings_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -251,10 +262,10 @@ async def show_system_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
-
+
await callback.message.edit_text(
- "🛠️ **Системные функции**\n\n"
- "Отчеты, обновления, логи, резервные копии и системные операции:",
+ texts.t("ADMIN_SYSTEM_SUBMENU_TITLE", "🛠️ **Системные функции**\n\n") +
+ texts.t("ADMIN_SYSTEM_SUBMENU_DESCRIPTION", "Отчеты, обновления, логи, резервные копии и системные операции:"),
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py
index cc79292b..3f58fcd4 100644
--- a/app/handlers/admin/messages.py
+++ b/app/handlers/admin/messages.py
@@ -21,8 +21,8 @@ from app.keyboards.admin import (
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
- BROADCAST_BUTTONS, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
- BROADCAST_BUTTON_LABELS
+ BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
+ get_broadcast_button_config, get_broadcast_button_labels
)
from app.localization.texts import get_texts
from app.database.crud.user import get_users_list
@@ -31,10 +31,8 @@ from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
-BUTTON_CONFIG = BROADCAST_BUTTONS
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
-BUTTON_LABELS = BROADCAST_BUTTON_LABELS
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
@@ -45,16 +43,17 @@ def get_updated_message_buttons_selector_keyboard(selected_buttons: list, langua
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
-def create_broadcast_keyboard(selected_buttons: list) -> Optional[types.InlineKeyboardMarkup]:
+def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]:
selected_buttons = selected_buttons or []
keyboard: list[list[types.InlineKeyboardButton]] = []
+ button_config_map = get_broadcast_button_config(language)
for row in BUTTON_ROWS:
row_buttons: list[types.InlineKeyboardButton] = []
for button_key in row:
if button_key not in selected_buttons:
continue
- button_config = BUTTON_CONFIG[button_key]
+ button_config = button_config_map[button_key]
row_buttons.append(
types.InlineKeyboardButton(
text=button_config["text"],
@@ -628,7 +627,8 @@ async def confirm_button_selection(
media_info = f"\n🖼️ Медиафайл: {media_type_names.get(media_type, media_type)}"
ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row]
- selected_names = [BUTTON_LABELS[key] for key in ordered_keys if key in selected_buttons]
+ button_labels = get_broadcast_button_labels(db_user.language)
+ selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons]
if selected_names:
buttons_info = f"\n📘 Кнопки: {', '.join(selected_names)}"
else:
@@ -745,7 +745,7 @@ async def confirm_broadcast(
sent_count = 0
failed_count = 0
- broadcast_keyboard = create_broadcast_keyboard(selected_buttons)
+ broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
for user in users:
try:
diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py
index e707b2ac..528288b4 100644
--- a/app/handlers/admin/monitoring.py
+++ b/app/handlers/admin/monitoring.py
@@ -37,6 +37,9 @@ def _build_notification_settings_view(language: str):
trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True))
trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True))
+ trial_channel_status = _format_toggle(
+ config["trial_channel_unsubscribed"].get("enabled", True)
+ )
expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True))
second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True))
third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True))
@@ -45,6 +48,7 @@ def _build_notification_settings_view(language: str):
"🔔 Уведомления пользователям\n\n"
f"• 1 час после триала: {trial_1h_status}\n"
f"• 24 часа после триала: {trial_24h_status}\n"
+ f"• Отписка от канала: {trial_channel_status}\n"
f"• 1 день после истечения: {expired_1d_status}\n"
f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n"
f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}"
@@ -57,6 +61,8 @@ def _build_notification_settings_view(language: str):
[InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")],
[InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")],
[InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")],
+ [InlineKeyboardButton(text=f"{trial_channel_status} • Отписка от канала", callback_data="admin_mon_notify_toggle_trial_channel")],
+ [InlineKeyboardButton(text="🧪 Тест: отписка от канала", callback_data="admin_mon_notify_preview_trial_channel")],
[InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")],
[InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")],
[InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")],
@@ -153,6 +159,36 @@ def _build_notification_preview_message(language: str, notification_type: str):
],
]
)
+ elif notification_type == "trial_channel_unsubscribed":
+ template = texts.get(
+ "TRIAL_CHANNEL_UNSUBSCRIBED",
+ (
+ "🚫 Доступ приостановлен\n\n"
+ "Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
+ "Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
+ ),
+ )
+ check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
+ message = template.format(check_button=check_button)
+ buttons: list[list[InlineKeyboardButton]] = []
+ if settings.CHANNEL_LINK:
+ buttons.append(
+ [
+ InlineKeyboardButton(
+ text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
+ url=settings.CHANNEL_LINK,
+ )
+ ]
+ )
+ buttons.append(
+ [
+ InlineKeyboardButton(
+ text=check_button,
+ callback_data="sub_channel_check",
+ )
+ ]
+ )
+ keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
elif notification_type == "expired_1d":
template = texts.get(
"SUBSCRIPTION_EXPIRED_1D",
@@ -368,7 +404,8 @@ async def admin_monitoring_menu(callback: CallbackQuery):
🔧 Выберите действие:
"""
- keyboard = get_monitoring_keyboard()
+ language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
+ keyboard = get_monitoring_keyboard(language)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
@@ -461,6 +498,27 @@ async def preview_trial_24h_notification(callback: CallbackQuery):
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
+@router.callback_query(F.data == "admin_mon_notify_toggle_trial_channel")
+@admin_required
+async def toggle_trial_channel_notification(callback: CallbackQuery):
+ enabled = NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
+ NotificationSettingsService.set_trial_channel_unsubscribed_enabled(not enabled)
+ await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
+ await _render_notification_settings(callback)
+
+
+@router.callback_query(F.data == "admin_mon_notify_preview_trial_channel")
+@admin_required
+async def preview_trial_channel_notification(callback: CallbackQuery):
+ try:
+ language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
+ await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_channel_unsubscribed")
+ await callback.answer("✅ Пример отправлен")
+ except Exception as exc:
+ logger.error("Failed to send trial channel preview: %s", exc)
+ await callback.answer("❌ Не удалось отправить тест", show_alert=True)
+
+
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d")
@admin_required
async def toggle_expired_1d_notification(callback: CallbackQuery):
@@ -533,6 +591,7 @@ async def preview_all_notifications(callback: CallbackQuery):
for notification_type in [
"trial_inactive_1h",
"trial_inactive_24h",
+ "trial_channel_unsubscribed",
"expired_1d",
"expired_2d",
"expired_nd",
diff --git a/app/handlers/admin/support_settings.py b/app/handlers/admin/support_settings.py
index 5ab4d3af..ffd9c27f 100644
--- a/app/handlers/admin/support_settings.py
+++ b/app/handlers/admin/support_settings.py
@@ -29,33 +29,63 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
rows: list[list[types.InlineKeyboardButton]] = []
+ status_enabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED", "Включены")
+ status_disabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED", "Отключены")
+
+ def mode_button(label_key: str, default: str, active: bool) -> str:
+ prefix = "🔘" if active else "⚪"
+ return f"{prefix} {texts.t(label_key, default)}"
+
rows.append([
types.InlineKeyboardButton(
- text=("✅ Пункт 'Техподдержка' в меню" if menu_enabled else "🚫 Пункт 'Техподдержка' в меню"),
+ text=(
+ f"{'✅' if menu_enabled else '🚫'} "
+ f"{texts.t('ADMIN_SUPPORT_SETTINGS_MENU_LABEL', 'Пункт «Техподдержка» в меню')}"
+ ),
callback_data="admin_support_toggle_menu"
)
])
rows.append([
- types.InlineKeyboardButton(text=("🔘 Тикеты" if mode == "tickets" else "⚪ Тикеты"), callback_data="admin_support_mode_tickets"),
- types.InlineKeyboardButton(text=("🔘 Контакт" if mode == "contact" else "⚪ Контакт"), callback_data="admin_support_mode_contact"),
- types.InlineKeyboardButton(text=("🔘 Оба" if mode == "both" else "⚪ Оба"), callback_data="admin_support_mode_both"),
+ types.InlineKeyboardButton(
+ text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_TICKETS", "Тикеты", mode == "tickets"),
+ callback_data="admin_support_mode_tickets"
+ ),
+ types.InlineKeyboardButton(
+ text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_CONTACT", "Контакт", mode == "contact"),
+ callback_data="admin_support_mode_contact"
+ ),
+ types.InlineKeyboardButton(
+ text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_BOTH", "Оба", mode == "both"),
+ callback_data="admin_support_mode_both"
+ ),
])
rows.append([
- types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc")
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"),
+ callback_data="admin_support_edit_desc"
+ )
])
# Notifications block
rows.append([
types.InlineKeyboardButton(
- text=("🔔 Админ-уведомления: Включены" if admin_notif else "🔕 Админ-уведомления: Отключены"),
+ text=(
+ f"{'🔔' if admin_notif else '🔕'} "
+ f"{texts.t('ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS', 'Админ-уведомления')}: "
+ f"{status_enabled if admin_notif else status_disabled}"
+ ),
callback_data="admin_support_toggle_admin_notifications"
)
])
rows.append([
types.InlineKeyboardButton(
- text=("🔔 Пользовательские уведомления: Включены" if user_notif else "🔕 Пользовательские уведомления: Отключены"),
+ text=(
+ f"{'🔔' if user_notif else '🔕'} "
+ f"{texts.t('ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS', 'Пользовательские уведомления')}: "
+ f"{status_enabled if user_notif else status_disabled}"
+ ),
callback_data="admin_support_toggle_user_notifications"
)
])
@@ -63,13 +93,17 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
# SLA block
rows.append([
types.InlineKeyboardButton(
- text=("⏰ SLA: Включено" if sla_enabled else "⏹️ SLA: Отключено"),
+ text=(
+ f"{'⏰' if sla_enabled else '⏹️'} "
+ f"{texts.t('ADMIN_SUPPORT_SETTINGS_SLA_LABEL', 'SLA')}: "
+ f"{status_enabled if sla_enabled else status_disabled}"
+ ),
callback_data="admin_support_toggle_sla"
)
])
rows.append([
types.InlineKeyboardButton(
- text=f"⏳ Время SLA: {sla_minutes} мин",
+ text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes),
callback_data="admin_support_set_sla_minutes"
)
])
@@ -79,15 +113,18 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
mod_count = len(moderators)
rows.append([
types.InlineKeyboardButton(
- text=f"🧑⚖️ Модераторы: {mod_count}", callback_data="admin_support_list_moderators"
+ text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑⚖️ Модераторы: {count}").format(count=mod_count),
+ callback_data="admin_support_list_moderators"
)
])
rows.append([
types.InlineKeyboardButton(
- text="➕ Назначить модератора", callback_data="admin_support_add_moderator"
+ text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"),
+ callback_data="admin_support_add_moderator"
),
types.InlineKeyboardButton(
- text="➖ Удалить модератора", callback_data="admin_support_remove_moderator"
+ text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"),
+ callback_data="admin_support_remove_moderator"
)
])
@@ -108,8 +145,8 @@ async def show_support_settings(
texts = get_texts(db_user.language)
desc = SupportSettingsService.get_support_info_text(db_user.language)
await callback.message.edit_text(
- "🛟 Настройки поддержки\n\n" +
- "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:\n\n" +
+ texts.t("ADMIN_SUPPORT_SETTINGS_TITLE", "🛟 Настройки поддержки") + "\n\n" +
+ texts.t("ADMIN_SUPPORT_SETTINGS_DESCRIPTION", "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:") + "\n\n" +
desc,
reply_markup=_get_support_settings_keyboard(db_user.language),
parse_mode="HTML"
@@ -161,11 +198,15 @@ class SupportAdvancedStates(StatesGroup):
@admin_required
@error_handler
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ texts = get_texts(db_user.language)
await callback.message.edit_text(
- "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):",
+ texts.t(
+ "ADMIN_SUPPORT_SLA_SETUP_PROMPT",
+ "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):"
+ ),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
- inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
+ inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
@@ -175,63 +216,53 @@ async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db
@admin_required
@error_handler
async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
+ texts = get_texts(db_user.language)
text = (message.text or "").strip()
try:
minutes = int(text)
if minutes <= 0 or minutes > 1440:
raise ValueError()
except Exception:
- await message.answer("❌ Введите корректное число минут (1-1440)")
+ await message.answer(texts.t("ADMIN_SUPPORT_SLA_INVALID", "❌ Введите корректное число минут (1-1440)"))
return
SupportSettingsService.set_sla_minutes(minutes)
await state.clear()
markup = types.InlineKeyboardMarkup(
- inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
+ inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
- await message.answer("✅ Значение SLA сохранено", reply_markup=markup)
+ await message.answer(texts.t("ADMIN_SUPPORT_SLA_SAVED", "✅ Значение SLA сохранено"), reply_markup=markup)
@admin_required
@error_handler
async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ texts = get_texts(db_user.language)
await callback.message.edit_text(
- "🧑⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)",
+ texts.t(
+ "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT",
+ "🧑⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)"
+ ),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
- inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
+ inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
await callback.answer()
-@admin_required
-@error_handler
-async def handle_add_moderator(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
- text = (message.text or "").strip()
- try:
- tid = int(text)
- except Exception:
- await message.answer("❌ Введите корректный Telegram ID (число)")
- return
- if SupportSettingsService.add_moderator(tid):
- markup = types.InlineKeyboardMarkup(
- inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
- )
- await message.answer(f"✅ Пользователь {tid} назначен модератором", reply_markup=markup)
- else:
- await message.answer("❌ Не удалось сохранить")
- await state.clear()
-
-
@admin_required
@error_handler
async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ texts = get_texts(db_user.language)
await callback.message.edit_text(
- "🧑⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)",
+ texts.t(
+ "ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT",
+ "🧑⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)"
+ ),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
- inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
+ inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
@@ -243,24 +274,32 @@ async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, d
@admin_required
@error_handler
async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
+ texts = get_texts(db_user.language)
data = await state.get_data()
action = data.get("action", "add")
text = (message.text or "").strip()
try:
tid = int(text)
except Exception:
- await message.answer("❌ Введите корректный Telegram ID (число)")
+ await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)"))
return
- ok = False
if action == "remove_moderator":
ok = SupportSettingsService.remove_moderator(tid)
- msg = "✅ Модератор удалён" if ok else "❌ Не удалось удалить"
+ msg = (
+ texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS", "✅ Модератор {tid} удалён").format(tid=tid)
+ if ok
+ else texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL", "❌ Не удалось удалить модератора")
+ )
else:
ok = SupportSettingsService.add_moderator(tid)
- msg = "✅ Пользователь назначен модератором" if ok else "❌ Не удалось назначить"
+ msg = (
+ texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS", "✅ Пользователь {tid} назначен модератором").format(tid=tid)
+ if ok
+ else texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_FAIL", "❌ Не удалось назначить модератора")
+ )
await state.clear()
markup = types.InlineKeyboardMarkup(
- inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
+ inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer(msg, reply_markup=markup)
@@ -268,13 +307,17 @@ async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSe
@admin_required
@error_handler
async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
+ texts = get_texts(db_user.language)
moderators = SupportSettingsService.get_moderators()
if not moderators:
- await callback.answer("Список пуст", show_alert=True)
+ await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True)
return
- text = "🧑⚖️ Модераторы\n\n" + "\n".join([f"• {tid}" for tid in moderators])
+ text = (
+ texts.t("ADMIN_SUPPORT_MODERATORS_TITLE", "🧑⚖️ Модераторы") +
+ "\n\n" + "\n".join([f"• {tid}" for tid in moderators])
+ )
markup = types.InlineKeyboardMarkup(
- inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
+ inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
await callback.answer()
@@ -311,7 +354,10 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
kb_rows: list[list[types.InlineKeyboardButton]] = []
kb_rows.append([
- types.InlineKeyboardButton(text="📨 Прислать текст", callback_data="admin_support_send_desc")
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"),
+ callback_data="admin_support_send_desc"
+ )
])
# Подготовим блок контакта (отдельным инлайном)
from app.config import settings
@@ -321,19 +367,19 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
])
text_parts = [
- "📝 Редактирование описания поддержки",
+ texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE", "📝 Редактирование описания поддержки"),
"",
- "Текущее описание:",
+ texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT", "Текущее описание:"),
"",
f"{html.escape(current_desc_plain)}",
]
if support_contact_display:
text_parts += [
"",
- "Контакт для режима \u00abКонтакт\u00bb",
+ texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE", "Контакт для режима «Контакт»"),
f"{html.escape(support_contact_display)}",
"",
- "Добавьте в описание при необходимости.",
+ texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT", "Добавьте в описание при необходимости."),
]
await callback.message.edit_text(
"\n".join(text_parts),
@@ -347,24 +393,26 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
@admin_required
@error_handler
async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
+ texts = get_texts(db_user.language)
new_text = message.html_text or message.text
SupportSettingsService.set_support_info_text(db_user.language, new_text)
await state.clear()
markup = types.InlineKeyboardMarkup(
- inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
+ inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
- await message.answer("✅ Описание обновлено.", reply_markup=markup)
+ await message.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_UPDATED", "✅ Описание обновлено."), reply_markup=markup)
@admin_required
@error_handler
async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# send plain text for easy copying
+ texts = get_texts(db_user.language)
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
# attach delete button to the sent message
markup = types.InlineKeyboardMarkup(
- inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
+ inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
if len(current_desc_plain) <= 4000:
await callback.message.answer(current_desc_plain, reply_markup=markup)
@@ -376,7 +424,7 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async
is_last = (chunk + 4000) >= len(current_desc_plain)
await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None))
chunk += 4000
- await callback.answer("Текст отправлен ниже")
+ await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже"))
@error_handler
@@ -386,15 +434,15 @@ async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db:
may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id))
except Exception:
may_delete = False
+ texts = get_texts(db_user.language if db_user else 'ru')
if not may_delete:
- texts = get_texts(db_user.language if db_user else 'ru')
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
try:
await callback.message.delete()
finally:
with contextlib.suppress(Exception):
- await callback.answer("Сообщение удалено")
+ await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено"))
def register_handlers(dp: Dispatcher):
diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index 29d2e85c..1cb146f7 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -80,7 +80,7 @@ async def show_users_filters(
state: FSMContext
):
- text = "⚙️ Фильтры пользователей\n\nВыберите фильтр для отображения пользователей:"
+ text = ("⚙️ Фильтры пользователей\n\nВыберите фильтр для отображения пользователей:\n")
await callback.message.edit_text(
text,
@@ -142,7 +142,7 @@ async def show_users_list(
if user.balance_kopeks > 0:
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
- button_text += f" | 📅 {format_time_ago(user.created_at)}"
+ button_text += f" | 📅 {format_time_ago(user.created_at, db_user.language)}"
if len(button_text) > 60:
short_name = user.full_name
@@ -288,6 +288,464 @@ async def show_users_list_by_balance(
await callback.answer()
+@admin_required
+@error_handler
+async def show_users_list_by_traffic(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+ page: int = 1
+):
+
+ await state.set_state(AdminStates.viewing_user_from_traffic_list)
+
+ user_service = UserService()
+ users_data = await user_service.get_users_page(
+ db, page=page, limit=10, order_by_traffic=True
+ )
+
+ if not users_data["users"]:
+ await callback.message.edit_text(
+ "📶 Пользователи с трафиком не найдены",
+ reply_markup=get_admin_users_keyboard(db_user.language)
+ )
+ await callback.answer()
+ return
+
+ text = f"👥 Список пользователей по использованному трафику (стр. {page}/{users_data['total_pages']})\n\n"
+ text += "Нажмите на пользователя для управления:"
+
+ keyboard = []
+
+ for user in users_data["users"]:
+ if user.status == UserStatus.ACTIVE.value:
+ status_emoji = "✅"
+ elif user.status == UserStatus.BLOCKED.value:
+ status_emoji = "🚫"
+ else:
+ status_emoji = "🗑️"
+
+ if user.subscription:
+ sub = user.subscription
+ if sub.is_trial:
+ subscription_emoji = "🎁"
+ elif sub.is_active:
+ subscription_emoji = "💎"
+ else:
+ subscription_emoji = "⏰"
+ used = sub.traffic_used_gb or 0.0
+ if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
+ limit_display = f"{sub.traffic_limit_gb}"
+ else:
+ limit_display = "♾️"
+ traffic_display = f"{used:.1f}/{limit_display} ГБ"
+ else:
+ subscription_emoji = "❌"
+ traffic_display = "нет подписки"
+
+ button_text = f"{status_emoji} {subscription_emoji} {user.full_name}"
+ button_text += f" | 📶 {traffic_display}"
+
+ if user.balance_kopeks > 0:
+ button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
+
+ if len(button_text) > 60:
+ short_name = user.full_name
+ if len(short_name) > 20:
+ short_name = short_name[:17] + "..."
+ button_text = f"{status_emoji} {subscription_emoji} {short_name}"
+ button_text += f" | 📶 {traffic_display}"
+
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text=button_text,
+ callback_data=f"admin_user_manage_{user.id}"
+ )
+ ])
+
+ if users_data["total_pages"] > 1:
+ pagination_row = get_admin_pagination_keyboard(
+ users_data["current_page"],
+ users_data["total_pages"],
+ "admin_users_traffic_list",
+ "admin_users",
+ db_user.language
+ ).inline_keyboard[0]
+ keyboard.append(pagination_row)
+
+ keyboard.extend([
+ [
+ types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
+ types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
+ ],
+ [
+ types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
+ ]
+ ])
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def show_users_list_by_last_activity(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+ page: int = 1
+):
+
+ await state.set_state(AdminStates.viewing_user_from_last_activity_list)
+
+ user_service = UserService()
+ users_data = await user_service.get_users_page(
+ db,
+ page=page,
+ limit=10,
+ order_by_last_activity=True,
+ )
+
+ if not users_data["users"]:
+ await callback.message.edit_text(
+ "🕒 Пользователи с активностью не найдены",
+ reply_markup=get_admin_users_keyboard(db_user.language)
+ )
+ await callback.answer()
+ return
+
+ text = f"👥 Пользователи по активности (стр. {page}/{users_data['total_pages']})\n\n"
+ text += "Нажмите на пользователя для управления:"
+
+ keyboard = []
+
+ for user in users_data["users"]:
+ if user.status == UserStatus.ACTIVE.value:
+ status_emoji = "✅"
+ elif user.status == UserStatus.BLOCKED.value:
+ status_emoji = "🚫"
+ else:
+ status_emoji = "🗑️"
+
+ activity_display = (
+ format_time_ago(user.last_activity, db_user.language)
+ if user.last_activity
+ else "неизвестно"
+ )
+
+ subscription_emoji = "❌"
+ if user.subscription:
+ if user.subscription.is_trial:
+ subscription_emoji = "🎁"
+ elif user.subscription.is_active:
+ subscription_emoji = "💎"
+ else:
+ subscription_emoji = "⏰"
+
+ button_text = f"{status_emoji} {subscription_emoji} {user.full_name}"
+ button_text += f" | 🕒 {activity_display}"
+
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text=button_text,
+ callback_data=f"admin_user_manage_{user.id}"
+ )
+ ])
+
+ if users_data["total_pages"] > 1:
+ pagination_row = get_admin_pagination_keyboard(
+ users_data["current_page"],
+ users_data["total_pages"],
+ "admin_users_activity_list",
+ "admin_users",
+ db_user.language
+ ).inline_keyboard[0]
+ keyboard.append(pagination_row)
+
+ keyboard.extend([
+ [
+ types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
+ types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
+ ],
+ [
+ types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
+ ]
+ ])
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def show_users_list_by_spending(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+ page: int = 1
+):
+
+ await state.set_state(AdminStates.viewing_user_from_spending_list)
+
+ user_service = UserService()
+ users_data = await user_service.get_users_page(
+ db,
+ page=page,
+ limit=10,
+ order_by_total_spent=True,
+ )
+
+ users = users_data["users"]
+ if not users:
+ await callback.message.edit_text(
+ "💳 Пользователи с тратами не найдены",
+ reply_markup=get_admin_users_keyboard(db_user.language)
+ )
+ await callback.answer()
+ return
+
+ spending_map = await user_service.get_user_spending_stats_map(
+ db,
+ [user.id for user in users],
+ )
+
+ text = f"👥 Пользователи по сумме трат (стр. {page}/{users_data['total_pages']})\n\n"
+ text += "Нажмите на пользователя для управления:"
+
+ keyboard = []
+
+ for user in users:
+ stats = spending_map.get(
+ user.id,
+ {"total_spent": 0, "purchase_count": 0},
+ )
+ total_spent = stats.get("total_spent", 0)
+ purchases = stats.get("purchase_count", 0)
+
+ status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" if user.status == UserStatus.BLOCKED.value else "🗑️"
+
+ button_text = (
+ f"{status_emoji} {user.full_name}"
+ f" | 💳 {settings.format_price(total_spent)}"
+ f" | 🛒 {purchases}"
+ )
+
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text=button_text,
+ callback_data=f"admin_user_manage_{user.id}"
+ )
+ ])
+
+ if users_data["total_pages"] > 1:
+ pagination_row = get_admin_pagination_keyboard(
+ users_data["current_page"],
+ users_data["total_pages"],
+ "admin_users_spending_list",
+ "admin_users",
+ db_user.language
+ ).inline_keyboard[0]
+ keyboard.append(pagination_row)
+
+ keyboard.extend([
+ [
+ types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
+ types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
+ ],
+ [
+ types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
+ ]
+ ])
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def show_users_list_by_purchases(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+ page: int = 1
+):
+
+ await state.set_state(AdminStates.viewing_user_from_purchases_list)
+
+ user_service = UserService()
+ users_data = await user_service.get_users_page(
+ db,
+ page=page,
+ limit=10,
+ order_by_purchase_count=True,
+ )
+
+ users = users_data["users"]
+ if not users:
+ await callback.message.edit_text(
+ "🛒 Пользователи с покупками не найдены",
+ reply_markup=get_admin_users_keyboard(db_user.language)
+ )
+ await callback.answer()
+ return
+
+ spending_map = await user_service.get_user_spending_stats_map(
+ db,
+ [user.id for user in users],
+ )
+
+ text = f"👥 Пользователи по количеству покупок (стр. {page}/{users_data['total_pages']})\n\n"
+ text += "Нажмите на пользователя для управления:"
+
+ keyboard = []
+
+ for user in users:
+ stats = spending_map.get(
+ user.id,
+ {"total_spent": 0, "purchase_count": 0},
+ )
+ total_spent = stats.get("total_spent", 0)
+ purchases = stats.get("purchase_count", 0)
+
+ status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" if user.status == UserStatus.BLOCKED.value else "🗑️"
+
+ button_text = (
+ f"{status_emoji} {user.full_name}"
+ f" | 🛒 {purchases}"
+ f" | 💳 {settings.format_price(total_spent)}"
+ )
+
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text=button_text,
+ callback_data=f"admin_user_manage_{user.id}"
+ )
+ ])
+
+ if users_data["total_pages"] > 1:
+ pagination_row = get_admin_pagination_keyboard(
+ users_data["current_page"],
+ users_data["total_pages"],
+ "admin_users_purchases_list",
+ "admin_users",
+ db_user.language
+ ).inline_keyboard[0]
+ keyboard.append(pagination_row)
+
+ keyboard.extend([
+ [
+ types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
+ types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
+ ],
+ [
+ types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
+ ]
+ ])
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def show_users_list_by_campaign(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+ page: int = 1
+):
+
+ await state.set_state(AdminStates.viewing_user_from_campaign_list)
+
+ user_service = UserService()
+ users_data = await user_service.get_users_by_campaign_page(
+ db,
+ page=page,
+ limit=10,
+ )
+
+ users = users_data.get("users", [])
+ campaign_map = users_data.get("campaigns", {})
+
+ if not users:
+ await callback.message.edit_text(
+ "📢 Пользователи с кампанией не найдены",
+ reply_markup=get_admin_users_keyboard(db_user.language)
+ )
+ await callback.answer()
+ return
+
+ text = f"👥 Пользователи по кампании регистрации (стр. {page}/{users_data['total_pages']})\n\n"
+ text += "Нажмите на пользователя для управления:"
+
+ keyboard = []
+
+ for user in users:
+ info = campaign_map.get(user.id, {})
+ campaign_name = info.get("campaign_name") or "Без кампании"
+ registered_at = info.get("registered_at")
+ registered_display = format_datetime(registered_at) if registered_at else "неизвестно"
+
+ status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" if user.status == UserStatus.BLOCKED.value else "🗑️"
+
+ button_text = (
+ f"{status_emoji} {user.full_name}"
+ f" | 📢 {campaign_name}"
+ f" | 📅 {registered_display}"
+ )
+
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text=button_text,
+ callback_data=f"admin_user_manage_{user.id}"
+ )
+ ])
+
+ if users_data["total_pages"] > 1:
+ pagination_row = get_admin_pagination_keyboard(
+ users_data["current_page"],
+ users_data["total_pages"],
+ "admin_users_campaign_list",
+ "admin_users",
+ db_user.language
+ ).inline_keyboard[0]
+ keyboard.append(pagination_row)
+
+ keyboard.extend([
+ [
+ types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
+ types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
+ ],
+ [
+ types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
+ ]
+ ])
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
+ )
+ await callback.answer()
+
+
+
@admin_required
@error_handler
async def handle_users_list_pagination_fixed(
@@ -322,6 +780,91 @@ async def handle_users_balance_list_pagination(
await show_users_list_by_balance(callback, db_user, db, state, 1)
+@admin_required
+@error_handler
+async def handle_users_traffic_list_pagination(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ try:
+ callback_parts = callback.data.split('_')
+ page = int(callback_parts[-1])
+ await show_users_list_by_traffic(callback, db_user, db, state, page)
+ except (ValueError, IndexError) as e:
+ logger.error(f"Ошибка парсинга номера страницы: {e}")
+ await show_users_list_by_traffic(callback, db_user, db, state, 1)
+
+
+@admin_required
+@error_handler
+async def handle_users_activity_list_pagination(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ try:
+ callback_parts = callback.data.split('_')
+ page = int(callback_parts[-1])
+ await show_users_list_by_last_activity(callback, db_user, db, state, page)
+ except (ValueError, IndexError) as e:
+ logger.error(f"Ошибка парсинга номера страницы: {e}")
+ await show_users_list_by_last_activity(callback, db_user, db, state, 1)
+
+
+@admin_required
+@error_handler
+async def handle_users_spending_list_pagination(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ try:
+ callback_parts = callback.data.split('_')
+ page = int(callback_parts[-1])
+ await show_users_list_by_spending(callback, db_user, db, state, page)
+ except (ValueError, IndexError) as e:
+ logger.error(f"Ошибка парсинга номера страницы: {e}")
+ await show_users_list_by_spending(callback, db_user, db, state, 1)
+
+
+@admin_required
+@error_handler
+async def handle_users_purchases_list_pagination(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ try:
+ callback_parts = callback.data.split('_')
+ page = int(callback_parts[-1])
+ await show_users_list_by_purchases(callback, db_user, db, state, page)
+ except (ValueError, IndexError) as e:
+ logger.error(f"Ошибка парсинга номера страницы: {e}")
+ await show_users_list_by_purchases(callback, db_user, db, state, 1)
+
+
+@admin_required
+@error_handler
+async def handle_users_campaign_list_pagination(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ try:
+ callback_parts = callback.data.split('_')
+ page = int(callback_parts[-1])
+ await show_users_list_by_campaign(callback, db_user, db, state, page)
+ except (ValueError, IndexError) as e:
+ logger.error(f"Ошибка парсинга номера страницы: {e}")
+ await show_users_list_by_campaign(callback, db_user, db, state, 1)
+
+
@admin_required
@error_handler
async def start_user_search(
@@ -830,66 +1373,97 @@ async def show_user_management(
user = profile["user"]
subscription = profile["subscription"]
-
- if user.status == UserStatus.ACTIVE.value:
- status_text = "✅ Активен"
- elif user.status == UserStatus.BLOCKED.value:
- status_text = "🚫 Заблокирован"
- elif user.status == UserStatus.DELETED.value:
- status_text = "🗑️ Удален"
- else:
- status_text = "❓ Неизвестно"
-
- text = f"""
-👤 Управление пользователем
-Основная информация:
-• Имя: {user.full_name}
-• ID: {user.telegram_id}
-• Username: @{user.username or 'не указан'}
-• Статус: {status_text}
-• Язык: {user.language}
+ texts = get_texts(db_user.language)
-Финансы:
-• Баланс: {settings.format_price(user.balance_kopeks)}
-• Транзакций: {profile['transactions_count']}
+ status_map = {
+ UserStatus.ACTIVE.value: texts.ADMIN_USER_STATUS_ACTIVE,
+ UserStatus.BLOCKED.value: texts.ADMIN_USER_STATUS_BLOCKED,
+ UserStatus.DELETED.value: texts.ADMIN_USER_STATUS_DELETED,
+ }
+ status_text = status_map.get(user.status, texts.ADMIN_USER_STATUS_UNKNOWN)
+
+ username_display = (
+ f"@{user.username}" if user.username else texts.ADMIN_USER_USERNAME_NOT_SET
+ )
+ last_activity = (
+ format_time_ago(user.last_activity, db_user.language)
+ if user.last_activity
+ else texts.ADMIN_USER_LAST_ACTIVITY_UNKNOWN
+ )
+
+ sections = [
+ texts.ADMIN_USER_MANAGEMENT_PROFILE.format(
+ name=user.full_name,
+ telegram_id=user.telegram_id,
+ username=username_display,
+ status=status_text,
+ language=user.language,
+ balance=settings.format_price(user.balance_kopeks),
+ transactions=profile["transactions_count"],
+ registration=format_datetime(user.created_at),
+ last_activity=last_activity,
+ registration_days=profile["registration_days"],
+ )
+ ]
-Активность:
-• Регистрация: {format_datetime(user.created_at)}
-• Последняя активность: {format_time_ago(user.last_activity) if user.last_activity else 'Неизвестно'}
-• Дней с регистрации: {profile['registration_days']}
-"""
-
if subscription:
- text += f"""
-Подписка:
-• Тип: {'🎁 Триал' if subscription.is_trial else '💎 Платная'}
-• Статус: {'✅ Активна' if subscription.is_active else '❌ Неактивна'}
-• До: {format_datetime(subscription.end_date)}
-• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ
-• Устройства: {subscription.device_limit}
-• Стран: {len(subscription.connected_squads)}
-"""
+ subscription_type = (
+ texts.ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL
+ if subscription.is_trial
+ else texts.ADMIN_USER_SUBSCRIPTION_TYPE_PAID
+ )
+ subscription_status = (
+ texts.ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE
+ if subscription.is_active
+ else texts.ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE
+ )
+ traffic_usage = texts.ADMIN_USER_TRAFFIC_USAGE.format(
+ used=f"{subscription.traffic_used_gb:.1f}",
+ limit=subscription.traffic_limit_gb,
+ )
+ sections.append(
+ texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION.format(
+ type=subscription_type,
+ status=subscription_status,
+ end_date=format_datetime(subscription.end_date),
+ traffic=traffic_usage,
+ devices=subscription.device_limit,
+ countries=len(subscription.connected_squads),
+ )
+ )
else:
- text += "\nПодписка: Отсутствует"
+ sections.append(texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE)
if user.promo_group:
promo_group = user.promo_group
- text += f"""
-
-Промогруппа:
-• Название: {promo_group.name}
-• Скидка на сервера: {promo_group.server_discount_percent}%
-• Скидка на трафик: {promo_group.traffic_discount_percent}%
-• Скидка на устройства: {promo_group.device_discount_percent}%
-"""
+ sections.append(
+ texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format(
+ name=promo_group.name,
+ server_discount=promo_group.server_discount_percent,
+ traffic_discount=promo_group.traffic_discount_percent,
+ device_discount=promo_group.device_discount_percent,
+ )
+ )
else:
- text += "\nПромогруппа: Не назначена"
+ sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
+
+ text = "\n\n".join(sections)
# Проверяем состояние, чтобы определить, откуда пришел пользователь
current_state = await state.get_state()
if current_state == AdminStates.viewing_user_from_balance_list:
back_callback = "admin_users_balance_filter"
+ elif current_state == AdminStates.viewing_user_from_traffic_list:
+ back_callback = "admin_users_traffic_filter"
+ elif current_state == AdminStates.viewing_user_from_last_activity_list:
+ back_callback = "admin_users_activity_filter"
+ elif current_state == AdminStates.viewing_user_from_spending_list:
+ back_callback = "admin_users_spending_filter"
+ elif current_state == AdminStates.viewing_user_from_purchases_list:
+ back_callback = "admin_users_purchases_filter"
+ elif current_state == AdminStates.viewing_user_from_campaign_list:
+ back_callback = "admin_users_campaign_filter"
# Базовая клавиатура профиля
kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
@@ -1210,7 +1784,12 @@ async def show_inactive_users(
for user in inactive_users[:10]:
text += f"👤 {user.full_name}\n"
text += f"🆔 {user.telegram_id}\n"
- text += f"📅 {format_time_ago(user.last_activity) if user.last_activity else 'Никогда'}\n\n"
+ last_activity_display = (
+ format_time_ago(user.last_activity, db_user.language)
+ if user.last_activity
+ else "Никогда"
+ )
+ text += f"📅 {last_activity_display}\n\n"
if len(inactive_users) > 10:
text += f"... и еще {len(inactive_users) - 10} пользователей"
@@ -3340,6 +3919,31 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("admin_users_balance_list_page_")
)
+ dp.callback_query.register(
+ handle_users_traffic_list_pagination,
+ F.data.startswith("admin_users_traffic_list_page_")
+ )
+
+ dp.callback_query.register(
+ handle_users_activity_list_pagination,
+ F.data.startswith("admin_users_activity_list_page_")
+ )
+
+ dp.callback_query.register(
+ handle_users_spending_list_pagination,
+ F.data.startswith("admin_users_spending_list_page_")
+ )
+
+ dp.callback_query.register(
+ handle_users_purchases_list_pagination,
+ F.data.startswith("admin_users_purchases_list_page_")
+ )
+
+ dp.callback_query.register(
+ handle_users_campaign_list_pagination,
+ F.data.startswith("admin_users_campaign_list_page_")
+ )
+
dp.callback_query.register(
start_user_search,
F.data == "admin_users_search"
@@ -3545,9 +4149,27 @@ def register_handlers(dp: Dispatcher):
)
dp.callback_query.register(
- show_users_list_by_balance,
- F.data.startswith("admin_users_balance_list_page_")
+ show_users_list_by_traffic,
+ F.data == "admin_users_traffic_filter"
)
+ dp.callback_query.register(
+ show_users_list_by_last_activity,
+ F.data == "admin_users_activity_filter"
+ )
+ dp.callback_query.register(
+ show_users_list_by_spending,
+ F.data == "admin_users_spending_filter"
+ )
+ dp.callback_query.register(
+ show_users_list_by_purchases,
+ F.data == "admin_users_purchases_filter"
+ )
+
+ dp.callback_query.register(
+ show_users_list_by_campaign,
+ F.data == "admin_users_campaign_filter"
+ )
+
diff --git a/app/handlers/balance.py b/app/handlers/balance.py
index e6f0b832..adcabebd 100644
--- a/app/handlers/balance.py
+++ b/app/handlers/balance.py
@@ -1,3 +1,4 @@
+import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
@@ -1002,9 +1003,7 @@ async def process_pal24_payment_amount(
language=db_user.language,
)
- if not payment_result or not (
- payment_result.get("link_url") or payment_result.get("link_page_url")
- ):
+ if not payment_result:
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
@@ -1014,49 +1013,146 @@ async def process_pal24_payment_amount(
await state.clear()
return
- payment_url = (
+ sbp_url = (
+ payment_result.get("sbp_url")
+ or payment_result.get("transfer_url")
+ )
+ card_url = payment_result.get("card_url")
+ fallback_url = (
payment_result.get("link_page_url")
or payment_result.get("link_url")
)
+
+ if not (sbp_url or card_url or fallback_url):
+ await message.answer(
+ texts.t(
+ "PAL24_PAYMENT_ERROR",
+ "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
+ )
+ )
+ await state.clear()
+ return
+
+ if not sbp_url:
+ sbp_url = fallback_url
+
bill_id = payment_result.get("bill_id")
local_payment_id = payment_result.get("local_payment_id")
- keyboard = types.InlineKeyboardMarkup(
- inline_keyboard=[
- [
- types.InlineKeyboardButton(
- text=texts.t("PAL24_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)"),
- url=payment_url,
- )
- ],
- [
- types.InlineKeyboardButton(
- text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
- callback_data=f"check_pal24_{local_payment_id}",
- )
- ],
- [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
- ]
+ pay_buttons: list[list[types.InlineKeyboardButton]] = []
+ steps: list[str] = []
+ step_counter = 1
+
+ default_sbp_text = texts.t(
+ "PAL24_SBP_PAY_BUTTON",
+ "🏦 Оплатить через PayPalych (СБП)",
)
+ sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
+
+ if sbp_url:
+ pay_buttons.append(
+ [
+ types.InlineKeyboardButton(
+ text=sbp_button_text,
+ url=sbp_url,
+ )
+ ]
+ )
+ steps.append(
+ texts.t(
+ "PAL24_INSTRUCTION_BUTTON",
+ "{step}. Нажмите кнопку «{button}»",
+ ).format(step=step_counter, button=html.escape(sbp_button_text))
+ )
+ step_counter += 1
+
+ default_card_text = texts.t(
+ "PAL24_CARD_PAY_BUTTON",
+ "💳 Оплатить банковской картой (PayPalych)",
+ )
+ card_button_text = settings.get_pal24_card_button_text(default_card_text)
+
+ if card_url and card_url != sbp_url:
+ pay_buttons.append(
+ [
+ types.InlineKeyboardButton(
+ text=card_button_text,
+ url=card_url,
+ )
+ ]
+ )
+ steps.append(
+ texts.t(
+ "PAL24_INSTRUCTION_BUTTON",
+ "{step}. Нажмите кнопку «{button}»",
+ ).format(step=step_counter, button=html.escape(card_button_text))
+ )
+ step_counter += 1
+
+ if not pay_buttons and fallback_url:
+ pay_buttons.append(
+ [
+ types.InlineKeyboardButton(
+ text=sbp_button_text,
+ url=fallback_url,
+ )
+ ]
+ )
+ steps.append(
+ texts.t(
+ "PAL24_INSTRUCTION_BUTTON",
+ "{step}. Нажмите кнопку «{button}»",
+ ).format(step=step_counter, button=html.escape(sbp_button_text))
+ )
+ step_counter += 1
+
+ follow_template = texts.t(
+ "PAL24_INSTRUCTION_FOLLOW",
+ "{step}. Следуйте подсказкам платёжной системы",
+ )
+ steps.append(follow_template.format(step=step_counter))
+ step_counter += 1
+
+ confirm_template = texts.t(
+ "PAL24_INSTRUCTION_CONFIRM",
+ "{step}. Подтвердите перевод",
+ )
+ steps.append(confirm_template.format(step=step_counter))
+ step_counter += 1
+
+ success_template = texts.t(
+ "PAL24_INSTRUCTION_COMPLETE",
+ "{step}. Средства зачислятся автоматически",
+ )
+ steps.append(success_template.format(step=step_counter))
message_template = texts.t(
"PAL24_PAYMENT_INSTRUCTIONS",
(
- "🏦 Оплата через PayPalych (СБП)\n\n"
+ "🏦 Оплата через PayPalych\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID счета: {bill_id}\n\n"
- "📱 Инструкция:\n"
- "1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n"
- "2. Следуйте подсказкам платежной системы\n"
- "3. Подтвердите перевод\n"
- "4. Средства зачислятся автоматически\n\n"
+ "📱 Инструкция:\n{steps}\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
+ keyboard_rows = pay_buttons + [
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
+ callback_data=f"check_pal24_{local_payment_id}",
+ )
+ ],
+ [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
+ ]
+
+ keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
+
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
bill_id=bill_id,
+ steps="\n".join(steps),
support=settings.get_support_contact_display_html(),
)
@@ -1231,28 +1327,48 @@ async def check_pal24_payment_status(
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
- payment_link = payment.link_page_url or payment.link_url
+ metadata = payment.metadata_json or {}
+ links_meta = metadata.get("links") if isinstance(metadata, dict) else None
+ if not isinstance(links_meta, dict):
+ links_meta = {}
+
+ sbp_link = links_meta.get("sbp") or payment.link_url
+ card_link = links_meta.get("card")
+
+ if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
+ card_link = payment.link_page_url
message_lines = [
- "🏦 Статус платежа PayPalych (СБП):\n\n",
- f"🆔 ID счета: {payment.bill_id}\n",
- f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
- f"📊 Статус: {emoji} {status_text}\n",
- f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
+ "🏦 Статус платежа PayPalych:",
+ "",
+ f"🆔 ID счета: {payment.bill_id}",
+ f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
+ f"📊 Статус: {emoji} {status_text}",
+ f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}",
]
if payment.is_paid:
- message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
+ message_lines.append("")
+ message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"NEW", "PROCESS"}:
- message_lines.append("\n⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
- if payment_link:
- message_lines.append(f"\n🔗 Ссылка на оплату: {payment_link}")
+ message_lines.append("")
+ message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
+ if sbp_link:
+ message_lines.append("")
+ message_lines.append(f"🏦 СБП: {sbp_link}")
+ if card_link and card_link != sbp_link:
+ message_lines.append(f"💳 Банковская карта: {card_link}")
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
+ message_lines.append("")
message_lines.append(
- f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
+ f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
)
- await callback.answer("".join(message_lines), show_alert=True)
+ await callback.answer()
+ await callback.message.answer(
+ "\n".join(message_lines),
+ disable_web_page_preview=True,
+ )
except Exception as e:
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
diff --git a/app/handlers/menu.py b/app/handlers/menu.py
index a191d537..d073f0dd 100644
--- a/app/handlers/menu.py
+++ b/app/handlers/menu.py
@@ -44,13 +44,18 @@ async def show_main_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
+ is_admin = settings.is_admin(db_user.telegram_id)
+ is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
+ db_user.telegram_id
+ )
+
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
- is_admin=settings.is_admin(db_user.telegram_id),
- is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)),
+ is_admin=is_admin,
+ is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
@@ -191,13 +196,18 @@ async def handle_back_to_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
+ is_admin = settings.is_admin(db_user.telegram_id)
+ is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
+ db_user.telegram_id
+ )
+
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
- is_admin=settings.is_admin(db_user.telegram_id),
- is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)),
+ is_admin=is_admin,
+ is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
diff --git a/app/handlers/start.py b/app/handlers/start.py
index 780f44c3..92187a1f 100644
--- a/app/handlers/start.py
+++ b/app/handlers/start.py
@@ -30,6 +30,7 @@ from app.services.referral_service import process_referral_registration
from app.services.campaign_service import AdvertisingCampaignService
from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_service import SubscriptionService
+from app.services.support_settings_service import SupportSettingsService
from app.utils.user_utils import generate_unique_referral_code
from app.database.crud.user_message import get_random_active_message
@@ -322,17 +323,23 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
-
+
+ is_admin = settings.is_admin(user.telegram_id)
+ is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
+ user.telegram_id
+ )
+
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
- is_admin=settings.is_admin(user.telegram_id),
+ is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
- subscription=user.subscription
+ subscription=user.subscription,
+ is_moderator=is_moderator,
),
parse_mode="HTML"
)
@@ -728,18 +735,25 @@ async def complete_registration_from_callback(
subscription_is_active = existing_user.subscription.is_active
menu_text = await get_main_menu_text(existing_user, texts, db)
-
+
+ is_admin = settings.is_admin(existing_user.telegram_id)
+ is_moderator = (
+ (not is_admin)
+ and SupportSettingsService.is_moderator(existing_user.telegram_id)
+ )
+
try:
await callback.message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=existing_user.language,
- is_admin=settings.is_admin(existing_user.telegram_id),
+ is_admin=is_admin,
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks,
- subscription=existing_user.subscription
+ subscription=existing_user.subscription,
+ is_moderator=is_moderator,
),
parse_mode="HTML"
)
@@ -893,18 +907,25 @@ async def complete_registration_from_callback(
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
-
+
+ is_admin = settings.is_admin(user.telegram_id)
+ is_moderator = (
+ (not is_admin)
+ and SupportSettingsService.is_moderator(user.telegram_id)
+ )
+
try:
await callback.message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
- is_admin=settings.is_admin(user.telegram_id),
+ is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
- subscription=user.subscription
+ subscription=user.subscription,
+ is_moderator=is_moderator,
),
parse_mode="HTML"
)
@@ -952,18 +973,25 @@ async def complete_registration(
subscription_is_active = existing_user.subscription.is_active
menu_text = await get_main_menu_text(existing_user, texts, db)
-
+
+ is_admin = settings.is_admin(existing_user.telegram_id)
+ is_moderator = (
+ (not is_admin)
+ and SupportSettingsService.is_moderator(existing_user.telegram_id)
+ )
+
try:
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=existing_user.language,
- is_admin=settings.is_admin(existing_user.telegram_id),
+ is_admin=is_admin,
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks,
- subscription=existing_user.subscription
+ subscription=existing_user.subscription,
+ is_moderator=is_moderator,
),
parse_mode="HTML"
)
@@ -1117,18 +1145,25 @@ async def complete_registration(
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
-
+
+ is_admin = settings.is_admin(user.telegram_id)
+ is_moderator = (
+ (not is_admin)
+ and SupportSettingsService.is_moderator(user.telegram_id)
+ )
+
try:
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
- is_admin=settings.is_admin(user.telegram_id),
+ is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
- subscription=user.subscription
+ subscription=user.subscription,
+ is_moderator=is_moderator,
),
parse_mode="HTML"
)
@@ -1357,14 +1392,21 @@ async def required_sub_channel_check(
from app.utils.message_patch import LOGO_PATH
from aiogram.types import FSInputFile
+ is_admin = settings.is_admin(user.telegram_id)
+ is_moderator = (
+ (not is_admin)
+ and SupportSettingsService.is_moderator(user.telegram_id)
+ )
+
keyboard = get_main_menu_keyboard(
language=user.language,
- is_admin=settings.is_admin(user.telegram_id),
+ is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
+ is_moderator=is_moderator,
)
if settings.ENABLE_LOGO_MODE:
diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py
index 8c2ae0b4..52800062 100644
--- a/app/handlers/subscription.py
+++ b/app/handlers/subscription.py
@@ -1,44 +1,40 @@
+import json
import logging
from datetime import datetime, timedelta
-from aiogram import Dispatcher, types, F
-from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
-from aiogram.fsm.context import FSMContext
-from sqlalchemy.ext.asyncio import AsyncSession
-import json
-import os
from typing import Dict, List, Any, Tuple, Optional
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+
from app.config import settings, PERIOD_PRICES, get_traffic_prices
-from app.states import SubscriptionStates
+from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed
from app.database.crud.subscription import (
- get_subscription_by_user_id, create_trial_subscription,
- create_paid_subscription, extend_subscription,
- add_subscription_traffic, add_subscription_devices,
- add_subscription_squad, update_subscription_autopay,
- add_subscription_servers
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
)
+from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance, add_user_balance
-from app.database.crud.transaction import create_transaction, get_user_transactions
from app.database.models import (
User, TransactionType, SubscriptionStatus,
- SubscriptionServer, Subscription
+ Subscription
)
-from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
- get_extend_subscription_keyboard, get_add_traffic_keyboard,
+ get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
- get_devices_management_keyboard, get_device_reset_confirm_keyboard,
- get_device_management_help_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
@@ -47,15 +43,17 @@ from app.keyboards.inline import (
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
-from app.services.remnawave_service import RemnaWaveService
from app.services.admin_notification_service import AdminNotificationService
-from app.services.subscription_service import SubscriptionService
+from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
+from app.services.subscription_service import SubscriptionService
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
@@ -64,7 +62,6 @@ from app.utils.pricing_utils import (
format_period_description,
apply_percentage_discount,
)
-from app.utils.pagination import paginate_list
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
@@ -77,9 +74,9 @@ TRAFFIC_PRICES = get_traffic_prices()
def _get_addon_discount_percent_for_user(
- user: Optional[User],
- category: str,
- period_days_hint: Optional[int] = None,
+ user: Optional[User],
+ category: str,
+ period_days_hint: Optional[int] = None,
) -> int:
if user is None:
return 0
@@ -98,10 +95,10 @@ def _get_addon_discount_percent_for_user(
def _apply_addon_discount(
- user: Optional[User],
- category: str,
- amount: int,
- period_days_hint: Optional[int] = None,
+ user: Optional[User],
+ category: str,
+ amount: int,
+ period_days_hint: Optional[int] = None,
) -> Dict[str, int]:
percent = _get_addon_discount_percent_for_user(user, category, period_days_hint)
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
@@ -125,9 +122,9 @@ def _get_period_hint_from_subscription(subscription: Optional[Subscription]) ->
def _apply_discount_to_monthly_component(
- amount_per_month: int,
- percent: int,
- months: int,
+ amount_per_month: int,
+ percent: int,
+ months: int,
) -> Dict[str, int]:
discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent)
@@ -142,11 +139,10 @@ def _apply_discount_to_monthly_component(
async def _prepare_subscription_summary(
- db_user: User,
- data: Dict[str, Any],
- texts,
+ db_user: User,
+ data: Dict[str, Any],
+ texts,
) -> Tuple[str, Dict[str, Any]]:
-
summary_data = dict(data)
countries = await _get_available_countries(db_user.promo_group_id)
@@ -234,9 +230,9 @@ async def _prepare_subscription_summary(
total_price = base_price + total_traffic_price + total_countries_price + total_devices_price
discounted_monthly_additions = (
- traffic_component["discounted_per_month"]
- + discounted_servers_price_per_month
- + devices_component["discounted_per_month"]
+ traffic_component["discounted_per_month"]
+ + discounted_servers_price_per_month
+ + devices_component["discounted_per_month"]
)
is_valid = validate_pricing_calculation(
@@ -351,9 +347,9 @@ async def _prepare_subscription_summary(
def _build_promo_group_discount_text(
- db_user: User,
- periods: Optional[List[int]] = None,
- texts=None,
+ db_user: User,
+ periods: Optional[List[int]] = None,
+ texts=None,
) -> str:
promo_group = getattr(db_user, "promo_group", None)
@@ -446,15 +442,15 @@ def _build_subscription_period_prompt(db_user: User, texts) -> str:
async def show_subscription_info(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
await db.refresh(db_user)
-
+
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
if not subscription:
await callback.message.edit_text(
texts.SUBSCRIPTION_NONE,
@@ -462,17 +458,17 @@ async def show_subscription_info(
)
await callback.answer()
return
-
+
from app.database.crud.subscription import check_and_update_subscription_status
subscription = await check_and_update_subscription_status(db, subscription)
-
+
subscription_service = SubscriptionService()
await subscription_service.sync_subscription_usage(db, subscription)
-
+
await db.refresh(subscription)
-
+
current_time = datetime.utcnow()
-
+
if subscription.status == "expired" or subscription.end_date <= current_time:
actual_status = "expired"
status_display = texts.t("SUBSCRIPTION_STATUS_EXPIRED", "Истекла")
@@ -536,7 +532,7 @@ async def show_subscription_info(
"SUBSCRIPTION_TRAFFIC_LIMITED",
"{used} / {limit} ГБ",
).format(used=used_traffic, limit=subscription.traffic_limit_gb)
-
+
devices_used_str = "—"
devices_list = []
devices_count = 0
@@ -545,10 +541,10 @@ async def show_subscription_info(
if db_user.remnawave_uuid:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
-
+
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
+
if response and 'response' in response:
devices_info = response['response']
devices_count = devices_info.get('total', 0)
@@ -557,7 +553,7 @@ async def show_subscription_info(
logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}")
else:
logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}")
-
+
except Exception as e:
logger.error(f"Ошибка получения устройств для отображения: {e}")
devices_used_str = await get_current_devices_count(db_user)
@@ -611,14 +607,14 @@ async def show_subscription_info(
device_info = device_info[:32] + "..."
message += f"• {device_info}\n"
message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "")
-
+
subscription_link = get_display_subscription_link(subscription)
hide_subscription_link = settings.should_hide_subscription_link()
if (
- subscription_link
- and actual_status in ["trial_active", "paid_active"]
- and not hide_subscription_link
+ subscription_link
+ and actual_status in ["trial_active", "paid_active"]
+ and not hide_subscription_link
):
message += "\n\n" + texts.t(
"SUBSCRIPTION_CONNECT_LINK_SECTION",
@@ -628,7 +624,7 @@ async def show_subscription_info(
"SUBSCRIPTION_CONNECT_LINK_PROMPT",
"📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
)
-
+
await callback.message.edit_text(
message,
reply_markup=get_subscription_keyboard(
@@ -641,43 +637,45 @@ async def show_subscription_info(
)
await callback.answer()
+
async def get_current_devices_detailed(db_user: User) -> dict:
try:
if not db_user.remnawave_uuid:
return {"count": 0, "devices": []}
-
+
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
-
+
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
+
if response and 'response' in response:
devices_info = response['response']
total_devices = devices_info.get('total', 0)
devices_list = devices_info.get('devices', [])
-
+
return {
"count": total_devices,
- "devices": devices_list[:5]
+ "devices": devices_list[:5]
}
else:
return {"count": 0, "devices": []}
-
+
except Exception as e:
logger.error(f"Ошибка получения детальной информации об устройствах: {e}")
return {"count": 0, "devices": []}
+
async def get_servers_display_names(squad_uuids: List[str]) -> str:
if not squad_uuids:
return "Нет серверов"
-
+
try:
from app.database.database import AsyncSessionLocal
from app.database.crud.server_squad import get_server_squad_by_uuid
-
+
server_names = []
-
+
async with AsyncSessionLocal() as db:
for uuid in squad_uuids:
server = await get_server_squad_by_uuid(db, uuid)
@@ -686,7 +684,7 @@ async def get_servers_display_names(squad_uuids: List[str]) -> str:
logger.debug(f"Найден сервер в БД: {uuid} -> {server.display_name}")
else:
logger.warning(f"Сервер с UUID {uuid} не найден в БД")
-
+
if not server_names:
countries = await _get_available_countries()
for uuid in squad_uuids:
@@ -695,42 +693,43 @@ async def get_servers_display_names(squad_uuids: List[str]) -> str:
server_names.append(country['name'])
logger.debug(f"Найден сервер в кэше: {uuid} -> {country['name']}")
break
-
+
if not server_names:
if len(squad_uuids) == 1:
return "🎯 Тестовый сервер"
return f"{len(squad_uuids)} стран"
-
+
if len(server_names) > 6:
displayed = ", ".join(server_names[:6])
remaining = len(server_names) - 6
return f"{displayed} и ещё {remaining}"
else:
return ", ".join(server_names)
-
+
except Exception as e:
logger.error(f"Ошибка получения названий серверов: {e}")
if len(squad_uuids) == 1:
return "🎯 Тестовый сервер"
return f"{len(squad_uuids)} стран"
+
async def get_current_devices_count(db_user: User) -> str:
try:
if not db_user.remnawave_uuid:
return "—"
-
+
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
-
+
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
+
if response and 'response' in response:
total_devices = response['response'].get('total', 0)
return str(total_devices)
else:
return "—"
-
+
except Exception as e:
logger.error(f"Ошибка получения количества устройств: {e}")
return "—"
@@ -740,12 +739,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
try:
if subscription.is_trial:
return 0
-
+
from app.config import settings
from app.services.subscription_service import SubscriptionService
-
+
subscription_service = SubscriptionService()
-
+
base_cost_original = PERIOD_PRICES.get(30, 0)
try:
owner = subscription.user
@@ -780,43 +779,43 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
db,
promo_group_id=promo_group_id,
)
-
+
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
-
+
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
-
+
logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:")
- base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original/100}₽"
+ base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original / 100}₽"
if period_discount_percent > 0:
discount_value = base_cost_original * period_discount_percent // 100
base_log += (
- f" → {base_cost/100}₽"
- f" (скидка {period_discount_percent}%: -{discount_value/100}₽)"
+ f" → {base_cost / 100}₽"
+ f" (скидка {period_discount_percent}%: -{discount_value / 100}₽)"
)
logger.info(base_log)
if servers_cost > 0:
- logger.info(f" 🌍 Серверы: {servers_cost/100}₽")
+ logger.info(f" 🌍 Серверы: {servers_cost / 100}₽")
if traffic_cost > 0:
- logger.info(f" 📊 Трафик: {traffic_cost/100}₽")
+ logger.info(f" 📊 Трафик: {traffic_cost / 100}₽")
if devices_cost > 0:
- logger.info(f" 📱 Устройства: {devices_cost/100}₽")
- logger.info(f" 💎 ИТОГО: {total_cost/100}₽")
-
+ logger.info(f" 📱 Устройства: {devices_cost / 100}₽")
+ logger.info(f" 💎 ИТОГО: {total_cost / 100}₽")
+
return total_cost
-
+
except Exception as e:
logger.error(f"⚠️ Ошибка расчета стоимости подписки: {e}")
return 0
async def show_trial_offer(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
texts = get_texts(db_user.language)
-
+
if db_user.subscription or db_user.has_had_paid_subscription:
await callback.message.edit_text(
texts.TRIAL_ALREADY_USED,
@@ -824,11 +823,11 @@ async def show_trial_offer(
)
await callback.answer()
return
-
- trial_server_name = "🎯 Тестовый сервер"
+
+ trial_server_name = "🎯 Тестовый сервер"
try:
from app.database.crud.server_squad import get_server_squad_by_uuid
-
+
if settings.TRIAL_SQUAD_UUID:
trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID)
if trial_server:
@@ -837,17 +836,17 @@ async def show_trial_offer(
logger.warning(f"Триальный сервер с UUID {settings.TRIAL_SQUAD_UUID} не найден в БД")
else:
logger.warning("TRIAL_SQUAD_UUID не настроен в конфигурации")
-
+
except Exception as e:
logger.error(f"Ошибка получения триального сервера: {e}")
-
+
trial_text = texts.TRIAL_AVAILABLE.format(
days=settings.TRIAL_DURATION_DAYS,
traffic=settings.TRIAL_TRAFFIC_LIMIT_GB,
devices=settings.TRIAL_DEVICE_LIMIT,
server_name=trial_server_name
)
-
+
await callback.message.edit_text(
trial_text,
reply_markup=get_trial_keyboard(db_user.language)
@@ -856,14 +855,14 @@ async def show_trial_offer(
async def activate_trial(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
from app.services.admin_notification_service import AdminNotificationService
-
+
texts = get_texts(db_user.language)
-
+
if db_user.subscription or db_user.has_had_paid_subscription:
await callback.message.edit_text(
texts.TRIAL_ALREADY_USED,
@@ -871,54 +870,54 @@ async def activate_trial(
)
await callback.answer()
return
-
+
try:
subscription = await create_trial_subscription(db, db_user.id)
-
+
await db.refresh(db_user)
-
+
subscription_service = SubscriptionService()
remnawave_user = await subscription_service.create_remnawave_user(
db, subscription
)
-
+
await db.refresh(db_user)
-
+
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_trial_activation_notification(db, db_user, subscription)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о триале: {e}")
-
+
subscription_link = get_display_subscription_link(subscription)
hide_subscription_link = settings.should_hide_subscription_link()
if remnawave_user and subscription_link:
if settings.is_happ_cryptolink_mode():
trial_success_text = (
- f"{texts.TRIAL_ACTIVATED}\n\n"
- + texts.t(
- "SUBSCRIPTION_HAPP_LINK_PROMPT",
- "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
- "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
- )
+ f"{texts.TRIAL_ACTIVATED}\n\n"
+ + texts.t(
+ "SUBSCRIPTION_HAPP_LINK_PROMPT",
+ "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
+ "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
+ )
)
elif hide_subscription_link:
trial_success_text = (
- f"{texts.TRIAL_ACTIVATED}\n\n"
- + texts.t(
- "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
- "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
- "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
- )
+ f"{texts.TRIAL_ACTIVATED}\n\n"
+ + texts.t(
+ "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
+ "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
+ "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
+ )
)
else:
subscription_import_link = texts.t(
@@ -942,7 +941,8 @@ async def activate_trial(
web_app=types.WebAppInfo(url=subscription_link),
)
],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
])
elif connect_mode == "miniapp_custom":
if not settings.MINIAPP_CUSTOM_URL:
@@ -962,7 +962,8 @@ async def activate_trial(
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
)
],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
])
elif connect_mode == "link":
rows = [
@@ -999,10 +1000,12 @@ async def activate_trial(
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
else:
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")],
+ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ callback_data="subscription_connect")],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
])
-
+
await callback.message.edit_text(
trial_success_text,
reply_markup=connect_keyboard,
@@ -1013,23 +1016,23 @@ async def activate_trial(
f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд.",
reply_markup=get_back_keyboard(db_user.language)
)
-
+
logger.info(f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}")
-
+
except Exception as e:
logger.error(f"Ошибка активации триала: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
-
+
await callback.answer()
async def start_subscription_purchase(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User
):
texts = get_texts(db_user.language)
@@ -1050,27 +1053,26 @@ async def start_subscription_purchase(
'devices': initial_devices,
'total_price': 0
}
-
+
if settings.is_traffic_fixed():
initial_data['traffic_gb'] = settings.get_fixed_traffic_limit()
else:
initial_data['traffic_gb'] = None
-
+
await state.set_data(initial_data)
await state.set_state(SubscriptionStates.selecting_period)
await callback.answer()
+
async def save_cart_and_redirect_to_topup(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- missing_amount: int
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ missing_amount: int
):
- from app.handlers.balance import show_payment_methods
-
texts = get_texts(db_user.language)
data = await state.get_data()
-
+
await state.set_state(SubscriptionStates.cart_saved_for_topup)
await state.update_data({
**data,
@@ -1078,7 +1080,7 @@ async def save_cart_and_redirect_to_topup(
'missing_amount': missing_amount,
'return_to_cart': True
})
-
+
await callback.message.edit_text(
f"💰 Недостаточно средств для оформления подписки\n\n"
f"Требуется: {texts.format_price(missing_amount)}\n"
@@ -1093,21 +1095,22 @@ async def save_cart_and_redirect_to_topup(
parse_mode="HTML"
)
+
async def return_to_saved_cart(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
):
data = await state.get_data()
texts = get_texts(db_user.language)
-
+
if not data.get('saved_cart'):
await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True)
return
-
+
total_price = data.get('total_price', 0)
-
+
if db_user.balance_kopeks < total_price:
missing_amount = total_price - db_user.balance_kopeks
await callback.message.edit_text(
@@ -1121,23 +1124,22 @@ async def return_to_saved_cart(
)
)
return
-
-
+
countries = await _get_available_countries(db_user.promo_group_id)
selected_countries_names = []
-
+
months_in_period = calculate_months_from_days(data['period_days'])
period_display = format_period_description(data['period_days'], db_user.language)
-
+
for country in countries:
if country['uuid'] in data['countries']:
selected_countries_names.append(country['name'])
-
+
if settings.is_traffic_fixed():
traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
else:
traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
-
+
summary_text = (
"🛒 Восстановленная корзина\n\n"
f"📅 Период: {period_display}\n"
@@ -1147,31 +1149,42 @@ async def return_to_saved_cart(
f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
-
+
await callback.message.edit_text(
summary_text,
reply_markup=get_subscription_confirm_keyboard_with_cart(db_user.language),
parse_mode="HTML"
)
-
+
await state.set_state(SubscriptionStates.confirming_purchase)
await callback.answer("✅ Корзина восстановлена!")
+
async def handle_add_countries(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
):
if not await _should_show_countries_management(db_user):
- await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True)
+ texts = get_texts(db_user.language)
+ await callback.answer(
+ texts.t(
+ "COUNTRY_MANAGEMENT_UNAVAILABLE",
+ "ℹ️ Управление серверами недоступно - доступен только один сервер",
+ ),
+ show_alert=True,
+ )
return
-
+
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
- await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True)
+ await callback.answer(
+ texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
+ show_alert=True,
+ )
return
countries = await _get_available_countries(db_user.promo_group_id)
@@ -1183,28 +1196,38 @@ async def handle_add_countries(
"servers",
period_hint_days,
)
-
+
current_countries_names = []
for country in countries:
if country['uuid'] in current_countries:
current_countries_names.append(country['name'])
-
- text = "🌍 Управление странами подписки\n\n"
- text += f"📋 Текущие страны ({len(current_countries)}):\n"
- if current_countries_names:
- text += "\n".join(f"• {name}" for name in current_countries_names)
- else:
- text += "Нет подключенных стран"
-
- text += "\n\n💡 Инструкция:\n"
- text += "✅ - страна подключена\n"
- text += "➕ - будет добавлена (платно)\n"
- text += "➖ - будет отключена (бесплатно)\n"
- text += "⚪ - не выбрана\n\n"
- text += "⚠️ Важно: Повторное подключение отключенных стран будет платным!"
-
+
+ current_list = (
+ "\n".join(f"• {name}" for name in current_countries_names)
+ if current_countries_names
+ else texts.t("COUNTRY_MANAGEMENT_NONE", "Нет подключенных стран")
+ )
+
+ text = texts.t(
+ "COUNTRY_MANAGEMENT_PROMPT",
+ (
+ "🌍 Управление странами подписки\n\n"
+ "📋 Текущие страны ({current_count}):\n"
+ "{current_list}\n\n"
+ "💡 Инструкция:\n"
+ "✅ - страна подключена\n"
+ "➕ - будет добавлена (платно)\n"
+ "➖ - будет отключена (бесплатно)\n"
+ "⚪ - не выбрана\n\n"
+ "⚠️ Важно: Повторное подключение отключенных стран будет платным!"
+ ),
+ ).format(
+ current_count=len(current_countries),
+ current_list=current_list,
+ )
+
await state.update_data(countries=current_countries.copy())
-
+
await callback.message.edit_text(
text,
reply_markup=get_manage_countries_keyboard(
@@ -1217,20 +1240,21 @@ async def handle_add_countries(
),
parse_mode="HTML"
)
-
+
await callback.answer()
+
async def get_countries_price_by_uuids_fallback(
- country_uuids: List[str],
- db: AsyncSession,
- promo_group_id: Optional[int] = None,
+ country_uuids: List[str],
+ db: AsyncSession,
+ promo_group_id: Optional[int] = None,
) -> Tuple[int, List[int]]:
try:
from app.database.crud.server_squad import get_server_squad_by_uuid
-
+
total_price = 0
prices_list = []
-
+
for country_uuid in country_uuids:
try:
server = await get_server_squad_by_uuid(db, country_uuid)
@@ -1251,27 +1275,32 @@ async def get_countries_price_by_uuids_fallback(
default_price = 0
total_price += default_price
prices_list.append(default_price)
-
+
return total_price, prices_list
-
+
except Exception as e:
logger.error(f"Ошибка fallback функции: {e}")
default_prices = [0] * len(country_uuids)
return sum(default_prices), default_prices
+
async def handle_manage_country(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
):
logger.info(f"🔍 Управление страной: {callback.data}")
-
- country_uuid = callback.data.split('_')[2]
-
+
+ country_uuid = callback.data.split('_')[2]
+
subscription = db_user.subscription
if not subscription or subscription.is_trial:
- await callback.answer("⚠ Только для платных подписок", show_alert=True)
+ texts = get_texts(db_user.language)
+ await callback.answer(
+ texts.t("PAID_FEATURE_ONLY_SHORT", "⚠ Только для платных подписок"),
+ show_alert=True,
+ )
return
data = await state.get_data()
@@ -1281,7 +1310,14 @@ async def handle_manage_country(
allowed_country_ids = {country['uuid'] for country in countries}
if country_uuid not in allowed_country_ids and country_uuid not in current_selected:
- await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
+ texts = get_texts(db_user.language)
+ await callback.answer(
+ texts.t(
+ "COUNTRY_NOT_AVAILABLE_PROMOGROUP",
+ "❌ Сервер недоступен для вашей промогруппы",
+ ),
+ show_alert=True,
+ )
return
if country_uuid in current_selected:
@@ -1314,21 +1350,21 @@ async def handle_manage_country(
)
)
logger.info(f"✅ Клавиатура обновлена")
-
+
except Exception as e:
logger.error(f"⚠ Ошибка обновления клавиатуры: {e}")
-
+
await callback.answer()
+
async def apply_countries_changes(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
):
-
logger.info(f"🔧 Применение изменений стран")
-
+
data = await state.get_data()
texts = get_texts(db_user.language)
@@ -1339,7 +1375,7 @@ async def apply_countries_changes(
else None
)
subscription = db_user.subscription
-
+
selected_countries = data.get('countries', [])
current_countries = subscription.connected_squads
@@ -1356,7 +1392,10 @@ async def apply_countries_changes(
removed = [c for c in current_countries if c not in selected_countries]
if not added and not removed:
- await callback.answer("⚠️ Изменения не обнаружены", show_alert=True)
+ await callback.answer(
+ texts.t("COUNTRY_CHANGES_NOT_FOUND", "⚠️ Изменения не обнаружены"),
+ show_alert=True,
+ )
return
logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}")
@@ -1418,7 +1457,7 @@ async def apply_countries_changes(
total_cost / 100,
total_discount / 100,
)
-
+
if total_cost > 0 and db_user.balance_kopeks < total_cost:
missing_kopeks = total_cost - db_user.balance_kopeks
required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)"
@@ -1448,17 +1487,20 @@ async def apply_countries_changes(
)
await callback.answer()
return
-
+
try:
if added and total_cost > 0:
success = await subtract_user_balance(
- db, db_user, total_cost,
+ db, db_user, total_cost,
f"Добавление стран: {', '.join(added_names)} на {charged_months} мес"
)
if not success:
- await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
+ await callback.answer(
+ texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"),
+ show_alert=True,
+ )
return
-
+
await create_transaction(
db=db,
user_id=db_user.id,
@@ -1466,28 +1508,29 @@ async def apply_countries_changes(
amount_kopeks=total_cost,
description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес"
)
-
+
if added:
from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
from app.database.crud.subscription import add_subscription_servers
-
+
added_server_ids = await get_server_ids_by_uuids(db, added)
-
+
if added_server_ids:
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
await add_user_to_servers(db, added_server_ids)
-
- logger.info(f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}")
-
+
+ logger.info(
+ f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}")
+
subscription.connected_squads = selected_countries
subscription.updated_at = datetime.utcnow()
await db.commit()
-
+
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
-
+
await db.refresh(subscription)
-
+
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
@@ -1496,69 +1539,107 @@ async def apply_countries_changes(
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}")
-
- success_text = "✅ Страны успешно обновлены!\n\n"
-
+
+ success_text = texts.t(
+ "COUNTRY_CHANGES_SUCCESS_HEADER",
+ "✅ Страны успешно обновлены!\n\n",
+ )
+
if added_names:
- success_text += f"➕ Добавлены страны:\n"
+ success_text += texts.t(
+ "COUNTRY_CHANGES_ADDED_HEADER",
+ "➕ Добавлены страны:\n",
+ )
success_text += "\n".join(f"• {name}" for name in added_names)
if total_cost > 0:
- success_text += f"\n💰 Списано: {texts.format_price(total_cost)} (за {charged_months} мес)"
+ success_text += "\n" + texts.t(
+ "COUNTRY_CHANGES_CHARGED",
+ "💰 Списано: {amount} (за {months} мес)",
+ ).format(
+ amount=texts.format_price(total_cost),
+ months=charged_months,
+ )
if total_discount > 0:
- success_text += (
- f" (скидка {servers_discount_percent}%:"
- f" -{texts.format_price(total_discount)})"
+ success_text += texts.t(
+ "COUNTRY_CHANGES_DISCOUNT_INFO",
+ " (скидка {percent}%: -{amount})",
+ ).format(
+ percent=servers_discount_percent,
+ amount=texts.format_price(total_discount),
)
success_text += "\n"
-
+
if removed_names:
- success_text += f"\n➖ Отключены страны:\n"
+ success_text += "\n" + texts.t(
+ "COUNTRY_CHANGES_REMOVED_HEADER",
+ "➖ Отключены страны:\n",
+ )
success_text += "\n".join(f"• {name}" for name in removed_names)
- success_text += "\nℹ️ Повторное подключение будет платным\n"
-
- success_text += f"\n🌐 Активных стран: {len(selected_countries)}"
-
+ success_text += "\n" + texts.t(
+ "COUNTRY_CHANGES_REMOVED_WARNING",
+ "ℹ️ Повторное подключение будет платным",
+ ) + "\n"
+
+ success_text += "\n" + texts.t(
+ "COUNTRY_CHANGES_ACTIVE_COUNT",
+ "🌐 Активных стран: {count}",
+ ).format(count=len(selected_countries))
+
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML"
)
-
+
await state.clear()
- logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost/100}₽")
-
+ logger.info(
+ f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost / 100}₽")
+
except Exception as e:
logger.error(f"⚠️ Ошибка применения изменений: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
-
+
await callback.answer()
async def handle_add_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
from app.config import settings
-
- if settings.is_traffic_fixed():
- await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть изменен", show_alert=True)
- return
-
+
texts = get_texts(db_user.language)
+
+ if settings.is_traffic_fixed():
+ await callback.answer(
+ texts.t(
+ "TRAFFIC_FIXED_MODE",
+ "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
+ ),
+ show_alert=True,
+ )
+ return
+
subscription = db_user.subscription
-
+
if not subscription or subscription.is_trial:
- await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True)
+ await callback.answer(
+ texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
+ show_alert=True,
+ )
return
-
+
if subscription.traffic_limit_gb == 0:
- await callback.answer("⚠ У вас уже безлимитный трафик", show_alert=True)
+ await callback.answer(
+ texts.t("TRAFFIC_ALREADY_UNLIMITED", "⚠ У вас уже безлимитный трафик"),
+ show_alert=True,
+ )
return
-
+
current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
@@ -1567,10 +1648,17 @@ async def handle_add_traffic(
period_hint_days,
)
+ prompt_text = texts.t(
+ "ADD_TRAFFIC_PROMPT",
+ (
+ "📈 Добавить трафик к подписке\n\n"
+ "Текущий лимит: {current_traffic}\n"
+ "Выберите дополнительный трафик:"
+ ),
+ ).format(current_traffic=texts.format_traffic(current_traffic))
+
await callback.message.edit_text(
- f"📈 Добавить трафик к подписке\n\n"
- f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
- f"Выберите дополнительный трафик:",
+ prompt_text,
reply_markup=get_add_traffic_keyboard(
db_user.language,
subscription.end_date,
@@ -1578,22 +1666,25 @@ async def handle_add_traffic(
),
parse_mode="HTML"
)
-
+
await callback.answer()
-
+
async def handle_change_devices(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
if not subscription or subscription.is_trial:
- await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True)
+ await callback.answer(
+ texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
+ show_alert=True,
+ )
return
-
+
current_devices = subscription.device_limit
period_hint_days = _get_period_hint_from_subscription(subscription)
@@ -1603,13 +1694,20 @@ async def handle_change_devices(
period_hint_days,
)
+ prompt_text = texts.t(
+ "CHANGE_DEVICES_PROMPT",
+ (
+ "📱 Изменение количества устройств\n\n"
+ "Текущий лимит: {current_devices} устройств\n"
+ "Выберите новое количество устройств:\n\n"
+ "💡 Важно:\n"
+ "• При увеличении - доплата пропорционально оставшемуся времени\n"
+ "• При уменьшении - возврат средств не производится"
+ ),
+ ).format(current_devices=current_devices)
+
await callback.message.edit_text(
- f"📱 Изменение количества устройств\n\n"
- f"Текущий лимит: {current_devices} устройств\n"
- f"Выберите новое количество устройств:\n\n"
- f"💡 Важно:\n"
- f"• При увеличении - доплата пропорционально оставшемуся времени\n"
- f"• При уменьшении - возврат средств не производится",
+ prompt_text,
reply_markup=get_change_devices_keyboard(
current_devices,
db_user.language,
@@ -1618,43 +1716,49 @@ async def handle_change_devices(
),
parse_mode="HTML"
)
-
+
await callback.answer()
+
async def confirm_change_devices(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
new_devices_count = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
current_devices = subscription.device_limit
-
+
if new_devices_count == current_devices:
- await callback.answer("ℹ️ Количество устройств не изменилось", show_alert=True)
+ await callback.answer(
+ texts.t("DEVICES_NO_CHANGE", "ℹ️ Количество устройств не изменилось"),
+ show_alert=True,
+ )
return
-
+
if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT:
await callback.answer(
- f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT})",
+ texts.t(
+ "DEVICES_LIMIT_EXCEEDED",
+ "⚠️ Превышен максимальный лимит устройств ({limit})",
+ ).format(limit=settings.MAX_DEVICES_LIMIT),
show_alert=True
)
return
-
+
devices_difference = new_devices_count - current_devices
-
- if devices_difference > 0:
+
+ if devices_difference > 0:
additional_devices = devices_difference
-
+
if current_devices < settings.DEFAULT_DEVICE_LIMIT:
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
chargeable_devices = max(0, additional_devices - free_devices)
else:
chargeable_devices = additional_devices
-
+
devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
@@ -1672,7 +1776,7 @@ async def confirm_change_devices(
subscription.end_date,
)
total_discount = discount_per_month * charged_months
-
+
if price > 0 and db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
required_text = f"{texts.format_price(price)} (за {charged_months} мес)"
@@ -1701,64 +1805,91 @@ async def confirm_change_devices(
)
await callback.answer()
return
-
- action_text = f"увеличить до {new_devices_count}"
+
+ action_text = texts.t(
+ "DEVICE_CHANGE_ACTION_INCREASE",
+ "увеличить до {count}",
+ ).format(count=new_devices_count)
if price > 0:
- cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)"
+ cost_text = texts.t(
+ "DEVICE_CHANGE_EXTRA_COST",
+ "Доплата: {amount} (за {months} мес)",
+ ).format(
+ amount=texts.format_price(price),
+ months=charged_months,
+ )
if total_discount > 0:
- cost_text += (
- f" (скидка {devices_discount_percent}%:"
- f" -{texts.format_price(total_discount)})"
+ cost_text += texts.t(
+ "DEVICE_CHANGE_DISCOUNT_INFO",
+ " (скидка {percent}%: -{amount})",
+ ).format(
+ percent=devices_discount_percent,
+ amount=texts.format_price(total_discount),
)
else:
- cost_text = "Бесплатно"
-
- else:
+ cost_text = texts.t("DEVICE_CHANGE_FREE", "Бесплатно")
+
+ else:
price = 0
- action_text = f"уменьшить до {new_devices_count}"
- cost_text = "Возврат средств не производится"
-
- confirm_text = f"📱 Подтверждение изменения\n\n"
- confirm_text += f"Текущее количество: {current_devices} устройств\n"
- confirm_text += f"Новое количество: {new_devices_count} устройств\n\n"
- confirm_text += f"Действие: {action_text}\n"
- confirm_text += f"💰 {cost_text}\n\n"
- confirm_text += "Подтвердить изменение?"
-
+ action_text = texts.t(
+ "DEVICE_CHANGE_ACTION_DECREASE",
+ "уменьшить до {count}",
+ ).format(count=new_devices_count)
+ cost_text = texts.t("DEVICE_CHANGE_NO_REFUND", "Возврат средств не производится")
+
+ confirm_text = texts.t(
+ "DEVICE_CHANGE_CONFIRMATION",
+ (
+ "📱 Подтверждение изменения\n\n"
+ "Текущее количество: {current} устройств\n"
+ "Новое количество: {new} устройств\n\n"
+ "Действие: {action}\n"
+ "💰 {cost}\n\n"
+ "Подтвердить изменение?"
+ ),
+ ).format(
+ current=current_devices,
+ new=new_devices_count,
+ action=action_text,
+ cost=cost_text,
+ )
+
await callback.message.edit_text(
confirm_text,
reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language),
parse_mode="HTML"
)
-
+
await callback.answer()
async def execute_change_devices(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
callback_parts = callback.data.split('_')
new_devices_count = int(callback_parts[3])
price = int(callback_parts[4])
-
+
texts = get_texts(db_user.language)
subscription = db_user.subscription
current_devices = subscription.device_limit
-
+
try:
if price > 0:
success = await subtract_user_balance(
db, db_user, price,
f"Изменение количества устройств с {current_devices} до {new_devices_count}"
)
-
+
if not success:
- await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
+ await callback.answer(
+ texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"),
+ show_alert=True,
+ )
return
-
+
charged_months = get_remaining_months(subscription.end_date)
await create_transaction(
db=db,
@@ -1767,18 +1898,18 @@ async def execute_change_devices(
amount_kopeks=price,
description=f"Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес"
)
-
+
subscription.device_limit = new_devices_count
subscription.updated_at = datetime.utcnow()
-
+
await db.commit()
-
+
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
-
+
await db.refresh(db_user)
await db.refresh(subscription)
-
+
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
@@ -1787,119 +1918,170 @@ async def execute_change_devices(
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении устройств: {e}")
-
+
if new_devices_count > current_devices:
- success_text = f"✅ Количество устройств увеличено!\n\n"
- success_text += f"📱 Было: {current_devices} → Стало: {new_devices_count}\n"
+ success_text = texts.t(
+ "DEVICE_CHANGE_INCREASE_SUCCESS",
+ "✅ Количество устройств увеличено!\n\n",
+ )
+ success_text += texts.t(
+ "DEVICE_CHANGE_RESULT_LINE",
+ "📱 Было: {old} → Стало: {new}\n",
+ ).format(old=current_devices, new=new_devices_count)
if price > 0:
- success_text += f"💰 Списано: {texts.format_price(price)}"
+ success_text += texts.t(
+ "DEVICE_CHANGE_CHARGED",
+ "💰 Списано: {amount}",
+ ).format(amount=texts.format_price(price))
else:
- success_text = f"✅ Количество устройств уменьшено!\n\n"
- success_text += f"📱 Было: {current_devices} → Стало: {new_devices_count}\n"
- success_text += f"ℹ️ Возврат средств не производится"
-
+ success_text = texts.t(
+ "DEVICE_CHANGE_DECREASE_SUCCESS",
+ "✅ Количество устройств уменьшено!\n\n",
+ )
+ success_text += texts.t(
+ "DEVICE_CHANGE_RESULT_LINE",
+ "📱 Было: {old} → Стало: {new}\n",
+ ).format(old=current_devices, new=new_devices_count)
+ success_text += texts.t(
+ "DEVICE_CHANGE_NO_REFUND_INFO",
+ "ℹ️ Возврат средств не производится",
+ )
+
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
-
- logger.info(f"✅ Пользователь {db_user.telegram_id} изменил количество устройств с {current_devices} на {new_devices_count}, доплата: {price/100}₽")
-
+
+ logger.info(
+ f"✅ Пользователь {db_user.telegram_id} изменил количество устройств с {current_devices} на {new_devices_count}, доплата: {price / 100}₽")
+
except Exception as e:
logger.error(f"Ошибка изменения количества устройств: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
-
+
await callback.answer()
+
async def handle_device_management(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
if not subscription or subscription.is_trial:
- await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True)
+ await callback.answer(
+ texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
+ show_alert=True,
+ )
return
-
+
if not db_user.remnawave_uuid:
- await callback.answer("❌ UUID пользователя не найден", show_alert=True)
+ await callback.answer(
+ texts.t("DEVICE_UUID_NOT_FOUND", "❌ UUID пользователя не найден"),
+ show_alert=True,
+ )
return
-
+
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
-
+
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
+
if response and 'response' in response:
devices_info = response['response']
total_devices = devices_info.get('total', 0)
devices_list = devices_info.get('devices', [])
-
+
if total_devices == 0:
await callback.message.edit_text(
- "ℹ️ У вас нет подключенных устройств",
+ texts.t("DEVICE_NONE_CONNECTED", "ℹ️ У вас нет подключенных устройств"),
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
-
+
await show_devices_page(callback, db_user, devices_list, page=1)
else:
- await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True)
-
+ await callback.answer(
+ texts.t(
+ "DEVICE_FETCH_INFO_ERROR",
+ "❌ Ошибка получения информации об устройствах",
+ ),
+ show_alert=True,
+ )
+
except Exception as e:
logger.error(f"Ошибка получения списка устройств: {e}")
- await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True)
-
+ await callback.answer(
+ texts.t(
+ "DEVICE_FETCH_INFO_ERROR",
+ "❌ Ошибка получения информации об устройствах",
+ ),
+ show_alert=True,
+ )
+
await callback.answer()
async def show_devices_page(
- callback: types.CallbackQuery,
- db_user: User,
- devices_list: List[dict],
- page: int = 1
+ callback: types.CallbackQuery,
+ db_user: User,
+ devices_list: List[dict],
+ page: int = 1
):
-
-
texts = get_texts(db_user.language)
devices_per_page = 5
-
+
pagination = paginate_list(devices_list, page=page, per_page=devices_per_page)
-
- devices_text = f"🔄 Управление устройствами\n\n"
- devices_text += f"📊 Всего подключено: {len(devices_list)} устройств\n"
- devices_text += f"📄 Страница {pagination.page} из {pagination.total_pages}\n\n"
-
+
+ devices_text = texts.t(
+ "DEVICE_MANAGEMENT_OVERVIEW",
+ (
+ "🔄 Управление устройствами\n\n"
+ "📊 Всего подключено: {total} устройств\n"
+ "📄 Страница {page} из {pages}\n\n"
+ ),
+ ).format(total=len(devices_list), page=pagination.page, pages=pagination.total_pages)
+
if pagination.items:
- devices_text += "Подключенные устройства:\n"
+ devices_text += texts.t(
+ "DEVICE_MANAGEMENT_CONNECTED_HEADER",
+ "Подключенные устройства:\n",
+ )
for i, device in enumerate(pagination.items, 1):
platform = device.get('platform', 'Unknown')
device_model = device.get('deviceModel', 'Unknown')
device_info = f"{platform} - {device_model}"
-
+
if len(device_info) > 35:
device_info = device_info[:32] + "..."
-
- devices_text += f"• {device_info}\n"
-
- devices_text += "\n💡 Действия:\n"
- devices_text += "• Выберите устройство для сброса\n"
- devices_text += "• Или сбросьте все устройства сразу"
-
+
+ devices_text += texts.t(
+ "DEVICE_MANAGEMENT_LIST_ITEM",
+ "• {device}\n",
+ ).format(device=device_info)
+
+ devices_text += texts.t(
+ "DEVICE_MANAGEMENT_ACTIONS",
+ (
+ "\n💡 Действия:\n"
+ "• Выберите устройство для сброса\n"
+ "• Или сбросьте все устройства сразу"
+ ),
+ )
+
await callback.message.edit_text(
devices_text,
reply_markup=get_devices_management_keyboard(
- pagination.items,
- pagination,
+ pagination.items,
+ pagination,
db_user.language
),
parse_mode="HTML"
@@ -1907,150 +2089,197 @@ async def show_devices_page(
async def handle_devices_page(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
page = int(callback.data.split('_')[2])
-
+ texts = get_texts(db_user.language)
+
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
-
+
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
+
if response and 'response' in response:
devices_list = response['response'].get('devices', [])
await show_devices_page(callback, db_user, devices_list, page=page)
else:
- await callback.answer("❌ Ошибка получения устройств", show_alert=True)
-
+ await callback.answer(
+ texts.t("DEVICE_FETCH_ERROR", "❌ Ошибка получения устройств"),
+ show_alert=True,
+ )
+
except Exception as e:
logger.error(f"Ошибка перехода на страницу устройств: {e}")
- await callback.answer("❌ Ошибка загрузки страницы", show_alert=True)
+ await callback.answer(
+ texts.t("DEVICE_PAGE_LOAD_ERROR", "❌ Ошибка загрузки страницы"),
+ show_alert=True,
+ )
async def handle_single_device_reset(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
try:
callback_parts = callback.data.split('_')
if len(callback_parts) < 4:
logger.error(f"Некорректный формат callback_data: {callback.data}")
- await callback.answer("❌ Ошибка: некорректный запрос", show_alert=True)
+ await callback.answer(
+ texts.t("DEVICE_RESET_INVALID_REQUEST", "❌ Ошибка: некорректный запрос"),
+ show_alert=True,
+ )
return
-
+
device_index = int(callback_parts[2])
page = int(callback_parts[3])
-
+
logger.info(f"🔧 Сброс устройства: index={device_index}, page={page}")
-
+
except (ValueError, IndexError) as e:
logger.error(f"❌ Ошибка парсинга callback_data {callback.data}: {e}")
- await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
+ await callback.answer(
+ texts.t("DEVICE_RESET_PARSE_ERROR", "❌ Ошибка обработки запроса"),
+ show_alert=True,
+ )
return
-
+
texts = get_texts(db_user.language)
-
+
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
-
+
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
+
if response and 'response' in response:
devices_list = response['response'].get('devices', [])
-
+
devices_per_page = 5
pagination = paginate_list(devices_list, page=page, per_page=devices_per_page)
-
+
if device_index < len(pagination.items):
device = pagination.items[device_index]
device_hwid = device.get('hwid')
-
+
if device_hwid:
delete_data = {
"userUuid": db_user.remnawave_uuid,
"hwid": device_hwid
}
-
+
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
-
+
platform = device.get('platform', 'Unknown')
device_model = device.get('deviceModel', 'Unknown')
device_info = f"{platform} - {device_model}"
-
- await callback.answer(f"✅ Устройство {device_info} успешно сброшено!", show_alert=True)
-
+
+ await callback.answer(
+ texts.t(
+ "DEVICE_RESET_SUCCESS",
+ "✅ Устройство {device} успешно сброшено!",
+ ).format(device=device_info),
+ show_alert=True,
+ )
+
updated_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
if updated_response and 'response' in updated_response:
updated_devices = updated_response['response'].get('devices', [])
-
+
if updated_devices:
- updated_pagination = paginate_list(updated_devices, page=page, per_page=devices_per_page)
+ updated_pagination = paginate_list(updated_devices, page=page,
+ per_page=devices_per_page)
if not updated_pagination.items and page > 1:
page = page - 1
-
+
await show_devices_page(callback, db_user, updated_devices, page=page)
else:
await callback.message.edit_text(
- "ℹ️ Все устройства сброшены",
+ texts.t(
+ "DEVICE_RESET_ALL_DONE",
+ "ℹ️ Все устройства сброшены",
+ ),
reply_markup=get_back_keyboard(db_user.language)
)
-
+
logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил устройство {device_info}")
else:
- await callback.answer("❌ Не удалось получить ID устройства", show_alert=True)
+ await callback.answer(
+ texts.t(
+ "DEVICE_RESET_ID_FAILED",
+ "❌ Не удалось получить ID устройства",
+ ),
+ show_alert=True,
+ )
else:
- await callback.answer("❌ Устройство не найдено", show_alert=True)
+ await callback.answer(
+ texts.t("DEVICE_RESET_NOT_FOUND", "❌ Устройство не найдено"),
+ show_alert=True,
+ )
else:
- await callback.answer("❌ Ошибка получения устройств", show_alert=True)
-
+ await callback.answer(
+ texts.t("DEVICE_FETCH_ERROR", "❌ Ошибка получения устройств"),
+ show_alert=True,
+ )
+
except Exception as e:
logger.error(f"Ошибка сброса устройства: {e}")
- await callback.answer("❌ Ошибка сброса устройства", show_alert=True)
+ await callback.answer(
+ texts.t("DEVICE_RESET_ERROR", "❌ Ошибка сброса устройства"),
+ show_alert=True,
+ )
async def handle_all_devices_reset_from_management(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
texts = get_texts(db_user.language)
-
+
if not db_user.remnawave_uuid:
- await callback.answer("❌ UUID пользователя не найден", show_alert=True)
+ await callback.answer(
+ texts.t("DEVICE_UUID_NOT_FOUND", "❌ UUID пользователя не найден"),
+ show_alert=True,
+ )
return
-
+
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
-
+
async with service.get_api_client() as api:
devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
+
if not devices_response or 'response' not in devices_response:
- await callback.answer("❌ Ошибка получения списка устройств", show_alert=True)
+ await callback.answer(
+ texts.t(
+ "DEVICE_LIST_FETCH_ERROR",
+ "❌ Ошибка получения списка устройств",
+ ),
+ show_alert=True,
+ )
return
-
+
devices_list = devices_response['response'].get('devices', [])
-
+
if not devices_list:
- await callback.answer("ℹ️ У вас нет подключенных устройств", show_alert=True)
+ await callback.answer(
+ texts.t("DEVICE_NONE_CONNECTED", "ℹ️ У вас нет подключенных устройств"),
+ show_alert=True,
+ )
return
-
+
logger.info(f"🔧 Найдено {len(devices_list)} устройств для сброса")
-
+
success_count = 0
failed_count = 0
-
+
for device in devices_list:
device_hwid = device.get('hwid')
if device_hwid:
@@ -2059,80 +2288,96 @@ async def handle_all_devices_reset_from_management(
"userUuid": db_user.remnawave_uuid,
"hwid": device_hwid
}
-
+
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
success_count += 1
logger.info(f"✅ Устройство {device_hwid} удалено")
-
+
except Exception as device_error:
failed_count += 1
logger.error(f"❌ Ошибка удаления устройства {device_hwid}: {device_error}")
else:
failed_count += 1
logger.warning(f"⚠️ У устройства нет HWID: {device}")
-
+
if success_count > 0:
if failed_count == 0:
await callback.message.edit_text(
- f"✅ Все устройства успешно сброшены!\n\n"
- f"🔄 Сброшено: {success_count} устройств\n"
- f"📱 Теперь вы можете заново подключить свои устройства\n\n"
- f"💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения",
+ texts.t(
+ "DEVICE_RESET_ALL_SUCCESS_MESSAGE",
+ (
+ "✅ Все устройства успешно сброшены!\n\n"
+ "🔄 Сброшено: {count} устройств\n"
+ "📱 Теперь вы можете заново подключить свои устройства\n\n"
+ "💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения"
+ ),
+ ).format(count=success_count),
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML"
)
logger.info(f"✅ Пользователь {db_user.telegram_id} успешно сбросил {success_count} устройств")
else:
await callback.message.edit_text(
- f"⚠️ Частичный сброс устройств\n\n"
- f"✅ Удалено: {success_count} устройств\n"
- f"❌ Не удалось удалить: {failed_count} устройств\n\n"
- f"Попробуйте еще раз или обратитесь в поддержку.",
+ texts.t(
+ "DEVICE_RESET_PARTIAL_MESSAGE",
+ (
+ "⚠️ Частичный сброс устройств\n\n"
+ "✅ Удалено: {success} устройств\n"
+ "❌ Не удалось удалить: {failed} устройств\n\n"
+ "Попробуйте еще раз или обратитесь в поддержку."
+ ),
+ ).format(success=success_count, failed=failed_count),
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML"
)
- logger.warning(f"⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}")
+ logger.warning(
+ f"⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}")
else:
await callback.message.edit_text(
- f"❌ Не удалось сбросить устройства\n\n"
- f"Попробуйте еще раз позже или обратитесь в техподдержку.\n\n"
- f"Всего устройств: {len(devices_list)}",
+ texts.t(
+ "DEVICE_RESET_ALL_FAILED_MESSAGE",
+ (
+ "❌ Не удалось сбросить устройства\n\n"
+ "Попробуйте еще раз позже или обратитесь в техподдержку.\n\n"
+ "Всего устройств: {total}"
+ ),
+ ).format(total=len(devices_list)),
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML"
)
logger.error(f"❌ Не удалось сбросить ни одного устройства у пользователя {db_user.telegram_id}")
-
+
except Exception as e:
logger.error(f"Ошибка сброса всех устройств: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
-
+
await callback.answer()
async def handle_extend_subscription(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
if not subscription or subscription.is_trial:
await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True)
return
-
+
subscription_service = SubscriptionService()
-
+
available_periods = settings.get_available_renewal_periods()
renewal_prices = {}
-
+
for days in available_periods:
try:
months_in_period = calculate_months_from_days(days)
-
+
from app.config import PERIOD_PRICES
from app.utils.pricing_utils import apply_percentage_discount
@@ -2142,7 +2387,7 @@ async def handle_extend_subscription(
base_price_original,
period_discount_percent,
)
-
+
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads,
db,
@@ -2174,15 +2419,15 @@ async def handle_extend_subscription(
price = base_price + total_servers_price + total_devices_price + total_traffic_price
renewal_prices[days] = price
-
+
except Exception as e:
logger.error(f"Ошибка расчета цены для периода {days}: {e}")
continue
-
+
if not renewal_prices:
await callback.answer("⚠ Нет доступных периодов для продления", show_alert=True)
return
-
+
prices_text = ""
for days in available_periods:
@@ -2217,38 +2462,38 @@ async def handle_extend_subscription(
reply_markup=get_extend_subscription_keyboard_with_prices(db_user.language, renewal_prices),
parse_mode="HTML"
)
-
+
await callback.answer()
async def handle_reset_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
from app.config import settings
-
+
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True)
return
-
+
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
if not subscription or subscription.is_trial:
await callback.answer("⌛ Эта функция доступна только для платных подписок", show_alert=True)
return
-
+
if subscription.traffic_limit_gb == 0:
await callback.answer("⌛ У вас безлимитный трафик", show_alert=True)
return
-
+
reset_price = PERIOD_PRICES[30]
-
+
if db_user.balance_kopeks < reset_price:
await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
return
-
+
await callback.message.edit_text(
f"🔄 Сброс трафика\n\n"
f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n"
@@ -2257,7 +2502,7 @@ async def handle_reset_traffic(
"После сброса счетчик использованного трафика станет равным 0.",
reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language)
)
-
+
await callback.answer()
@@ -2268,19 +2513,18 @@ def update_traffic_prices():
async def confirm_add_devices(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
devices_count = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
resume_callback = None
-
+
new_total_devices = subscription.device_limit + devices_count
-
+
if settings.MAX_DEVICES_LIMIT > 0 and new_total_devices > settings.MAX_DEVICES_LIMIT:
await callback.answer(
f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT}). "
@@ -2288,7 +2532,7 @@ async def confirm_add_devices(
show_alert=True
)
return
-
+
devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
@@ -2315,7 +2559,7 @@ async def confirm_add_devices(
price / 100,
total_discount / 100,
)
-
+
if db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
required_text = f"{texts.format_price(price)} (за {charged_months} мес)"
@@ -2345,22 +2589,22 @@ async def confirm_add_devices(
)
await callback.answer()
return
-
+
try:
success = await subtract_user_balance(
db, db_user, price,
f"Добавление {devices_count} устройств на {charged_months} мес"
)
-
+
if not success:
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
return
-
+
await add_subscription_devices(db, subscription, devices_count)
-
+
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
-
+
await create_transaction(
db=db,
user_id=db_user.id,
@@ -2368,11 +2612,10 @@ async def confirm_add_devices(
amount_kopeks=price,
description=f"Добавление {devices_count} устройств на {charged_months} мес"
)
-
-
+
await db.refresh(db_user)
await db.refresh(subscription)
-
+
success_text = (
"✅ Устройства успешно добавлены!\n\n"
f"📱 Добавлено: {devices_count} устройств\n"
@@ -2389,23 +2632,23 @@ async def confirm_add_devices(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
-
- logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price/100}₽")
-
+
+ logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price / 100}₽")
+
except Exception as e:
logger.error(f"Ошибка добавления устройств: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
-
+
await callback.answer()
async def confirm_extend_subscription(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
from app.services.admin_notification_service import AdminNotificationService
@@ -2453,7 +2696,7 @@ async def confirm_extend_subscription(
server_uuid_prices[squad_uuid] = discounted_per_month * months_in_period
discounted_servers_price_per_month = servers_price_per_month - (
- servers_price_per_month * servers_discount_percent // 100
+ servers_price_per_month * servers_discount_percent // 100
)
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
@@ -2478,9 +2721,9 @@ async def confirm_extend_subscription(
price = base_price + total_servers_price + total_devices_price + total_traffic_price
monthly_additions = (
- discounted_servers_price_per_month
- + discounted_devices_price_per_month
- + discounted_traffic_price_per_month
+ discounted_servers_price_per_month
+ + discounted_devices_price_per_month
+ + discounted_traffic_price_per_month
)
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price)
@@ -2490,47 +2733,47 @@ async def confirm_extend_subscription(
return
logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):")
- base_log = f" 📅 Период {days} дней: {base_price_original/100}₽"
+ base_log = f" 📅 Период {days} дней: {base_price_original / 100}₽"
if base_discount_total > 0:
base_log += (
- f" → {base_price/100}₽"
- f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
+ f" → {base_price / 100}₽"
+ f" (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)"
)
logger.info(base_log)
if total_servers_price > 0:
logger.info(
- f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period}"
- f" = {total_servers_price/100}₽"
+ f" 🌐 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period}"
+ f" = {total_servers_price / 100}₽"
+ (
f" (скидка {servers_discount_percent}%:"
- f" -{total_servers_discount/100}₽)"
+ f" -{total_servers_discount / 100}₽)"
if total_servers_discount > 0
else ""
)
)
if total_devices_price > 0:
logger.info(
- f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}"
- f" = {total_devices_price/100}₽"
+ f" 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}"
+ f" = {total_devices_price / 100}₽"
+ (
f" (скидка {devices_discount_percent}%:"
- f" -{devices_discount_per_month * months_in_period/100}₽)"
+ f" -{devices_discount_per_month * months_in_period / 100}₽)"
if devices_discount_percent > 0 and devices_discount_per_month > 0
else ""
)
)
if total_traffic_price > 0:
logger.info(
- f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}"
- f" = {total_traffic_price/100}₽"
+ f" 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}"
+ f" = {total_traffic_price / 100}₽"
+ (
f" (скидка {traffic_discount_percent}%:"
- f" -{traffic_discount_per_month * months_in_period/100}₽)"
+ f" -{traffic_discount_per_month * months_in_period / 100}₽)"
if traffic_discount_percent > 0 and traffic_discount_per_month > 0
else ""
)
)
- logger.info(f" 💎 ИТОГО: {price/100}₽")
+ logger.info(f" 💎 ИТОГО: {price / 100}₽")
except Exception as e:
logger.error(f"⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}")
@@ -2665,7 +2908,7 @@ async def confirm_extend_subscription(
reply_markup=get_back_keyboard(db_user.language)
)
- logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price/100}₽")
+ logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price / 100}₽")
except Exception as e:
logger.error(f"⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}")
@@ -2681,19 +2924,19 @@ async def confirm_extend_subscription(
async def confirm_reset_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
from app.config import settings
-
+
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
-
+
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
reset_price = PERIOD_PRICES[30]
if db_user.balance_kopeks < reset_price:
@@ -2723,29 +2966,29 @@ async def confirm_reset_traffic(
)
await callback.answer()
return
-
+
try:
success = await subtract_user_balance(
db, db_user, reset_price,
"Сброс трафика"
)
-
+
if not success:
await callback.answer("⌛ Ошибка списания средств", show_alert=True)
return
-
+
subscription.traffic_used_gb = 0.0
subscription.updated_at = datetime.utcnow()
await db.commit()
-
+
subscription_service = SubscriptionService()
remnawave_service = RemnaWaveService()
-
+
user = db_user
if user.remnawave_uuid:
async with remnawave_service.get_api_client() as api:
await api.reset_user_traffic(user.remnawave_uuid)
-
+
await create_transaction(
db=db,
user_id=db_user.id,
@@ -2753,56 +2996,55 @@ async def confirm_reset_traffic(
amount_kopeks=reset_price,
description="Сброс трафика"
)
-
+
await db.refresh(db_user)
await db.refresh(subscription)
-
+
await callback.message.edit_text(
f"✅ Трафик успешно сброшен!\n\n"
f"🔄 Использованный трафик обнулен\n"
f"📊 Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}",
reply_markup=get_back_keyboard(db_user.language)
)
-
+
logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил трафик")
-
+
except Exception as e:
logger.error(f"Ошибка сброса трафика: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
-
+
await callback.answer()
-
async def select_period(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User
):
period_days = int(callback.data.split('_')[1])
texts = get_texts(db_user.language)
-
+
data = await state.get_data()
data['period_days'] = period_days
data['total_price'] = PERIOD_PRICES[period_days]
-
+
if settings.is_traffic_fixed():
fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
data['total_price'] += fixed_traffic_price
data['traffic_gb'] = settings.get_fixed_traffic_limit()
-
+
await state.set_data(data)
-
+
if settings.is_traffic_selectable():
available_packages = [pkg for pkg in settings.get_traffic_packages() if pkg['enabled']]
-
+
if not available_packages:
await callback.answer("⚠️ Пакеты трафика не настроены", show_alert=True)
return
-
+
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
@@ -2821,7 +3063,7 @@ async def select_period(
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
-
+
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
@@ -2829,67 +3071,69 @@ async def select_period(
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
-
+
await callback.answer()
+
async def refresh_traffic_config():
try:
from app.config import refresh_traffic_prices
refresh_traffic_prices()
-
+
packages = settings.get_traffic_packages()
enabled_count = sum(1 for pkg in packages if pkg['enabled'])
-
+
logger.info(f"🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов")
for pkg in packages:
if pkg['enabled']:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
- logger.info(f" 📦 {gb_text}: {pkg['price']/100}₽")
-
+ logger.info(f" 📦 {gb_text}: {pkg['price'] / 100}₽")
+
return True
-
+
except Exception as e:
logger.error(f"⚠️ Ошибка обновления конфигурации трафика: {e}")
return False
+
async def get_traffic_packages_info() -> str:
try:
packages = settings.get_traffic_packages()
-
+
info_lines = ["📦 Настроенные пакеты трафика:"]
-
+
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
disabled_packages = [pkg for pkg in packages if not pkg['enabled']]
-
+
if enabled_packages:
info_lines.append("\n✅ Активные:")
for pkg in enabled_packages:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
- info_lines.append(f" • {gb_text}: {pkg['price']//100}₽")
-
+ info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽")
+
if disabled_packages:
info_lines.append("\n❌ Отключенные:")
for pkg in disabled_packages:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
- info_lines.append(f" • {gb_text}: {pkg['price']//100}₽")
-
+ info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽")
+
info_lines.append(f"\n📊 Всего пакетов: {len(packages)}")
info_lines.append(f"🟢 Активных: {len(enabled_packages)}")
info_lines.append(f"🔴 Отключенных: {len(disabled_packages)}")
-
+
return "\n".join(info_lines)
-
+
except Exception as e:
return f"⚠️ Ошибка получения информации: {e}"
+
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
-
devices_used = await get_current_devices_count(db_user)
countries_info = await _get_countries_info(subscription.connected_squads)
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
-
+
subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..."
-
+
if subscription.is_trial:
status_text = "🎁 Тестовая"
type_text = "Триал"
@@ -2899,7 +3143,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
else:
status_text = "⌛ Истекла"
type_text = "Платная подписка"
-
+
if subscription.traffic_limit_gb == 0:
if settings.is_traffic_fixed():
traffic_text = "∞ Безлимитный"
@@ -2910,9 +3154,9 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
else:
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
-
+
subscription_cost = await get_subscription_cost(subscription, db)
-
+
info_text = texts.SUBSCRIPTION_INFO.format(
status=status_text,
type=type_text,
@@ -2925,23 +3169,24 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
devices_limit=subscription.device_limit,
autopay_status="✅ Включен" if subscription.autopay_enabled else "⌛ Выключен"
)
-
+
if subscription_cost > 0:
info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}"
-
+
if (
- subscription_url
- and subscription_url != "Генерируется..."
- and not settings.should_hide_subscription_link()
+ subscription_url
+ and subscription_url != "Генерируется..."
+ and not settings.should_hide_subscription_link()
):
info_text += f"\n\n🔗 Ваша ссылка для импорта в VPN приложениe:\n{subscription_url}"
return info_text
+
def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str:
if is_fixed_mode is None:
is_fixed_mode = settings.is_traffic_fixed()
-
+
if traffic_gb == 0:
if is_fixed_mode:
return "Безлимитный"
@@ -2953,22 +3198,23 @@ def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str:
else:
return f"{traffic_gb} ГБ"
+
async def select_traffic(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User
):
traffic_gb = int(callback.data.split('_')[1])
texts = get_texts(db_user.language)
-
+
data = await state.get_data()
data['traffic_gb'] = traffic_gb
-
+
traffic_price = settings.get_traffic_price(traffic_gb)
data['total_price'] += traffic_price
-
+
await state.set_data(data)
-
+
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
await callback.message.edit_text(
@@ -2981,7 +3227,7 @@ async def select_traffic(
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
-
+
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
@@ -2989,32 +3235,32 @@ async def select_traffic(
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
-
+
await callback.answer()
async def select_country(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
):
country_uuid = callback.data.split('_')[1]
data = await state.get_data()
-
+
selected_countries = data.get('countries', [])
if country_uuid in selected_countries:
selected_countries.remove(country_uuid)
else:
selected_countries.append(country_uuid)
-
+
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
return
-
+
period_base_price = PERIOD_PRICES[data['period_days']]
discounted_base_price, _ = apply_percentage_discount(
@@ -3023,7 +3269,7 @@ async def select_country(
)
base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb'])
-
+
try:
subscription_service = SubscriptionService()
countries_price, _ = await subscription_service.get_countries_price_by_uuids(
@@ -3038,11 +3284,11 @@ async def select_country(
db,
promo_group_id=db_user.promo_group_id,
)
-
+
data['countries'] = selected_countries
data['total_price'] = base_price + countries_price
await state.set_data(data)
-
+
await callback.message.edit_reply_markup(
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
)
@@ -3050,73 +3296,73 @@ async def select_country(
async def countries_continue(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User
):
-
data = await state.get_data()
texts = get_texts(db_user.language)
-
+
if not data.get('countries'):
await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
return
-
+
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
-
+
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
async def select_devices(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User
):
if not callback.data.startswith("devices_") or callback.data == "devices_continue":
await callback.answer("❌ Некорректный запрос", show_alert=True)
return
-
+
try:
devices = int(callback.data.split('_')[1])
except (ValueError, IndexError):
await callback.answer("❌ Некорректное количество устройств", show_alert=True)
return
-
+
data = await state.get_data()
-
+
base_price = (
- PERIOD_PRICES[data['period_days']] +
- settings.get_traffic_price(data['traffic_gb'])
+ PERIOD_PRICES[data['period_days']] +
+ settings.get_traffic_price(data['traffic_gb'])
)
-
+
countries = await _get_available_countries(db_user.promo_group_id)
countries_price = sum(
- c['price_kopeks'] for c in countries
+ c['price_kopeks'] for c in countries
if c['uuid'] in data['countries']
)
-
+
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
-
+
data['devices'] = devices
data['total_price'] = base_price + countries_price + devices_price
await state.set_data(data)
-
+
await callback.message.edit_reply_markup(
reply_markup=get_devices_keyboard(devices, db_user.language)
)
await callback.answer()
+
async def devices_continue(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
):
if not callback.data == "devices_continue":
await callback.answer("⚠️ Некорректный запрос", show_alert=True)
@@ -3146,13 +3392,13 @@ async def devices_continue(
async def confirm_purchase(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
):
from app.services.admin_notification_service import AdminNotificationService
-
+
data = await state.get_data()
texts = get_texts(db_user.language)
@@ -3164,7 +3410,7 @@ async def confirm_purchase(
)
countries = await _get_available_countries(db_user.promo_group_id)
-
+
months_in_period = data.get(
'months_in_period', calculate_months_from_days(data['period_days'])
)
@@ -3318,55 +3564,55 @@ async def confirm_purchase(
months_in_period,
final_price,
)
-
+
if not is_valid:
logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
return
-
+
logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):")
- base_log = f" Период: {base_price_original/100}₽"
+ base_log = f" Период: {base_price_original / 100}₽"
if base_discount_total and base_discount_total > 0:
base_log += (
- f" → {base_price/100}₽"
- f" (скидка {base_discount_percent}%: -{base_discount_total/100}₽)"
+ f" → {base_price / 100}₽"
+ f" (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)"
)
logger.info(base_log)
if total_traffic_price > 0:
message = (
- f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}"
- f" = {total_traffic_price/100}₽"
+ f" Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}"
+ f" = {total_traffic_price / 100}₽"
)
if traffic_discount_total > 0:
message += (
f" (скидка {traffic_discount_percent}%:"
- f" -{traffic_discount_total/100}₽)"
+ f" -{traffic_discount_total / 100}₽)"
)
logger.info(message)
if total_servers_price > 0:
message = (
- f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period}"
- f" = {total_servers_price/100}₽"
+ f" Серверы: {countries_price_per_month / 100}₽/мес × {months_in_period}"
+ f" = {total_servers_price / 100}₽"
)
if total_servers_discount > 0:
message += (
f" (скидка {servers_discount_percent}%:"
- f" -{total_servers_discount/100}₽)"
+ f" -{total_servers_discount / 100}₽)"
)
logger.info(message)
if total_devices_price > 0:
message = (
- f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}"
- f" = {total_devices_price/100}₽"
+ f" Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}"
+ f" = {total_devices_price / 100}₽"
)
if devices_discount_total > 0:
message += (
f" (скидка {devices_discount_percent}%:"
- f" -{devices_discount_total/100}₽)"
+ f" -{devices_discount_total / 100}₽)"
)
logger.info(message)
- logger.info(f" ИТОГО: {final_price/100}₽")
-
+ logger.info(f" ИТОГО: {final_price / 100}₽")
+
if db_user.balance_kopeks < final_price:
missing_kopeks = final_price - db_user.balance_kopeks
message_text = texts.t(
@@ -3395,7 +3641,7 @@ async def confirm_purchase(
)
await callback.answer()
return
-
+
purchase_completed = False
try:
@@ -3403,7 +3649,7 @@ async def confirm_purchase(
db, db_user, final_price,
f"Покупка подписки на {data['period_days']} дней"
)
-
+
if not success:
missing_kopeks = final_price - db_user.balance_kopeks
message_text = texts.t(
@@ -3432,7 +3678,7 @@ async def confirm_purchase(
)
await callback.answer()
return
-
+
existing_subscription = db_user.subscription
was_trial_conversion = False
current_time = datetime.utcnow()
@@ -3468,10 +3714,11 @@ async def confirm_purchase(
first_payment_amount_kopeks=final_price,
first_paid_period_days=data['period_days']
)
- logger.info(f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price/100}₽")
+ logger.info(
+ f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price / 100}₽")
except Exception as conversion_error:
logger.error(f"Ошибка записи конверсии: {conversion_error}")
-
+
existing_subscription.is_trial = False
existing_subscription.status = SubscriptionStatus.ACTIVE.value
existing_subscription.traffic_limit_gb = final_traffic_gb
@@ -3487,7 +3734,7 @@ async def confirm_purchase(
await db.commit()
await db.refresh(existing_subscription)
subscription = existing_subscription
-
+
else:
logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}")
subscription = await create_paid_subscription_with_traffic_mode(
@@ -3498,25 +3745,25 @@ async def confirm_purchase(
connected_squads=data['countries'],
traffic_gb=final_traffic_gb
)
-
+
from app.utils.user_utils import mark_user_as_had_paid_subscription
await mark_user_as_had_paid_subscription(db, db_user)
-
+
from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
from app.database.crud.subscription import add_subscription_servers
-
+
server_ids = await get_server_ids_by_uuids(db, data['countries'])
-
+
if server_ids:
await add_subscription_servers(db, subscription, server_ids, server_prices)
await add_user_to_servers(db, server_ids)
-
+
logger.info(f"Сохранены цены серверов за весь период: {server_prices}")
-
+
await db.refresh(db_user)
-
+
subscription_service = SubscriptionService()
-
+
if db_user.remnawave_uuid:
remnawave_user = await subscription_service.update_remnawave_user(
db,
@@ -3531,7 +3778,7 @@ async def confirm_purchase(
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="покупка подписки",
)
-
+
if not remnawave_user:
logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
remnawave_user = await subscription_service.create_remnawave_user(
@@ -3540,7 +3787,7 @@ async def confirm_purchase(
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="покупка подписки (повторная попытка)",
)
-
+
transaction = await create_transaction(
db=db,
user_id=db_user.id,
@@ -3548,7 +3795,7 @@ async def confirm_purchase(
amount_kopeks=final_price,
description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)"
)
-
+
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
@@ -3556,39 +3803,39 @@ async def confirm_purchase(
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
-
+
await db.refresh(db_user)
await db.refresh(subscription)
-
+
subscription_link = get_display_subscription_link(subscription)
hide_subscription_link = settings.should_hide_subscription_link()
if remnawave_user and subscription_link:
if settings.is_happ_cryptolink_mode():
success_text = (
- f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
- + texts.t(
- "SUBSCRIPTION_HAPP_LINK_PROMPT",
- "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
- "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
- )
+ f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
+ + texts.t(
+ "SUBSCRIPTION_HAPP_LINK_PROMPT",
+ "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
+ "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
+ )
)
elif hide_subscription_link:
success_text = (
- f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
- + texts.t(
- "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
- "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
- "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
- )
+ f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
+ + texts.t(
+ "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
+ "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
+ "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
+ )
)
else:
import_link_section = texts.t(
@@ -3612,7 +3859,8 @@ async def confirm_purchase(
web_app=types.WebAppInfo(url=subscription_link),
)
],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
])
elif connect_mode == "miniapp_custom":
if not settings.MINIAPP_CUSTOM_URL:
@@ -3632,7 +3880,8 @@ async def confirm_purchase(
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
)
],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
])
elif connect_mode == "link":
rows = [
@@ -3641,7 +3890,8 @@ async def confirm_purchase(
happ_row = get_happ_download_button_row(texts)
if happ_row:
rows.append(happ_row)
- rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")])
+ rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")])
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
elif connect_mode == "happ_cryptolink":
rows = [
@@ -3655,14 +3905,17 @@ async def confirm_purchase(
happ_row = get_happ_download_button_row(texts)
if happ_row:
rows.append(happ_row)
- rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")])
+ rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")])
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
else:
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")],
+ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ callback_data="subscription_connect")],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
])
-
+
await callback.message.edit_text(
success_text,
reply_markup=connect_keyboard,
@@ -3676,17 +3929,18 @@ async def confirm_purchase(
).format(purchase_text=texts.SUBSCRIPTION_PURCHASED),
reply_markup=get_back_keyboard(db_user.language)
)
-
+
purchase_completed = True
- logger.info(f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price/100}₽")
-
+ logger.info(
+ f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price / 100}₽")
+
except Exception as e:
logger.error(f"Ошибка покупки подписки: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
-
+
if purchase_completed:
await clear_subscription_checkout_draft(db_user.id)
@@ -3694,11 +3948,10 @@ async def confirm_purchase(
await callback.answer()
-
async def resume_subscription_checkout(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
):
texts = get_texts(db_user.language)
@@ -3729,19 +3982,21 @@ async def resume_subscription_checkout(
)
await callback.answer()
+
+
async def add_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
-
+
traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
base_price = settings.get_traffic_price(traffic_gb)
if base_price == 0 and traffic_gb != 0:
@@ -3797,7 +4052,7 @@ async def add_traffic(
)
await callback.answer()
return
-
+
try:
success = await subtract_user_balance(
db,
@@ -3805,19 +4060,19 @@ async def add_traffic(
price,
f"Добавление {traffic_gb} ГБ трафика",
)
-
+
if not success:
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
return
-
- if traffic_gb == 0:
+
+ if traffic_gb == 0:
subscription.traffic_limit_gb = 0
else:
await add_subscription_traffic(db, subscription, traffic_gb)
-
+
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
-
+
await create_transaction(
db=db,
user_id=db_user.id,
@@ -3825,11 +4080,10 @@ async def add_traffic(
amount_kopeks=price,
description=f"Добавление {traffic_gb} ГБ трафика",
)
-
-
+
await db.refresh(db_user)
await db.refresh(subscription)
-
+
success_text = f"✅ Трафик успешно добавлен!\n\n"
if traffic_gb == 0:
success_text += "🎉 Теперь у вас безлимитный трафик!"
@@ -3849,36 +4103,37 @@ async def add_traffic(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
-
+
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика")
-
+
except Exception as e:
logger.error(f"Ошибка добавления трафика: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
-
+
await callback.answer()
+
async def create_paid_subscription_with_traffic_mode(
- db: AsyncSession,
- user_id: int,
- duration_days: int,
- device_limit: int,
- connected_squads: List[str],
- traffic_gb: Optional[int] = None
+ db: AsyncSession,
+ user_id: int,
+ duration_days: int,
+ device_limit: int,
+ connected_squads: List[str],
+ traffic_gb: Optional[int] = None
):
from app.config import settings
-
+
if traffic_gb is None:
if settings.is_traffic_fixed():
traffic_limit_gb = settings.get_fixed_traffic_limit()
else:
- traffic_limit_gb = 0
+ traffic_limit_gb = 0
else:
traffic_limit_gb = traffic_gb
-
+
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
@@ -3887,48 +4142,62 @@ async def create_paid_subscription_with_traffic_mode(
device_limit=device_limit,
connected_squads=connected_squads
)
-
+
logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})")
-
+
return subscription
+
def validate_traffic_price(gb: int) -> bool:
from app.config import settings
-
+
price = settings.get_traffic_price(gb)
- if gb == 0:
+ if gb == 0:
return True
-
+
return price > 0
async def handle_subscription_settings(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
if not subscription or subscription.is_trial:
- await callback.answer("⚠️ Настройки доступны только для платных подписок", show_alert=True)
+ await callback.answer(
+ texts.t(
+ "SUBSCRIPTION_SETTINGS_PAID_ONLY",
+ "⚠️ Настройки доступны только для платных подписок",
+ ),
+ show_alert=True,
+ )
return
-
+
devices_used = await get_current_devices_count(db_user)
-
- settings_text = f"""
-⚙️ Настройки подписки
-📊 Текущие параметры:
-🌐 Стран: {len(subscription.connected_squads)}
-📈 Трафик: {texts.format_traffic(subscription.traffic_used_gb)} / {texts.format_traffic(subscription.traffic_limit_gb)}
-📱 Устройства: {devices_used} / {subscription.device_limit}
+ settings_text = texts.t(
+ "SUBSCRIPTION_SETTINGS_OVERVIEW",
+ (
+ "⚙️ Настройки подписки\n\n"
+ "📊 Текущие параметры:\n"
+ "🌐 Стран: {countries_count}\n"
+ "📈 Трафик: {traffic_used} / {traffic_limit}\n"
+ "📱 Устройства: {devices_used} / {devices_limit}\n\n"
+ "Выберите что хотите изменить:"
+ ),
+ ).format(
+ countries_count=len(subscription.connected_squads),
+ traffic_used=texts.format_traffic(subscription.traffic_used_gb),
+ traffic_limit=texts.format_traffic(subscription.traffic_limit_gb),
+ devices_used=devices_used,
+ devices_limit=subscription.device_limit,
+ )
-Выберите что хотите изменить:
-"""
-
show_countries = await _should_show_countries_management(db_user)
-
+
await callback.message.edit_text(
settings_text,
reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries),
@@ -3938,86 +4207,111 @@ async def handle_subscription_settings(
async def handle_autopay_menu(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
+ texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription:
- await callback.answer("⚠️ У вас нет активной подписки!", show_alert=True)
+ await callback.answer(
+ texts.t("SUBSCRIPTION_ACTIVE_REQUIRED", "⚠️ У вас нет активной подписки!"),
+ show_alert=True,
+ )
return
-
- status = "включен" if subscription.autopay_enabled else "выключен"
+
+ status = (
+ texts.t("AUTOPAY_STATUS_ENABLED", "включен")
+ if subscription.autopay_enabled
+ else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
+ )
days = subscription.autopay_days_before
-
- text = f"💳 Автоплатеж\n\n"
- text += f"📊 Статус: {status}\n"
- text += f"⏰ Списание за: {days} дн. до окончания\n\n"
- text += "Выберите действие:"
-
+
+ text = texts.t(
+ "AUTOPAY_MENU_TEXT",
+ (
+ "💳 Автоплатеж\n\n"
+ "📊 Статус: {status}\n"
+ "⏰ Списание за: {days} дн. до окончания\n\n"
+ "Выберите действие:"
+ ),
+ ).format(status=status, days=days)
+
await callback.message.edit_text(
text,
- reply_markup=get_autopay_keyboard(db_user.language)
+ reply_markup=get_autopay_keyboard(db_user.language),
+ parse_mode="HTML",
)
await callback.answer()
async def toggle_autopay(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
subscription = db_user.subscription
enable = callback.data == "autopay_enable"
-
+
await update_subscription_autopay(db, subscription, enable)
-
- status = "включен" if enable else "выключен"
- await callback.answer(f"✅ Автоплатеж {status}!")
-
+
+ texts = get_texts(db_user.language)
+ status = (
+ texts.t("AUTOPAY_STATUS_ENABLED", "включен")
+ if enable
+ else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
+ )
+ await callback.answer(
+ texts.t("AUTOPAY_TOGGLE_SUCCESS", "✅ Автоплатеж {status}!").format(status=status)
+ )
+
await handle_autopay_menu(callback, db_user, db)
async def show_autopay_days(
- callback: types.CallbackQuery,
- db_user: User
+ callback: types.CallbackQuery,
+ db_user: User
):
-
+ texts = get_texts(db_user.language)
await callback.message.edit_text(
- "⏰ Выберите за сколько дней до окончания списывать средства:",
+ texts.t(
+ "AUTOPAY_SELECT_DAYS_PROMPT",
+ "⏰ Выберите за сколько дней до окончания списывать средства:",
+ ),
reply_markup=get_autopay_days_keyboard(db_user.language)
)
await callback.answer()
async def set_autopay_days(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
days = int(callback.data.split('_')[2])
subscription = db_user.subscription
-
+
await update_subscription_autopay(
db, subscription, subscription.autopay_enabled, days
)
-
- await callback.answer(f"✅ Установлено {days} дней!")
-
+
+ texts = get_texts(db_user.language)
+ await callback.answer(
+ texts.t("AUTOPAY_DAYS_SET", "✅ Установлено {days} дней!").format(days=days)
+ )
+
await handle_autopay_menu(callback, db_user, db)
+
async def handle_subscription_config_back(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
):
current_state = await state.get_state()
texts = get_texts(db_user.language)
-
+
if current_state == SubscriptionStates.selecting_traffic.state:
await callback.message.edit_text(
_build_subscription_period_prompt(db_user, texts),
@@ -4062,21 +4356,21 @@ async def handle_subscription_config_back(
reply_markup=get_subscription_period_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_period)
-
+
else:
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
await state.clear()
-
+
await callback.answer()
-async def handle_subscription_cancel(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
-):
+async def handle_subscription_cancel(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
+):
texts = get_texts(db_user.language)
await state.clear()
@@ -4087,6 +4381,7 @@ async def handle_subscription_cancel(
await callback.answer("❌ Покупка отменена")
+
async def _get_available_countries(promo_group_id: Optional[int] = None):
from app.utils.cache import cache, cache_key
from app.database.database import AsyncSessionLocal
@@ -4115,23 +4410,24 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
for server in available_servers:
countries.append({
"uuid": server.squad_uuid,
- "name": server.display_name,
+ "name": server.display_name,
"price_kopeks": server.price_kopeks,
"country_code": server.country_code,
"is_available": server.is_available and not server.is_full
})
-
+
if not countries:
logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...")
from app.services.remnawave_service import RemnaWaveService
-
+
service = RemnaWaveService()
squads = await service.get_all_squads()
-
+
for squad in squads:
squad_name = squad["name"]
-
- if not any(flag in squad_name for flag in ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]):
+
+ if not any(flag in squad_name for flag in
+ ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]):
name_lower = squad_name.lower()
if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower:
squad_name = f"🇳🇱 {squad_name}"
@@ -4141,14 +4437,14 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
squad_name = f"🇺🇸 {squad_name}"
else:
squad_name = f"🌐 {squad_name}"
-
+
countries.append({
"uuid": squad["uuid"],
"name": squad_name,
- "price_kopeks": 0,
+ "price_kopeks": 0,
"is_available": True
})
-
+
await cache.set(cache_key_value, countries, 300)
return countries
@@ -4161,35 +4457,36 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
await cache.set(cache_key_value, fallback_countries, 60)
return fallback_countries
+
async def _get_countries_info(squad_uuids):
countries = await _get_available_countries()
return [c for c in countries if c['uuid'] in squad_uuids]
+
async def handle_reset_devices(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
await handle_device_management(callback, db_user, db)
+
async def handle_add_country_to_subscription(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
):
-
logger.info(f"🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}")
logger.info(f"🔍 Callback data: {callback.data}")
-
+
current_state = await state.get_state()
logger.info(f"🔍 Текущее состояние: {current_state}")
-
+
country_uuid = callback.data.split('_')[1]
data = await state.get_data()
logger.info(f"🔍 Данные состояния: {data}")
-
+
selected_countries = data.get('countries', [])
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
@@ -4197,14 +4494,14 @@ async def handle_add_country_to_subscription(
if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
return
-
+
if country_uuid in selected_countries:
selected_countries.remove(country_uuid)
logger.info(f"🔍 Удалена страна: {country_uuid}")
else:
selected_countries.append(country_uuid)
logger.info(f"🔍 Добавлена страна: {country_uuid}")
-
+
total_price = 0
subscription = db_user.subscription
period_hint_days = _get_period_hint_from_subscription(subscription)
@@ -4219,8 +4516,8 @@ async def handle_add_country_to_subscription(
continue
if (
- country['uuid'] in selected_countries
- and country['uuid'] not in subscription.connected_squads
+ country['uuid'] in selected_countries
+ and country['uuid'] not in subscription.connected_squads
):
server_price = country['price_kopeks']
if servers_discount_percent > 0 and server_price > 0:
@@ -4238,7 +4535,7 @@ async def handle_add_country_to_subscription(
logger.info(f"🔍 Новые выбранные страны: {selected_countries}")
logger.info(f"🔍 Общая стоимость: {total_price}")
-
+
try:
from app.keyboards.inline import get_manage_countries_keyboard
await callback.message.edit_reply_markup(
@@ -4254,9 +4551,10 @@ async def handle_add_country_to_subscription(
logger.info(f"✅ Клавиатура обновлена")
except Exception as e:
logger.error(f"❌ Ошибка обновления клавиатуры: {e}")
-
+
await callback.answer()
+
async def _should_show_countries_management(user: Optional[User] = None) -> bool:
try:
promo_group_id = user.promo_group_id if user else None
@@ -4294,16 +4592,15 @@ async def _should_show_countries_management(user: Optional[User] = None) -> bool
async def confirm_add_countries_to_subscription(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
):
-
data = await state.get_data()
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
selected_countries = data.get('countries', [])
current_countries = subscription.connected_squads
@@ -4318,11 +4615,11 @@ async def confirm_add_countries_to_subscription(
new_countries = [c for c in selected_countries if c not in current_countries]
removed_countries = [c for c in current_countries if c not in selected_countries]
-
+
if not new_countries and not removed_countries:
await callback.answer("⚠️ Изменения не обнаружены", show_alert=True)
return
-
+
total_price = 0
new_countries_names = []
removed_countries_names = []
@@ -4360,7 +4657,7 @@ async def confirm_add_countries_to_subscription(
new_countries_names.append(country['name'])
if country['uuid'] in removed_countries:
removed_countries_names.append(country['name'])
-
+
if new_countries and db_user.balance_kopeks < total_price:
missing_kopeks = total_price - db_user.balance_kopeks
message_text = texts.t(
@@ -4389,18 +4686,18 @@ async def confirm_add_countries_to_subscription(
await state.clear()
await callback.answer()
return
-
+
try:
if new_countries and total_price > 0:
success = await subtract_user_balance(
db, db_user, total_price,
f"Добавление стран к подписке: {', '.join(new_countries_names)}"
)
-
+
if not success:
await callback.answer("❌ Ошибка списания средств", show_alert=True)
return
-
+
await create_transaction(
db=db,
user_id=db_user.id,
@@ -4408,19 +4705,19 @@ async def confirm_add_countries_to_subscription(
amount_kopeks=total_price,
description=f"Добавление стран к подписке: {', '.join(new_countries_names)}"
)
-
+
subscription.connected_squads = selected_countries
subscription.updated_at = datetime.utcnow()
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
-
+
await db.refresh(db_user)
await db.refresh(subscription)
-
+
success_text = "✅ Страны успешно обновлены!\n\n"
-
+
if new_countries_names:
success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n"
if total_price > 0:
@@ -4431,42 +4728,44 @@ async def confirm_add_countries_to_subscription(
f" -{texts.format_price(total_discount_value)})"
)
success_text += "\n"
-
+
if removed_countries_names:
success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n"
success_text += "ℹ️ Повторное подключение будет платным\n"
-
+
success_text += f"\n🌍 Активных стран: {len(selected_countries)}"
-
+
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
-
- logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}")
-
+
+ logger.info(
+ f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}")
+
except Exception as e:
logger.error(f"Ошибка обновления стран подписки: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
-
+
await state.clear()
await callback.answer()
-async def confirm_reset_devices(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
+async def confirm_reset_devices(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
await handle_device_management(callback, db_user, db)
+
async def handle_happ_download_request(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
texts = get_texts(db_user.language)
prompt_text = texts.t(
@@ -4481,9 +4780,9 @@ async def handle_happ_download_request(
async def handle_happ_download_platform_choice(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
platform = callback.data.split('_')[-1]
if platform == "pc":
@@ -4517,9 +4816,9 @@ async def handle_happ_download_platform_choice(
async def handle_happ_download_close(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
try:
await callback.message.delete()
@@ -4530,9 +4829,9 @@ async def handle_happ_download_close(
async def handle_happ_download_back(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
texts = get_texts(db_user.language)
prompt_text = texts.t(
@@ -4545,10 +4844,11 @@ async def handle_happ_download_back(
await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode="HTML")
await callback.answer()
+
async def handle_connect_subscription(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
@@ -4707,14 +5007,14 @@ async def handle_connect_subscription(
reply_markup=get_device_selection_keyboard(db_user.language),
parse_mode="HTML"
)
-
+
await callback.answer()
async def claim_discount_offer(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
):
texts = get_texts(db_user.language)
@@ -4782,11 +5082,11 @@ async def claim_discount_offer(
async def handle_device_guide(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
- device_type = callback.data.split('_')[2]
+ device_type = callback.data.split('_')[2]
texts = get_texts(db_user.language)
subscription = db_user.subscription
subscription_link = get_display_subscription_link(subscription)
@@ -4812,62 +5112,62 @@ async def handle_device_guide(
if hide_subscription_link:
link_section = (
- texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
- + "\n"
- + texts.t(
- "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
- "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
- )
- + "\n\n"
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
+ + "\n"
+ + texts.t(
+ "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
+ "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
+ )
+ + "\n\n"
)
else:
link_section = (
- texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
- + f"\n{subscription_link}\n\n"
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
+ + f"\n{subscription_link}\n\n"
)
guide_text = (
- texts.t(
- "SUBSCRIPTION_DEVICE_GUIDE_TITLE",
- "📱 Настройка для {device_name}",
- ).format(device_name=get_device_name(device_type, db_user.language))
- + "\n\n"
- + link_section
- + texts.t(
- "SUBSCRIPTION_DEVICE_FEATURED_APP",
- "📋 Рекомендуемое приложение: {app_name}",
- ).format(app_name=featured_app['name'])
- + "\n\n"
- + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:")
- + f"\n{featured_app['installationStep']['description'][db_user.language]}\n\n"
- + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:")
- + f"\n{featured_app['addSubscriptionStep']['description'][db_user.language]}\n\n"
- + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:")
- + f"\n{featured_app['connectAndUseStep']['description'][db_user.language]}\n\n"
- + texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 Как подключить:")
- + "\n"
- + "\n".join(
- [
- texts.t(
- "SUBSCRIPTION_DEVICE_HOW_TO_STEP1",
- "1. Установите приложение по ссылке выше",
- ),
- texts.t(
- "SUBSCRIPTION_DEVICE_HOW_TO_STEP2",
- "2. Скопируйте ссылку подписки (нажмите на неё)",
- ),
- texts.t(
- "SUBSCRIPTION_DEVICE_HOW_TO_STEP3",
- "3. Откройте приложение и вставьте ссылку",
- ),
- texts.t(
- "SUBSCRIPTION_DEVICE_HOW_TO_STEP4",
- "4. Подключитесь к серверу",
- ),
- ]
- )
+ texts.t(
+ "SUBSCRIPTION_DEVICE_GUIDE_TITLE",
+ "📱 Настройка для {device_name}",
+ ).format(device_name=get_device_name(device_type, db_user.language))
+ + "\n\n"
+ + link_section
+ + texts.t(
+ "SUBSCRIPTION_DEVICE_FEATURED_APP",
+ "📋 Рекомендуемое приложение: {app_name}",
+ ).format(app_name=featured_app['name'])
+ + "\n\n"
+ + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:")
+ + f"\n{featured_app['installationStep']['description'][db_user.language]}\n\n"
+ + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:")
+ + f"\n{featured_app['addSubscriptionStep']['description'][db_user.language]}\n\n"
+ + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:")
+ + f"\n{featured_app['connectAndUseStep']['description'][db_user.language]}\n\n"
+ + texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 Как подключить:")
+ + "\n"
+ + "\n".join(
+ [
+ texts.t(
+ "SUBSCRIPTION_DEVICE_HOW_TO_STEP1",
+ "1. Установите приложение по ссылке выше",
+ ),
+ texts.t(
+ "SUBSCRIPTION_DEVICE_HOW_TO_STEP2",
+ "2. Скопируйте ссылку подписки (нажмите на неё)",
+ ),
+ texts.t(
+ "SUBSCRIPTION_DEVICE_HOW_TO_STEP3",
+ "3. Откройте приложение и вставьте ссылку",
+ ),
+ texts.t(
+ "SUBSCRIPTION_DEVICE_HOW_TO_STEP4",
+ "4. Подключитесь к серверу",
+ ),
+ ]
)
-
+ )
+
await callback.message.edit_text(
guide_text,
reply_markup=get_connection_guide_keyboard(
@@ -4881,16 +5181,16 @@ async def handle_device_guide(
async def handle_app_selection(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
- device_type = callback.data.split('_')[2]
+ device_type = callback.data.split('_')[2]
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
apps = get_apps_for_device(device_type, db_user.language)
-
+
if not apps:
await callback.answer(
texts.t("SUBSCRIPTION_DEVICE_APPS_NOT_FOUND", "❌ Приложения для этого устройства не найдены"),
@@ -4899,14 +5199,14 @@ async def handle_app_selection(
return
app_text = (
- texts.t(
- "SUBSCRIPTION_APPS_TITLE",
- "📱 Приложения для {device_name}",
- ).format(device_name=get_device_name(device_type, db_user.language))
- + "\n\n"
- + texts.t("SUBSCRIPTION_APPS_PROMPT", "Выберите приложение для подключения:")
+ texts.t(
+ "SUBSCRIPTION_APPS_TITLE",
+ "📱 Приложения для {device_name}",
+ ).format(device_name=get_device_name(device_type, db_user.language))
+ + "\n\n"
+ + texts.t("SUBSCRIPTION_APPS_PROMPT", "Выберите приложение для подключения:")
)
-
+
await callback.message.edit_text(
app_text,
reply_markup=get_app_selection_keyboard(device_type, apps, db_user.language),
@@ -4916,14 +5216,14 @@ async def handle_app_selection(
async def handle_specific_app_guide(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
- _, device_type, app_id = callback.data.split('_')
+ _, device_type, app_id = callback.data.split('_')
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
subscription_link = get_display_subscription_link(subscription)
if not subscription_link:
@@ -4935,7 +5235,7 @@ async def handle_specific_app_guide(
apps = get_apps_for_device(device_type, db_user.language)
app = next((a for a in apps if a['id'] == app_id), None)
-
+
if not app:
await callback.answer(
texts.t("SUBSCRIPTION_APP_NOT_FOUND", "❌ Приложение не найдено"),
@@ -4947,46 +5247,46 @@ async def handle_specific_app_guide(
if hide_subscription_link:
link_section = (
- texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
- + "\n"
- + texts.t(
- "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
- "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
- )
- + "\n\n"
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
+ + "\n"
+ + texts.t(
+ "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
+ "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
+ )
+ + "\n\n"
)
else:
link_section = (
- texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
- + f"\n{subscription_link}\n\n"
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
+ + f"\n{subscription_link}\n\n"
)
guide_text = (
- texts.t(
- "SUBSCRIPTION_SPECIFIC_APP_TITLE",
- "📱 {app_name} - {device_name}",
- ).format(app_name=app['name'], device_name=get_device_name(device_type, db_user.language))
- + "\n\n"
- + link_section
- + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:")
- + f"\n{app['installationStep']['description'][db_user.language]}\n\n"
- + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:")
- + f"\n{app['addSubscriptionStep']['description'][db_user.language]}\n\n"
- + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:")
- + f"\n{app['connectAndUseStep']['description'][db_user.language]}"
+ texts.t(
+ "SUBSCRIPTION_SPECIFIC_APP_TITLE",
+ "📱 {app_name} - {device_name}",
+ ).format(app_name=app['name'], device_name=get_device_name(device_type, db_user.language))
+ + "\n\n"
+ + link_section
+ + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:")
+ + f"\n{app['installationStep']['description'][db_user.language]}\n\n"
+ + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:")
+ + f"\n{app['addSubscriptionStep']['description'][db_user.language]}\n\n"
+ + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:")
+ + f"\n{app['connectAndUseStep']['description'][db_user.language]}"
)
if 'additionalAfterAddSubscriptionStep' in app:
additional = app['additionalAfterAddSubscriptionStep']
guide_text += (
- "\n\n"
- + texts.t(
- "SUBSCRIPTION_ADDITIONAL_STEP_TITLE",
- "{title}:",
- ).format(title=additional['title'][db_user.language])
- + f"\n{additional['description'][db_user.language]}"
+ "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_ADDITIONAL_STEP_TITLE",
+ "{title}:",
+ ).format(title=additional['title'][db_user.language])
+ + f"\n{additional['description'][db_user.language]}"
)
-
+
await callback.message.edit_text(
guide_text,
reply_markup=get_specific_app_keyboard(
@@ -4999,21 +5299,22 @@ async def handle_specific_app_guide(
)
await callback.answer()
+
async def handle_no_traffic_packages(
- callback: types.CallbackQuery,
- db_user: User
+ callback: types.CallbackQuery,
+ db_user: User
):
await callback.answer(
"⚠️ В данный момент нет доступных пакетов трафика. "
- "Обратитесь в техподдержку для получения информации.",
+ "Обратитесь в техподдержку для получения информации.",
show_alert=True
)
async def handle_open_subscription_link(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
@@ -5030,20 +5331,20 @@ async def handle_open_subscription_link(
redirect_link = get_happ_cryptolink_redirect_link(subscription_link)
happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_link)
happ_message = (
- texts.t(
- "SUBSCRIPTION_HAPP_OPEN_TITLE",
- "🔗 Подключение через Happ",
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_HAPP_OPEN_LINK",
- "🔓 Открыть ссылку в Happ",
- ).format(subscription_link=happ_scheme_link)
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_HAPP_OPEN_HINT",
- "💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
- )
+ texts.t(
+ "SUBSCRIPTION_HAPP_OPEN_TITLE",
+ "🔗 Подключение через Happ",
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_HAPP_OPEN_LINK",
+ "🔓 Открыть ссылку в Happ",
+ ).format(subscription_link=happ_scheme_link)
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_HAPP_OPEN_HINT",
+ "💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
+ )
)
if redirect_link:
@@ -5073,43 +5374,44 @@ async def handle_open_subscription_link(
return
link_text = (
- texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
- + "\n\n"
- + f"{subscription_link}\n\n"
- + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:")
- + "\n"
- + "\n".join(
- [
- texts.t(
- "SUBSCRIPTION_LINK_STEP1",
- "1. Нажмите на ссылку выше чтобы её скопировать",
- ),
- texts.t(
- "SUBSCRIPTION_LINK_STEP2",
- "2. Откройте ваше VPN приложение",
- ),
- texts.t(
- "SUBSCRIPTION_LINK_STEP3",
- "3. Найдите функцию \"Добавить подписку\" или \"Import\"",
- ),
- texts.t(
- "SUBSCRIPTION_LINK_STEP4",
- "4. Вставьте скопированную ссылку",
- ),
- ]
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_LINK_HINT",
- "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
- )
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
+ + "\n\n"
+ + f"{subscription_link}\n\n"
+ + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:")
+ + "\n"
+ + "\n".join(
+ [
+ texts.t(
+ "SUBSCRIPTION_LINK_STEP1",
+ "1. Нажмите на ссылку выше чтобы её скопировать",
+ ),
+ texts.t(
+ "SUBSCRIPTION_LINK_STEP2",
+ "2. Откройте ваше VPN приложение",
+ ),
+ texts.t(
+ "SUBSCRIPTION_LINK_STEP3",
+ "3. Найдите функцию \"Добавить подписку\" или \"Import\"",
+ ),
+ texts.t(
+ "SUBSCRIPTION_LINK_STEP4",
+ "4. Вставьте скопированную ссылку",
+ ),
+ ]
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_LINK_HINT",
+ "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
+ )
)
await callback.message.edit_text(
link_text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")
+ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ callback_data="subscription_connect")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
@@ -5124,7 +5426,7 @@ def load_app_config() -> Dict[str, Any]:
try:
from app.config import settings
config_path = settings.get_app_config_path()
-
+
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
@@ -5133,16 +5435,16 @@ def load_app_config() -> Dict[str, Any]:
def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]:
- config = load_app_config()
-
+ config = load_app_config()['platforms']
+
device_mapping = {
'ios': 'ios',
- 'android': 'android',
- 'windows': 'pc',
- 'mac': 'pc',
- 'tv': 'tv'
+ 'android': 'android',
+ 'windows': 'windows',
+ 'mac': 'macos',
+ 'tv': 'androidTV'
}
-
+
config_key = device_mapping.get(device_type, device_type)
return config.get(config_key, [])
@@ -5164,13 +5466,11 @@ def get_device_name(device_type: str, language: str = "ru") -> str:
'mac': 'macOS',
'tv': 'Android TV'
}
-
+
return names.get(device_type, device_type)
def create_deep_link(app: Dict[str, Any], subscription_url: str) -> str:
- from app.config import settings
-
return subscription_url
@@ -5179,7 +5479,7 @@ def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMa
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
- text="✅ Да, сбросить все устройства",
+ text="✅ Да, сбросить все устройства",
callback_data="confirm_reset_devices"
)
],
@@ -5188,19 +5488,21 @@ def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMa
]
])
-async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User, subscription: Subscription):
+
+async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User,
+ subscription: Subscription):
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_trial_activation_notification(db, db_user, subscription)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о триале: {e}")
+
async def show_device_connection_help(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
subscription = db_user.subscription
subscription_link = get_display_subscription_link(subscription)
@@ -5230,7 +5532,7 @@ async def show_device_connection_help(
💡 Совет: Сохраните эту ссылку - она понадобится для подключения новых устройств
"""
-
+
await callback.message.edit_text(
help_text,
reply_markup=get_device_management_help_keyboard(db_user.language),
@@ -5238,18 +5540,19 @@ async def show_device_connection_help(
)
await callback.answer()
+
async def send_purchase_notification(
- callback: types.CallbackQuery,
- db: AsyncSession,
- db_user: User,
- subscription: Subscription,
- transaction_id: int,
- period_days: int,
- was_trial_conversion: bool = False
+ callback: types.CallbackQuery,
+ db: AsyncSession,
+ db_user: User,
+ subscription: Subscription,
+ transaction_id: int,
+ period_days: int,
+ was_trial_conversion: bool = False
):
try:
from app.database.crud.transaction import get_transaction_by_id
-
+
transaction = await get_transaction_by_id(db, transaction_id)
if transaction:
notification_service = AdminNotificationService(callback.bot)
@@ -5259,18 +5562,19 @@ async def send_purchase_notification(
except Exception as e:
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
+
async def send_extension_notification(
- callback: types.CallbackQuery,
- db: AsyncSession,
- db_user: User,
- subscription: Subscription,
- transaction_id: int,
- extended_days: int,
- old_end_date: datetime
+ callback: types.CallbackQuery,
+ db: AsyncSession,
+ db_user: User,
+ subscription: Subscription,
+ transaction_id: int,
+ extended_days: int,
+ old_end_date: datetime
):
try:
from app.database.crud.transaction import get_transaction_by_id
-
+
transaction = await get_transaction_by_id(db, transaction_id)
if transaction:
notification_service = AdminNotificationService(callback.bot)
@@ -5280,24 +5584,25 @@ async def send_extension_notification(
except Exception as e:
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
+
async def handle_switch_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
from app.config import settings
-
+
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
-
+
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
if not subscription or subscription.is_trial:
await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True)
return
-
+
current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
@@ -5321,26 +5626,25 @@ async def handle_switch_traffic(
),
parse_mode="HTML"
)
-
+
await callback.answer()
async def confirm_switch_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
new_traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
-
+
current_traffic = subscription.traffic_limit_gb
-
+
if new_traffic_gb == current_traffic:
await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True)
return
-
+
old_price_per_month = settings.get_traffic_price(current_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
@@ -5362,9 +5666,9 @@ async def confirm_switch_traffic(
)
price_difference_per_month = discounted_new_per_month - discounted_old_per_month
discount_savings_per_month = (
- (new_price_per_month - old_price_per_month) - price_difference_per_month
+ (new_price_per_month - old_price_per_month) - price_difference_per_month
)
-
+
if price_difference_per_month > 0:
total_price_difference = price_difference_per_month * months_remaining
@@ -5395,7 +5699,7 @@ async def confirm_switch_traffic(
)
await callback.answer()
return
-
+
action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}"
cost_text = f"Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)"
if discount_savings_per_month > 0:
@@ -5408,61 +5712,61 @@ async def confirm_switch_traffic(
total_price_difference = 0
action_text = f"уменьшить до {texts.format_traffic(new_traffic_gb)}"
cost_text = "Возврат средств не производится"
-
+
confirm_text = f"🔄 Подтверждение переключения трафика\n\n"
confirm_text += f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
confirm_text += f"Новый лимит: {texts.format_traffic(new_traffic_gb)}\n\n"
confirm_text += f"Действие: {action_text}\n"
confirm_text += f"💰 {cost_text}\n\n"
confirm_text += "Подтвердить переключение?"
-
+
await callback.message.edit_text(
confirm_text,
reply_markup=get_confirm_switch_traffic_keyboard(new_traffic_gb, total_price_difference, db_user.language),
parse_mode="HTML"
)
-
+
await callback.answer()
+
async def clear_saved_cart(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
):
await state.clear()
-
+
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
-
+
await callback.answer("🗑️ Корзина очищена")
async def execute_switch_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
):
-
callback_parts = callback.data.split('_')
new_traffic_gb = int(callback_parts[3])
price_difference = int(callback_parts[4])
-
+
texts = get_texts(db_user.language)
subscription = db_user.subscription
current_traffic = subscription.traffic_limit_gb
-
+
try:
if price_difference > 0:
success = await subtract_user_balance(
db, db_user, price_difference,
f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB"
)
-
+
if not success:
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
return
-
+
months_remaining = get_remaining_months(subscription.end_date)
await create_transaction(
db=db,
@@ -5471,18 +5775,18 @@ async def execute_switch_traffic(
amount_kopeks=price_difference,
description=f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес"
)
-
+
subscription.traffic_limit_gb = new_traffic_gb
subscription.updated_at = datetime.utcnow()
-
+
await db.commit()
-
+
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
-
+
await db.refresh(db_user)
await db.refresh(subscription)
-
+
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
@@ -5491,7 +5795,7 @@ async def execute_switch_traffic(
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}")
-
+
if new_traffic_gb > current_traffic:
success_text = f"✅ Лимит трафика увеличен!\n\n"
success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → "
@@ -5503,50 +5807,51 @@ async def execute_switch_traffic(
success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → "
success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n"
success_text += f"ℹ️ Возврат средств не производится"
-
+
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
-
- logger.info(f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference/100}₽")
-
+
+ logger.info(
+ f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference / 100}₽")
+
except Exception as e:
logger.error(f"Ошибка переключения трафика: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
-
+
await callback.answer()
def get_traffic_switch_keyboard(
- current_traffic_gb: int,
- language: str = "ru",
- subscription_end_date: datetime = None,
- discount_percent: int = 0,
+ current_traffic_gb: int,
+ language: str = "ru",
+ subscription_end_date: datetime = None,
+ discount_percent: int = 0,
) -> InlineKeyboardMarkup:
from app.config import settings
-
+
months_multiplier = 1
period_text = ""
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
-
+
packages = settings.get_traffic_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
-
+
current_price_per_month = settings.get_traffic_price(current_traffic_gb)
discounted_current_per_month, _ = apply_percentage_discount(
current_price_per_month,
discount_percent,
)
-
+
buttons = []
-
+
for package in enabled_packages:
gb = package['gb']
price_per_month = package['price']
@@ -5565,14 +5870,14 @@ def get_traffic_switch_keyboard(
elif total_price_diff > 0:
emoji = "⬆️"
action_text = ""
- price_text = f" (+{total_price_diff//100}₽{period_text})"
+ price_text = f" (+{total_price_diff // 100}₽{period_text})"
if discount_percent > 0:
discount_total = (
- (price_per_month - current_price_per_month) * months_multiplier
- - total_price_diff
+ (price_per_month - current_price_per_month) * months_multiplier
+ - total_price_diff
)
if discount_total > 0:
- price_text += f" (скидка {discount_percent}%: -{discount_total//100}₽)"
+ price_text += f" (скидка {discount_percent}%: -{discount_total // 100}₽)"
elif total_price_diff < 0:
emoji = "⬇️"
action_text = ""
@@ -5581,34 +5886,33 @@ def get_traffic_switch_keyboard(
emoji = "🔄"
action_text = ""
price_text = " (бесплатно)"
-
+
if gb == 0:
traffic_text = "Безлимит"
else:
traffic_text = f"{gb} ГБ"
-
+
button_text = f"{emoji} {traffic_text}{action_text}{price_text}"
-
+
buttons.append([
InlineKeyboardButton(text=button_text, callback_data=f"switch_traffic_{gb}")
])
-
+
buttons.append([
InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="subscription_settings"
)
])
-
+
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_confirm_switch_traffic_keyboard(
- new_traffic_gb: int,
- price_difference: int,
- language: str = "ru"
+ new_traffic_gb: int,
+ price_difference: int,
+ language: str = "ru"
) -> InlineKeyboardMarkup:
-
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
@@ -5627,32 +5931,32 @@ def get_confirm_switch_traffic_keyboard(
def register_handlers(dp: Dispatcher):
update_traffic_prices()
-
+
dp.callback_query.register(
show_subscription_info,
F.data == "menu_subscription"
)
-
+
dp.callback_query.register(
show_trial_offer,
F.data == "menu_trial"
)
-
+
dp.callback_query.register(
activate_trial,
F.data == "trial_activate"
)
-
+
dp.callback_query.register(
start_subscription_purchase,
F.data.in_(["menu_buy", "subscription_upgrade"])
)
-
+
dp.callback_query.register(
handle_add_countries,
F.data == "subscription_add_countries"
)
-
+
dp.callback_query.register(
handle_switch_traffic,
F.data == "subscription_switch_traffic"
@@ -5662,12 +5966,12 @@ def register_handlers(dp: Dispatcher):
confirm_switch_traffic,
F.data.startswith("switch_traffic_")
)
-
+
dp.callback_query.register(
execute_switch_traffic,
F.data.startswith("confirm_switch_traffic_")
)
-
+
dp.callback_query.register(
handle_change_devices,
F.data == "subscription_change_devices"
@@ -5677,33 +5981,32 @@ def register_handlers(dp: Dispatcher):
confirm_change_devices,
F.data.startswith("change_devices_")
)
-
+
dp.callback_query.register(
execute_change_devices,
F.data.startswith("confirm_change_devices_")
)
-
+
dp.callback_query.register(
handle_extend_subscription,
F.data == "subscription_extend"
)
-
+
dp.callback_query.register(
handle_reset_traffic,
F.data == "subscription_reset_traffic"
)
-
-
+
dp.callback_query.register(
confirm_add_devices,
F.data.startswith("add_devices_")
)
-
+
dp.callback_query.register(
confirm_extend_subscription,
F.data.startswith("extend_period_")
)
-
+
dp.callback_query.register(
confirm_reset_traffic,
F.data == "confirm_reset_traffic"
@@ -5713,36 +6016,36 @@ def register_handlers(dp: Dispatcher):
handle_reset_devices,
F.data == "subscription_reset_devices"
)
-
+
dp.callback_query.register(
confirm_reset_devices,
F.data == "confirm_reset_devices"
)
-
+
dp.callback_query.register(
select_period,
F.data.startswith("period_"),
SubscriptionStates.selecting_period
)
-
+
dp.callback_query.register(
select_traffic,
F.data.startswith("traffic_"),
SubscriptionStates.selecting_traffic
)
-
+
dp.callback_query.register(
select_devices,
F.data.startswith("devices_") & ~F.data.in_(["devices_continue"]),
SubscriptionStates.selecting_devices
)
-
+
dp.callback_query.register(
devices_continue,
F.data == "devices_continue",
SubscriptionStates.selecting_devices
)
-
+
dp.callback_query.register(
confirm_purchase,
F.data == "subscription_confirm",
@@ -5763,17 +6066,17 @@ def register_handlers(dp: Dispatcher):
clear_saved_cart,
F.data == "clear_saved_cart",
)
-
+
dp.callback_query.register(
handle_autopay_menu,
F.data == "subscription_autopay"
)
-
+
dp.callback_query.register(
toggle_autopay,
F.data.in_(["autopay_enable", "autopay_disable"])
)
-
+
dp.callback_query.register(
show_autopay_days,
F.data == "autopay_set_days"
@@ -5783,12 +6086,12 @@ def register_handlers(dp: Dispatcher):
handle_subscription_config_back,
F.data == "subscription_config_back"
)
-
+
dp.callback_query.register(
handle_subscription_cancel,
F.data == "subscription_cancel"
)
-
+
dp.callback_query.register(
set_autopay_days,
F.data.startswith("autopay_days_")
@@ -5799,7 +6102,7 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("country_"),
SubscriptionStates.selecting_countries
)
-
+
dp.callback_query.register(
countries_continue,
F.data == "countries_continue",
@@ -5810,7 +6113,7 @@ def register_handlers(dp: Dispatcher):
handle_manage_country,
F.data.startswith("country_manage_")
)
-
+
dp.callback_query.register(
apply_countries_changes,
F.data == "countries_apply"
@@ -5851,22 +6154,22 @@ def register_handlers(dp: Dispatcher):
handle_connect_subscription,
F.data == "subscription_connect"
)
-
+
dp.callback_query.register(
handle_device_guide,
F.data.startswith("device_guide_")
)
-
+
dp.callback_query.register(
handle_app_selection,
F.data.startswith("app_list_")
)
-
+
dp.callback_query.register(
handle_specific_app_guide,
F.data.startswith("app_")
)
-
+
dp.callback_query.register(
handle_open_subscription_link,
F.data == "open_subscription_link"
@@ -5886,17 +6189,17 @@ def register_handlers(dp: Dispatcher):
handle_device_management,
F.data == "subscription_manage_devices"
)
-
+
dp.callback_query.register(
handle_devices_page,
F.data.startswith("devices_page_")
)
-
+
dp.callback_query.register(
handle_single_device_reset,
- F.data.regexp(r"^reset_device_\d+_\d+$")
+ F.data.regexp(r"^reset_device_\d+_\d+$")
)
-
+
dp.callback_query.register(
handle_all_devices_reset_from_management,
F.data == "reset_all_devices"
@@ -5905,4 +6208,4 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_device_connection_help,
F.data == "device_connection_help"
- )
+ )
\ No newline at end of file
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 809481d9..ee7729af 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -4,16 +4,21 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from app.localization.texts import get_texts
+def _t(texts, key: str, default: str) -> str:
+ """Helper for localized button labels with fallbacks."""
+ return texts.t(key, default)
+
+
def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
-
+
return InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")],
- [InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")],
- [InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")],
- [InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")],
- [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")],
- [InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")],
+ [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_USERS_SUBSCRIPTIONS", "👥 Юзеры/Подписки"), callback_data="admin_submenu_users")],
+ [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_PROMO_STATS", "💰 Промокоды/Статистика"), callback_data="admin_submenu_promo")],
+ [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SUPPORT", "🛟 Поддержка"), callback_data="admin_submenu_support")],
+ [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_MESSAGES", "📨 Сообщения"), callback_data="admin_submenu_communications")],
+ [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SETTINGS", "⚙️ Настройки"), callback_data="admin_submenu_settings")],
+ [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SYSTEM", "🛠️ Система"), callback_data="admin_submenu_system")],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
@@ -30,7 +35,7 @@ def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions")
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
]
])
@@ -50,7 +55,7 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups")
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
]
])
@@ -63,11 +68,17 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
],
[
- InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"),
- InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_COMMUNICATIONS_WELCOME_TEXT", "👋 Приветственный текст"),
+ callback_data="welcome_text_panel"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_COMMUNICATIONS_MENU_MESSAGES", "📢 Сообщения в меню"),
+ callback_data="user_messages_panel"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
]
])
@@ -77,16 +88,25 @@ def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMa
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"),
+ callback_data="admin_tickets"
+ )
],
[
- InlineKeyboardButton(text="🧾 Аудит модераторов", callback_data="admin_support_audit")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SUPPORT_AUDIT", "🧾 Аудит модераторов"),
+ callback_data="admin_support_audit"
+ )
],
[
- InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SUPPORT_SETTINGS", "🛟 Настройки поддержки"),
+ callback_data="admin_support_settings"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
]
])
@@ -100,7 +120,10 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring")
],
[
- InlineKeyboardButton(text="🧩 Конфигурация бота", callback_data="admin_bot_config"),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SETTINGS_BOT_CONFIG", "🧩 Конфигурация бота"),
+ callback_data="admin_bot_config"
+ ),
],
[
InlineKeyboardButton(
@@ -110,10 +133,13 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
],
[
InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"),
- InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SETTINGS_MAINTENANCE", "🔧 Техработы"),
+ callback_data="maintenance_panel"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
]
])
@@ -123,23 +149,51 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="📄 Обновления", callback_data="admin_updates"),
- InlineKeyboardButton(text="🗄️ Бекапы", callback_data="backup_panel")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYSTEM_UPDATES", "📄 Обновления"),
+ callback_data="admin_updates"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYSTEM_BACKUPS", "🗄️ Бекапы"),
+ callback_data="backup_panel"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYSTEM_LOGS", "🧾 Логи"),
+ callback_data="admin_system_logs"
+ )
],
- [InlineKeyboardButton(text="🧾 Логи", callback_data="admin_system_logs")],
[InlineKeyboardButton(text=texts.t("ADMIN_REPORTS", "📊 Отчеты"), callback_data="admin_reports")],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
]
])
def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text="📆 За вчера", callback_data="admin_reports_daily")],
- [InlineKeyboardButton(text="🗓️ За неделю", callback_data="admin_reports_weekly")],
- [InlineKeyboardButton(text="📅 За месяц", callback_data="admin_reports_monthly")],
- [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_REPORTS_PREVIOUS_DAY", "📆 За вчера"),
+ callback_data="admin_reports_daily"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_REPORTS_LAST_WEEK", "🗓️ За неделю"),
+ callback_data="admin_reports_weekly"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_REPORTS_LAST_MONTH", "📅 За месяц"),
+ callback_data="admin_reports_monthly"
+ )
+ ],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")]
])
@@ -152,65 +206,139 @@ def get_admin_report_result_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="👥 Все пользователи", callback_data="admin_users_list"),
- InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USERS_ALL", "👥 Все пользователи"),
+ callback_data="admin_users_list"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USERS_SEARCH", "🔍 Поиск"),
+ callback_data="admin_users_search"
+ )
],
[
- InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"),
- InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive")
+ InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_users_stats"),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USERS_INACTIVE", "🗑️ Неактивные"),
+ callback_data="admin_users_inactive"
+ )
],
[
- InlineKeyboardButton(text="⚙️ Фильтры", callback_data="admin_users_filters")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USERS_FILTERS", "⚙️ Фильтры"),
+ callback_data="admin_users_filters"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_users")
]
])
def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="💰 По балансу", callback_data="admin_users_balance_filter")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USERS_FILTER_BALANCE", "💰 По балансу"),
+ callback_data="admin_users_balance_filter"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USERS_FILTER_TRAFFIC", "📶 По трафику"),
+ callback_data="admin_users_traffic_filter"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USERS_FILTER_ACTIVITY", "🕒 По активности"),
+ callback_data="admin_users_activity_filter"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USERS_FILTER_SPENDING", "💳 По сумме трат"),
+ callback_data="admin_users_spending_filter"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USERS_FILTER_PURCHASES", "🛒 По количеству покупок"),
+ callback_data="admin_users_purchases_filter"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USERS_FILTER_CAMPAIGN", "📢 По кампании"),
+ callback_data="admin_users_campaign_filter"
+ )
+ ],
+ [
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_users")
]
])
def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="📱 Все подписки", callback_data="admin_subs_list"),
- InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SUBSCRIPTIONS_ALL", "📱 Все подписки"),
+ callback_data="admin_subs_list"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SUBSCRIPTIONS_EXPIRING", "⏰ Истекающие"),
+ callback_data="admin_subs_expiring"
+ )
],
[
- InlineKeyboardButton(text="⚙️ Настройки цен", callback_data="admin_subs_pricing"),
- InlineKeyboardButton(text="🌍 Управление странами", callback_data="admin_subs_countries")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SUBSCRIPTIONS_PRICING", "⚙️ Настройки цен"),
+ callback_data="admin_subs_pricing"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SUBSCRIPTIONS_COUNTRIES", "🌍 Управление странами"),
+ callback_data="admin_subs_countries"
+ )
],
[
- InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats")
+ InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_subs_stats")
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_users")
]
])
def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="🎫 Все промокоды", callback_data="admin_promo_list"),
- InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODES_ALL", "🎫 Все промокоды"),
+ callback_data="admin_promo_list"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODES_CREATE", "➕ Создать"),
+ callback_data="admin_promo_create"
+ )
],
[
- InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_promo_general_stats")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODES_GENERAL_STATS", "📊 Общая статистика"),
+ callback_data="admin_promo_general_stats"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_promo")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")
]
])
@@ -220,11 +348,20 @@ def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="📋 Список кампаний", callback_data="admin_campaigns_list"),
- InlineKeyboardButton(text="➕ Создать", callback_data="admin_campaigns_create")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CAMPAIGNS_LIST", "📋 Список кампаний"),
+ callback_data="admin_campaigns_list"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CAMPAIGNS_CREATE", "➕ Создать"),
+ callback_data="admin_campaigns_create"
+ )
],
[
- InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_campaigns_stats")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CAMPAIGNS_GENERAL_STATS", "📊 Общая статистика"),
+ callback_data="admin_campaigns_stats"
+ )
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")
@@ -235,13 +372,18 @@ def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
def get_campaign_management_keyboard(
campaign_id: int, is_active: bool, language: str = "ru"
) -> InlineKeyboardMarkup:
- status_text = "🔴 Выключить" if is_active else "🟢 Включить"
+ texts = get_texts(language)
+ status_text = (
+ _t(texts, "ADMIN_CAMPAIGN_DISABLE", "🔴 Выключить")
+ if is_active
+ else _t(texts, "ADMIN_CAMPAIGN_ENABLE", "🟢 Включить")
+ )
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
- text="📊 Статистика",
+ text=_t(texts, "ADMIN_CAMPAIGN_STATS", "📊 Статистика"),
callback_data=f"admin_campaign_stats_{campaign_id}",
),
InlineKeyboardButton(
@@ -251,19 +393,20 @@ def get_campaign_management_keyboard(
],
[
InlineKeyboardButton(
- text="✏️ Редактировать",
+ text=_t(texts, "ADMIN_CAMPAIGN_EDIT", "✏️ Редактировать"),
callback_data=f"admin_campaign_edit_{campaign_id}",
)
],
[
InlineKeyboardButton(
- text="🗑️ Удалить",
+ text=_t(texts, "ADMIN_CAMPAIGN_DELETE", "🗑️ Удалить"),
callback_data=f"admin_campaign_delete_{campaign_id}",
)
],
[
InlineKeyboardButton(
- text="⬅️ К списку", callback_data="admin_campaigns_list"
+ text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"),
+ callback_data="admin_campaigns_list"
)
],
]
@@ -281,11 +424,11 @@ def get_campaign_edit_keyboard(
keyboard: List[List[InlineKeyboardButton]] = [
[
InlineKeyboardButton(
- text="✏️ Название",
+ text=_t(texts, "ADMIN_CAMPAIGN_EDIT_NAME", "✏️ Название"),
callback_data=f"admin_campaign_edit_name_{campaign_id}",
),
InlineKeyboardButton(
- text="🔗 Параметр",
+ text=_t(texts, "ADMIN_CAMPAIGN_EDIT_START", "🔗 Параметр"),
callback_data=f"admin_campaign_edit_start_{campaign_id}",
),
]
@@ -295,7 +438,7 @@ def get_campaign_edit_keyboard(
keyboard.append(
[
InlineKeyboardButton(
- text="💰 Бонус на баланс",
+ text=_t(texts, "ADMIN_CAMPAIGN_BONUS_BALANCE", "💰 Бонус на баланс"),
callback_data=f"admin_campaign_edit_balance_{campaign_id}",
)
]
@@ -305,21 +448,21 @@ def get_campaign_edit_keyboard(
[
[
InlineKeyboardButton(
- text="📅 Длительность",
+ text=_t(texts, "ADMIN_CAMPAIGN_DURATION", "📅 Длительность"),
callback_data=f"admin_campaign_edit_sub_days_{campaign_id}",
),
InlineKeyboardButton(
- text="🌐 Трафик",
+ text=_t(texts, "ADMIN_CAMPAIGN_TRAFFIC", "🌐 Трафик"),
callback_data=f"admin_campaign_edit_sub_traffic_{campaign_id}",
),
],
[
InlineKeyboardButton(
- text="📱 Устройства",
+ text=_t(texts, "ADMIN_CAMPAIGN_DEVICES", "📱 Устройства"),
callback_data=f"admin_campaign_edit_sub_devices_{campaign_id}",
),
InlineKeyboardButton(
- text="🌍 Серверы",
+ text=_t(texts, "ADMIN_CAMPAIGN_SERVERS", "🌍 Серверы"),
callback_data=f"admin_campaign_edit_sub_servers_{campaign_id}",
),
],
@@ -342,8 +485,14 @@ def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMark
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="💰 Бонус на баланс", callback_data="campaign_bonus_balance"),
- InlineKeyboardButton(text="📱 Подписка", callback_data="campaign_bonus_subscription")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CAMPAIGN_BONUS_BALANCE", "💰 Бонус на баланс"),
+ callback_data="campaign_bonus_balance"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CAMPAIGN_BONUS_SUBSCRIPTION", "📱 Подписка"),
+ callback_data="campaign_bonus_subscription"
+ )
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns")
@@ -352,90 +501,169 @@ def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"promo_edit_{promo_id}"),
- InlineKeyboardButton(text="🔄 Статус", callback_data=f"promo_toggle_{promo_id}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODE_EDIT", "✏️ Редактировать"),
+ callback_data=f"promo_edit_{promo_id}"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODE_TOGGLE", "🔄 Статус"),
+ callback_data=f"promo_toggle_{promo_id}"
+ )
],
[
- InlineKeyboardButton(text="📊 Статистика", callback_data=f"promo_stats_{promo_id}"),
- InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"promo_delete_{promo_id}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODE_STATS", "📊 Статистика"),
+ callback_data=f"promo_stats_{promo_id}"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODE_DELETE", "🗑️ Удалить"),
+ callback_data=f"promo_delete_{promo_id}"
+ )
],
[
- InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_promo_list")
+ InlineKeyboardButton(text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"), callback_data="admin_promo_list")
]
])
def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="📨 Всем пользователям", callback_data="admin_msg_all"),
- InlineKeyboardButton(text="🎯 По подпискам", callback_data="admin_msg_by_sub")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MESSAGES_ALL_USERS", "📨 Всем пользователям"),
+ callback_data="admin_msg_all"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MESSAGES_BY_SUBSCRIPTIONS", "🎯 По подпискам"),
+ callback_data="admin_msg_by_sub"
+ )
],
[
- InlineKeyboardButton(text="🔍 По критериям", callback_data="admin_msg_custom"),
- InlineKeyboardButton(text="📋 История", callback_data="admin_msg_history")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MESSAGES_BY_CRITERIA", "🔍 По критериям"),
+ callback_data="admin_msg_custom"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MESSAGES_HISTORY", "📋 История"),
+ callback_data="admin_msg_history"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_communications")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")
]
])
def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"),
- InlineKeyboardButton(text="⏸️ Остановить", callback_data="admin_mon_stop")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_START", "▶️ Запустить"),
+ callback_data="admin_mon_start"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_STOP", "⏸️ Остановить"),
+ callback_data="admin_mon_stop"
+ )
],
[
- InlineKeyboardButton(text="📊 Статус", callback_data="admin_mon_status"),
- InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_STATUS", "📊 Статус"),
+ callback_data="admin_mon_status"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_LOGS", "📋 Логи"),
+ callback_data="admin_mon_logs"
+ )
],
[
- InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_mon_settings")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_SETTINGS_BUTTON", "⚙️ Настройки"),
+ callback_data="admin_mon_settings"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings")
]
])
def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="📊 Системная статистика", callback_data="admin_rw_system"),
- InlineKeyboardButton(text="🖥️ Управление нодами", callback_data="admin_rw_nodes")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_REMNAWAVE_SYSTEM_STATS", "📊 Системная статистика"),
+ callback_data="admin_rw_system"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_REMNAWAVE_MANAGE_NODES", "🖥️ Управление нодами"),
+ callback_data="admin_rw_nodes"
+ )
],
[
- InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_rw_sync"),
- InlineKeyboardButton(text="🌐 Управление сквадами", callback_data="admin_rw_squads")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_REMNAWAVE_SYNC", "🔄 Синхронизация"),
+ callback_data="admin_rw_sync"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_REMNAWAVE_MANAGE_SQUADS", "🌐 Управление сквадами"),
+ callback_data="admin_rw_squads"
+ )
],
[
- InlineKeyboardButton(text="📈 Трафик", callback_data="admin_rw_traffic")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_REMNAWAVE_TRAFFIC", "📈 Трафик"),
+ callback_data="admin_rw_traffic"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings")
]
])
def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="👥 Пользователи", callback_data="admin_stats_users"),
- InlineKeyboardButton(text="📱 Подписки", callback_data="admin_stats_subs")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_STATS_USERS", "👥 Пользователи"),
+ callback_data="admin_stats_users"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_STATS_SUBSCRIPTIONS", "📱 Подписки"),
+ callback_data="admin_stats_subs"
+ )
],
[
- InlineKeyboardButton(text="💰 Доходы", callback_data="admin_stats_revenue"),
- InlineKeyboardButton(text="🤝 Партнерка", callback_data="admin_stats_referrals")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_STATS_REVENUE", "💰 Доходы"),
+ callback_data="admin_stats_revenue"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_STATS_REFERRALS", "🤝 Партнерка"),
+ callback_data="admin_stats_referrals"
+ )
],
[
- InlineKeyboardButton(text="📊 Общая сводка", callback_data="admin_stats_summary")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_STATS_SUMMARY", "📊 Общая сводка"),
+ callback_data="admin_stats_summary"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_promo")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")
]
])
@@ -445,8 +673,14 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
keyboard = [
[
- InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"),
- InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USER_BALANCE", "💰 Баланс"),
+ callback_data=f"admin_user_balance_{user_id}"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USER_SUBSCRIPTION_SETTINGS", "📱 Подписка и настройки"),
+ callback_data=f"admin_user_subscription_{user_id}"
+ )
],
[
InlineKeyboardButton(
@@ -455,30 +689,51 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
)
],
[
- InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
+ callback_data=f"admin_user_statistics_{user_id}"
+ )
],
[
- InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_transactions_{user_id}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USER_TRANSACTIONS", "📋 Транзакции"),
+ callback_data=f"admin_user_transactions_{user_id}"
+ )
]
]
-
+
if user_status == "active":
keyboard.append([
- InlineKeyboardButton(text="🚫 Заблокировать", callback_data=f"admin_user_block_{user_id}"),
- InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USER_BLOCK", "🚫 Заблокировать"),
+ callback_data=f"admin_user_block_{user_id}"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USER_DELETE", "🗑️ Удалить"),
+ callback_data=f"admin_user_delete_{user_id}"
+ )
])
elif user_status == "blocked":
keyboard.append([
- InlineKeyboardButton(text="✅ Разблокировать", callback_data=f"admin_user_unblock_{user_id}"),
- InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USER_UNBLOCK", "✅ Разблокировать"),
+ callback_data=f"admin_user_unblock_{user_id}"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USER_DELETE", "🗑️ Удалить"),
+ callback_data=f"admin_user_delete_{user_id}"
+ )
])
elif user_status == "deleted":
keyboard.append([
- InlineKeyboardButton(text="❌ Пользователь удален", callback_data="noop")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USER_ALREADY_DELETED", "❌ Пользователь удален"),
+ callback_data="noop"
+ )
])
-
+
keyboard.append([
- InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
+ InlineKeyboardButton(text=texts.BACK, callback_data=back_callback)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -530,21 +785,33 @@ def get_confirmation_keyboard(
def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="💰 Баланс", callback_data="promo_type_balance"),
- InlineKeyboardButton(text="📅 Дни подписки", callback_data="promo_type_days")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODE_TYPE_BALANCE", "💰 Баланс"),
+ callback_data="promo_type_balance"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODE_TYPE_DAYS", "📅 Дни подписки"),
+ callback_data="promo_type_days"
+ )
],
[
- InlineKeyboardButton(text="🎁 Триал", callback_data="promo_type_trial")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODE_TYPE_TRIAL", "🎁 Триал"),
+ callback_data="promo_type_trial"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_promocodes")
]
])
def get_promocode_list_keyboard(promocodes: list, page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
keyboard = []
for promo in promocodes:
@@ -578,65 +845,122 @@ def get_promocode_list_keyboard(promocodes: list, page: int, total_pages: int, l
keyboard.append(pagination_row)
keyboard.extend([
- [InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")],
- [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODES_CREATE", "➕ Создать"),
+ callback_data="admin_promo_create"
+ )
+ ],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_promocodes")]
])
-
+
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="👥 Всем", callback_data="broadcast_all"),
- InlineKeyboardButton(text="📱 С подпиской", callback_data="broadcast_active")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"),
+ callback_data="broadcast_all"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"),
+ callback_data="broadcast_active"
+ )
],
[
- InlineKeyboardButton(text="🎁 Триал", callback_data="broadcast_trial"),
- InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"),
+ callback_data="broadcast_trial"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"),
+ callback_data="broadcast_no_sub"
+ )
],
[
- InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring"),
- InlineKeyboardButton(text="🔚 Истекшие", callback_data="broadcast_expired")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"),
+ callback_data="broadcast_expiring"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"),
+ callback_data="broadcast_expired"
+ )
],
[
- InlineKeyboardButton(text="🧊 Активна 0 ГБ", callback_data="broadcast_active_zero"),
- InlineKeyboardButton(text="🥶 Триал 0 ГБ", callback_data="broadcast_trial_zero")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"),
+ callback_data="broadcast_active_zero"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"),
+ callback_data="broadcast_trial_zero"
+ )
],
- [
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")
- ]
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")]
])
def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="📅 Сегодня", callback_data="criteria_today"),
- InlineKeyboardButton(text="📅 За неделю", callback_data="criteria_week")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CRITERIA_TODAY", "📅 Сегодня"),
+ callback_data="criteria_today"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CRITERIA_WEEK", "📅 За неделю"),
+ callback_data="criteria_week"
+ )
],
[
- InlineKeyboardButton(text="📅 За месяц", callback_data="criteria_month"),
- InlineKeyboardButton(text="⚡ Активные сегодня", callback_data="criteria_active_today")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CRITERIA_MONTH", "📅 За месяц"),
+ callback_data="criteria_month"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CRITERIA_ACTIVE_TODAY", "⚡ Активные сегодня"),
+ callback_data="criteria_active_today"
+ )
],
[
- InlineKeyboardButton(text="💤 Неактивные 7+ дней", callback_data="criteria_inactive_week"),
- InlineKeyboardButton(text="💤 Неактивные 30+ дней", callback_data="criteria_inactive_month")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CRITERIA_INACTIVE_WEEK", "💤 Неактивные 7+ дней"),
+ callback_data="criteria_inactive_week"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CRITERIA_INACTIVE_MONTH", "💤 Неактивные 30+ дней"),
+ callback_data="criteria_inactive_month"
+ )
],
[
- InlineKeyboardButton(text="🤝 Через рефералов", callback_data="criteria_referrals"),
- InlineKeyboardButton(text="🎫 Использовали промокоды", callback_data="criteria_promocodes")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CRITERIA_REFERRALS", "🤝 Через рефералов"),
+ callback_data="criteria_referrals"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CRITERIA_PROMOCODES", "🎫 Использовали промокоды"),
+ callback_data="criteria_promocodes"
+ )
],
[
- InlineKeyboardButton(text="🎯 Прямая регистрация", callback_data="criteria_direct")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CRITERIA_DIRECT", "🎯 Прямая регистрация"),
+ callback_data="criteria_direct"
+ )
],
- [
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")
- ]
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")]
])
def get_broadcast_history_keyboard(page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
keyboard = []
if total_pages > 1:
@@ -659,55 +983,116 @@ def get_broadcast_history_keyboard(page: int, total_pages: int, language: str =
keyboard.append(pagination_row)
keyboard.extend([
- [InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_msg_history")],
- [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_HISTORY_REFRESH", "🔄 Обновить"),
+ callback_data="admin_msg_history"
+ )
+ ],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")]
])
-
+
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
keyboard = [
- [InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
- [InlineKeyboardButton(text="🆕 Только новые", callback_data="sync_new_users")],
- [InlineKeyboardButton(text="📈 Обновить данные", callback_data="sync_update_data")],
[
- InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate"),
- InlineKeyboardButton(text="🧹 Очистка", callback_data="sync_cleanup")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_FULL", "🔄 Полная синхронизация"),
+ callback_data="sync_all_users"
+ )
],
- [InlineKeyboardButton(text="💡 Рекомендации", callback_data="sync_recommendations")],
- [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_ONLY_NEW", "🆕 Только новые"),
+ callback_data="sync_new_users"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_UPDATE", "📈 Обновить данные"),
+ callback_data="sync_update_data"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_VALIDATE", "🔍 Валидация"),
+ callback_data="sync_validate"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_CLEANUP", "🧹 Очистка"),
+ callback_data="sync_cleanup"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_RECOMMENDATIONS", "💡 Рекомендации"),
+ callback_data="sync_recommendations"
+ )
+ ],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_remnawave")]
]
-
+
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_confirmation_keyboard(sync_type: str, language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
keyboard = [
- [InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"confirm_{sync_type}")],
- [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rw_sync")]
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_CONFIRM", "✅ Подтвердить"),
+ callback_data=f"confirm_{sync_type}"
+ )
+ ],
+ [InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_rw_sync")]
]
-
+
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_result_keyboard(sync_type: str, has_errors: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
keyboard = []
-
+
if has_errors:
keyboard.append([
- InlineKeyboardButton(text="🔄 Повторить", callback_data=f"sync_{sync_type}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_RETRY", "🔄 Повторить"),
+ callback_data=f"sync_{sync_type}"
+ )
])
-
+
if sync_type != "all_users":
keyboard.append([
- InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_FULL", "🔄 Полная синхронизация"),
+ callback_data="sync_all_users"
+ )
])
-
+
keyboard.extend([
[
- InlineKeyboardButton(text="📊 Статистика", callback_data="admin_rw_system"),
- InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_STATS_BUTTON", "📊 Статистика"),
+ callback_data="admin_rw_system"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_VALIDATE", "🔍 Валидация"),
+ callback_data="sync_validate"
+ )
],
- [InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")],
- [InlineKeyboardButton(text="🏠 В главное меню", callback_data="admin_remnawave")]
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_BACK", "⬅️ К синхронизации"),
+ callback_data="admin_rw_sync"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BACK_TO_MAIN", "🏠 В главное меню"),
+ callback_data="admin_remnawave"
+ )
+ ]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -715,104 +1100,185 @@ def get_sync_result_keyboard(sync_type: str, has_errors: bool = False, language:
def get_period_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="📅 Сегодня", callback_data="period_today"),
- InlineKeyboardButton(text="📅 Вчера", callback_data="period_yesterday")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PERIOD_TODAY", "📅 Сегодня"),
+ callback_data="period_today"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PERIOD_YESTERDAY", "📅 Вчера"),
+ callback_data="period_yesterday"
+ )
],
[
- InlineKeyboardButton(text="📅 Неделя", callback_data="period_week"),
- InlineKeyboardButton(text="📅 Месяц", callback_data="period_month")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PERIOD_WEEK", "📅 Неделя"),
+ callback_data="period_week"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PERIOD_MONTH", "📅 Месяц"),
+ callback_data="period_month"
+ )
],
[
- InlineKeyboardButton(text="📅 Все время", callback_data="period_all")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PERIOD_ALL", "📅 Все время"),
+ callback_data="period_all"
+ )
],
- [
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")
- ]
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_statistics")]
])
def get_node_management_keyboard(node_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="▶️ Включить", callback_data=f"node_enable_{node_uuid}"),
- InlineKeyboardButton(text="⏸️ Отключить", callback_data=f"node_disable_{node_uuid}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_NODE_ENABLE", "▶️ Включить"),
+ callback_data=f"node_enable_{node_uuid}"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_NODE_DISABLE", "⏸️ Отключить"),
+ callback_data=f"node_disable_{node_uuid}"
+ )
],
[
- InlineKeyboardButton(text="🔄 Перезагрузить", callback_data=f"node_restart_{node_uuid}"),
- InlineKeyboardButton(text="📊 Статистика", callback_data=f"node_stats_{node_uuid}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_NODE_RESTART", "🔄 Перезагрузить"),
+ callback_data=f"node_restart_{node_uuid}"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_NODE_STATS", "📊 Статистика"),
+ callback_data=f"node_stats_{node_uuid}"
+ )
],
- [
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_nodes")
- ]
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_rw_nodes")]
])
def get_squad_management_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="👥 Добавить всех пользователей", callback_data=f"squad_add_users_{squad_uuid}"),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SQUAD_ADD_ALL", "👥 Добавить всех пользователей"),
+ callback_data=f"squad_add_users_{squad_uuid}"
+ ),
],
[
- InlineKeyboardButton(text="❌ Удалить всех пользователей", callback_data=f"squad_remove_users_{squad_uuid}"),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SQUAD_REMOVE_ALL", "❌ Удалить всех пользователей"),
+ callback_data=f"squad_remove_users_{squad_uuid}"
+ ),
],
[
- InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"squad_edit_{squad_uuid}"),
- InlineKeyboardButton(text="🗑️ Удалить сквад", callback_data=f"squad_delete_{squad_uuid}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SQUAD_EDIT", "✏️ Редактировать"),
+ callback_data=f"squad_edit_{squad_uuid}"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SQUAD_DELETE", "🗑️ Удалить сквад"),
+ callback_data=f"squad_delete_{squad_uuid}"
+ )
],
- [
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_squads")
- ]
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_rw_squads")]
])
def get_squad_edit_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="🔧 Изменить инбаунды", callback_data=f"squad_edit_inbounds_{squad_uuid}"),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SQUAD_EDIT_INBOUNDS", "🔧 Изменить инбаунды"),
+ callback_data=f"squad_edit_inbounds_{squad_uuid}"
+ ),
],
[
- InlineKeyboardButton(text="✏️ Переименовать", callback_data=f"squad_rename_{squad_uuid}"),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SQUAD_RENAME", "✏️ Переименовать"),
+ callback_data=f"squad_rename_{squad_uuid}"
+ ),
],
[
- InlineKeyboardButton(text="⬅️ Назад к сквадам", callback_data=f"admin_squad_manage_{squad_uuid}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BACK_TO_SQUADS", "⬅️ Назад к сквадам"),
+ callback_data=f"admin_squad_manage_{squad_uuid}"
+ )
]
])
-def get_monitoring_keyboard() -> InlineKeyboardMarkup:
+def get_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"),
- InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_START", "▶️ Запустить"),
+ callback_data="admin_mon_start"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_STOP_HARD", "⏹️ Остановить"),
+ callback_data="admin_mon_stop"
+ )
],
[
- InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"),
- InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_FORCE_CHECK", "🔄 Принудительная проверка"),
+ callback_data="admin_mon_force_check"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_LOGS", "📋 Логи"),
+ callback_data="admin_mon_logs"
+ )
],
[
- InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"),
- InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_TEST_NOTIFICATIONS", "🧪 Тест уведомлений"),
+ callback_data="admin_mon_test_notifications"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_STATISTICS", "📊 Статистика"),
+ callback_data="admin_mon_statistics"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад в админку", callback_data="admin_panel")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BACK_TO_ADMIN", "⬅️ Назад в админку"),
+ callback_data="admin_panel"
+ )
]
])
-def get_monitoring_logs_keyboard() -> InlineKeyboardMarkup:
+def get_monitoring_logs_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
- InlineKeyboardButton(text="🗑️ Очистить старые", callback_data="admin_mon_clear_logs")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_HISTORY_REFRESH", "🔄 Обновить"),
+ callback_data="admin_mon_logs"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_CLEAR_OLD", "🗑️ Очистить старые"),
+ callback_data="admin_mon_clear_logs"
+ )
],
- [
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")
- ]
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_monitoring")]
])
def get_monitoring_logs_navigation_keyboard(
- current_page: int,
+ current_page: int,
total_pages: int,
- has_logs: bool = True
+ has_logs: bool = True,
+ language: str = "ru"
) -> InlineKeyboardMarkup:
+ texts = get_texts(language)
keyboard = []
if total_pages > 1:
@@ -839,179 +1305,274 @@ def get_monitoring_logs_navigation_keyboard(
management_row = []
+ refresh_button = InlineKeyboardButton(
+ text=_t(texts, "ADMIN_HISTORY_REFRESH", "🔄 Обновить"),
+ callback_data="admin_mon_logs"
+ )
+
if has_logs:
management_row.extend([
- InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
- InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
+ refresh_button,
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_CLEAR", "🗑️ Очистить"),
+ callback_data="admin_mon_clear_logs"
+ )
])
else:
- management_row.append(
- InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs")
- )
+ management_row.append(refresh_button)
keyboard.append(management_row)
keyboard.append([
- InlineKeyboardButton(text="⬅️ Назад к мониторингу", callback_data="admin_monitoring")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BACK_TO_MONITORING", "⬅️ Назад к мониторингу"),
+ callback_data="admin_monitoring"
+ )
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
-def get_log_detail_keyboard(log_id: int, current_page: int = 1) -> InlineKeyboardMarkup:
+def get_log_detail_keyboard(log_id: int, current_page: int = 1, language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
- text="🗑️ Удалить этот лог",
+ text=_t(texts, "ADMIN_MONITORING_DELETE_LOG", "🗑️ Удалить этот лог"),
callback_data=f"admin_mon_delete_log_{log_id}"
)
],
[
InlineKeyboardButton(
- text="⬅️ К списку логов",
+ text=_t(texts, "ADMIN_MONITORING_BACK_TO_LOGS", "⬅️ К списку логов"),
callback_data=f"admin_mon_logs_page_{current_page}"
)
]
])
-def get_monitoring_clear_confirm_keyboard() -> InlineKeyboardMarkup:
+def get_monitoring_clear_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_mon_clear_logs_confirm"),
- InlineKeyboardButton(text="❌ Отмена", callback_data="admin_mon_logs")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_CONFIRM_CLEAR", "✅ Да, очистить"),
+ callback_data="admin_mon_clear_logs_confirm"
+ ),
+ InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_mon_logs")
],
[
- InlineKeyboardButton(text="🗑️ Очистить ВСЕ логи", callback_data="admin_mon_clear_all_logs")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_CLEAR_ALL", "🗑️ Очистить ВСЕ логи"),
+ callback_data="admin_mon_clear_all_logs"
+ )
]
])
def get_monitoring_status_keyboard(
is_running: bool,
- last_check_ago_minutes: int = 0
+ last_check_ago_minutes: int = 0,
+ language: str = "ru"
) -> InlineKeyboardMarkup:
+ texts = get_texts(language)
keyboard = []
-
+
control_row = []
if is_running:
control_row.extend([
- InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop"),
- InlineKeyboardButton(text="🔄 Перезапустить", callback_data="admin_mon_restart")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_STOP_HARD", "⏹️ Остановить"),
+ callback_data="admin_mon_stop"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_RESTART", "🔄 Перезапустить"),
+ callback_data="admin_mon_restart"
+ )
])
else:
control_row.append(
- InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_START", "▶️ Запустить"),
+ callback_data="admin_mon_start"
+ )
)
-
+
keyboard.append(control_row)
-
+
monitoring_row = []
-
+
if not is_running or last_check_ago_minutes > 10:
monitoring_row.append(
InlineKeyboardButton(
- text="⚡ Срочная проверка",
+ text=_t(texts, "ADMIN_MONITORING_FORCE_CHECK", "⚡ Срочная проверка"),
callback_data="admin_mon_force_check"
)
)
else:
monitoring_row.append(
InlineKeyboardButton(
- text="🔄 Проверить сейчас",
+ text=_t(texts, "ADMIN_MONITORING_CHECK_NOW", "🔄 Проверить сейчас"),
callback_data="admin_mon_force_check"
)
)
-
+
keyboard.append(monitoring_row)
-
+
info_row = [
- InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs"),
- InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics")
+ InlineKeyboardButton(text=_t(texts, "ADMIN_MONITORING_LOGS", "📋 Логи"), callback_data="admin_mon_logs"),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_STATISTICS", "📊 Статистика"),
+ callback_data="admin_mon_statistics"
+ )
]
keyboard.append(info_row)
-
+
test_row = [
- InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_TEST_NOTIFICATIONS", "🧪 Тест уведомлений"),
+ callback_data="admin_mon_test_notifications"
+ )
]
keyboard.append(test_row)
-
+
keyboard.append([
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings")
])
-
+
return InlineKeyboardMarkup(inline_keyboard=keyboard)
-def get_monitoring_settings_keyboard() -> InlineKeyboardMarkup:
+def get_monitoring_settings_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="⏱️ Интервал проверки", callback_data="admin_mon_set_interval"),
- InlineKeyboardButton(text="🔔 Уведомления", callback_data="admin_mon_toggle_notifications")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_SET_INTERVAL", "⏱️ Интервал проверки"),
+ callback_data="admin_mon_set_interval"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_NOTIFICATIONS", "🔔 Уведомления"),
+ callback_data="admin_mon_toggle_notifications"
+ )
],
[
- InlineKeyboardButton(text="💳 Настройки автооплаты", callback_data="admin_mon_autopay_settings"),
- InlineKeyboardButton(text="🧹 Автоочистка логов", callback_data="admin_mon_auto_cleanup")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_AUTOPAY_SETTINGS", "💳 Настройки автооплаты"),
+ callback_data="admin_mon_autopay_settings"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_AUTO_CLEANUP", "🧹 Автоочистка логов"),
+ callback_data="admin_mon_auto_cleanup"
+ )
],
- [
- InlineKeyboardButton(text="⬅️ К мониторингу", callback_data="admin_monitoring")
- ]
+ [InlineKeyboardButton(text=_t(texts, "ADMIN_BACK_TO_MONITORING", "⬅️ К мониторингу"), callback_data="admin_monitoring")]
])
-def get_log_type_filter_keyboard() -> InlineKeyboardMarkup:
+def get_log_type_filter_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="✅ Успешные", callback_data="admin_mon_logs_filter_success"),
- InlineKeyboardButton(text="❌ Ошибки", callback_data="admin_mon_logs_filter_error")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_FILTER_SUCCESS", "✅ Успешные"),
+ callback_data="admin_mon_logs_filter_success"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_FILTER_ERRORS", "❌ Ошибки"),
+ callback_data="admin_mon_logs_filter_error"
+ )
],
[
- InlineKeyboardButton(text="🔄 Циклы мониторинга", callback_data="admin_mon_logs_filter_cycle"),
- InlineKeyboardButton(text="💳 Автооплаты", callback_data="admin_mon_logs_filter_autopay")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_FILTER_CYCLES", "🔄 Циклы мониторинга"),
+ callback_data="admin_mon_logs_filter_cycle"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MONITORING_FILTER_AUTOPAY", "💳 Автооплаты"),
+ callback_data="admin_mon_logs_filter_autopay"
+ )
],
[
- InlineKeyboardButton(text="📋 Все логи", callback_data="admin_mon_logs"),
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")
+ InlineKeyboardButton(text=_t(texts, "ADMIN_MONITORING_ALL_LOGS", "📋 Все логи"), callback_data="admin_mon_logs"),
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_monitoring")
]
])
def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
-
+
+ texts = get_texts(language)
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
- InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SERVERS_LIST", "📋 Список серверов"),
+ callback_data="admin_servers_list"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SERVERS_SYNC", "🔄 Синхронизация"),
+ callback_data="admin_servers_sync"
+ )
],
[
- InlineKeyboardButton(text="➕ Добавить сервер", callback_data="admin_servers_add"),
- InlineKeyboardButton(text="📊 Статистика", callback_data="admin_servers_stats")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SERVERS_ADD", "➕ Добавить сервер"),
+ callback_data="admin_servers_add"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SERVERS_STATS", "📊 Статистика"),
+ callback_data="admin_servers_stats"
+ )
],
- [
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
- ]
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_subscriptions")]
])
def get_server_edit_keyboard(server_id: int, is_available: bool, language: str = "ru") -> InlineKeyboardMarkup:
-
+ texts = get_texts(language)
+
+ toggle_text = _t(texts, "ADMIN_SERVER_DISABLE", "❌ Отключить") if is_available else _t(texts, "ADMIN_SERVER_ENABLE", "✅ Включить")
+
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server_id}"),
- InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server_id}")
- ],
- [
- InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server_id}"),
- InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server_id}")
- ],
- [
- InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server_id}")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SERVER_EDIT_NAME", "✏️ Название"),
+ callback_data=f"admin_server_edit_name_{server_id}"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SERVER_EDIT_PRICE", "💰 Цена"),
+ callback_data=f"admin_server_edit_price_{server_id}"
+ )
],
[
InlineKeyboardButton(
- text="❌ Отключить" if is_available else "✅ Включить",
+ text=_t(texts, "ADMIN_SERVER_EDIT_COUNTRY", "🌍 Страна"),
+ callback_data=f"admin_server_edit_country_{server_id}"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SERVER_EDIT_LIMIT", "👥 Лимит"),
+ callback_data=f"admin_server_edit_limit_{server_id}"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SERVER_EDIT_DESCRIPTION", "📝 Описание"),
+ callback_data=f"admin_server_edit_desc_{server_id}"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=toggle_text,
callback_data=f"admin_server_toggle_{server_id}"
)
],
[
- InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server_id}"),
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SERVER_DELETE", "🗑️ Удалить"),
+ callback_data=f"admin_server_delete_{server_id}"
+ ),
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_servers_list")
]
])
@@ -1023,6 +1584,7 @@ def get_admin_pagination_keyboard(
back_callback: str = "admin_panel",
language: str = "ru"
) -> InlineKeyboardMarkup:
+ texts = get_texts(language)
keyboard = []
if total_pages > 1:
@@ -1048,74 +1610,75 @@ def get_admin_pagination_keyboard(
keyboard.append(row)
keyboard.append([
- InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
+ InlineKeyboardButton(text=texts.BACK, callback_data=back_callback)
])
-
+
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_maintenance_keyboard(
- language: str,
- is_maintenance_active: bool,
+ language: str,
+ is_maintenance_active: bool,
is_monitoring_active: bool,
panel_has_issues: bool = False
) -> InlineKeyboardMarkup:
+ texts = get_texts(language)
keyboard = []
-
+
if is_maintenance_active:
keyboard.append([
InlineKeyboardButton(
- text="🟢 Выключить техработы",
+ text=_t(texts, "ADMIN_MAINTENANCE_DISABLE", "🟢 Выключить техработы"),
callback_data="maintenance_toggle"
)
])
else:
keyboard.append([
InlineKeyboardButton(
- text="🔧 Включить техработы",
+ text=_t(texts, "ADMIN_MAINTENANCE_ENABLE", "🔧 Включить техработы"),
callback_data="maintenance_toggle"
)
])
-
+
if is_monitoring_active:
keyboard.append([
InlineKeyboardButton(
- text="⏹️ Остановить мониторинг",
+ text=_t(texts, "ADMIN_MAINTENANCE_STOP_MONITORING", "⏹️ Остановить мониторинг"),
callback_data="maintenance_monitoring"
)
])
else:
keyboard.append([
InlineKeyboardButton(
- text="▶️ Запустить мониторинг",
+ text=_t(texts, "ADMIN_MAINTENANCE_START_MONITORING", "▶️ Запустить мониторинг"),
callback_data="maintenance_monitoring"
)
])
-
+
keyboard.append([
InlineKeyboardButton(
- text="🔍 Проверить API",
+ text=_t(texts, "ADMIN_MAINTENANCE_CHECK_API", "🔍 Проверить API"),
callback_data="maintenance_check_api"
),
InlineKeyboardButton(
- text="🌐 Статус панели" + ("⚠️" if panel_has_issues else ""),
+ text=_t(texts, "ADMIN_MAINTENANCE_PANEL_STATUS", "🌐 Статус панели") + ("⚠️" if panel_has_issues else ""),
callback_data="maintenance_check_panel"
)
])
-
+
keyboard.append([
InlineKeyboardButton(
- text="📢 Отправить уведомление",
+ text=_t(texts, "ADMIN_MAINTENANCE_SEND_NOTIFICATION", "📢 Отправить уведомление"),
callback_data="maintenance_manual_notify"
)
])
-
+
keyboard.append([
InlineKeyboardButton(
- text="🔄 Обновить",
+ text=_t(texts, "ADMIN_REFRESH", "🔄 Обновить"),
callback_data="maintenance_panel"
),
InlineKeyboardButton(
- text="⬅️ Назад",
+ text=texts.BACK,
callback_data="admin_submenu_settings"
)
])
@@ -1123,37 +1686,62 @@ def get_maintenance_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
keyboard = [
- [InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
- [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_SYNC_FULL", "🔄 Полная синхронизация"),
+ callback_data="sync_all_users"
+ )
+ ],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_remnawave")]
]
-
+
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) -> InlineKeyboardMarkup:
-
- toggle_text = "🔴 Отключить" if is_enabled else "🟢 Включить"
+
+ texts = get_texts(language)
+ toggle_text = _t(texts, "ADMIN_WELCOME_DISABLE", "🔴 Отключить") if is_enabled else _t(texts, "ADMIN_WELCOME_ENABLE", "🟢 Включить")
toggle_callback = "toggle_welcome_text"
-
+
keyboard = [
[
InlineKeyboardButton(text=toggle_text, callback_data=toggle_callback)
],
[
- InlineKeyboardButton(text="📝 Изменить текст", callback_data="edit_welcome_text"),
- InlineKeyboardButton(text="👁️ Показать текущий", callback_data="show_welcome_text")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_WELCOME_EDIT", "📝 Изменить текст"),
+ callback_data="edit_welcome_text"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_WELCOME_SHOW", "👁️ Показать текущий"),
+ callback_data="show_welcome_text"
+ )
],
[
- InlineKeyboardButton(text="👁️ Предпросмотр", callback_data="preview_welcome_text"),
- InlineKeyboardButton(text="🔄 Сбросить", callback_data="reset_welcome_text")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_WELCOME_PREVIEW", "👁️ Предпросмотр"),
+ callback_data="preview_welcome_text"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_WELCOME_RESET", "🔄 Сбросить"),
+ callback_data="reset_welcome_text"
+ )
],
[
- InlineKeyboardButton(text="🏷️ HTML форматирование", callback_data="show_formatting_help"),
- InlineKeyboardButton(text="💡 Плейсхолдеры", callback_data="show_placeholders_help")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_WELCOME_HTML", "🏷️ HTML форматирование"),
+ callback_data="show_formatting_help"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_WELCOME_PLACEHOLDERS", "💡 Плейсхолдеры"),
+ callback_data="show_placeholders_help"
+ )
],
[
- InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_communications")
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")
]
]
@@ -1162,13 +1750,41 @@ def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) ->
DEFAULT_BROADCAST_BUTTONS = ("home",)
BROADCAST_BUTTONS = {
- "balance": {"text": "💰 Пополнить баланс", "callback": "balance_topup"},
- "referrals": {"text": "🤝 Партнерка", "callback": "menu_referrals"},
- "promocode": {"text": "🎫 Промокод", "callback": "menu_promocode"},
- "connect": {"text": "🔗 Подключиться", "callback": "subscription_connect"},
- "subscription": {"text": "📱 Подписка", "callback": "menu_subscription"},
- "support": {"text": "🛠️ Техподдержка", "callback": "menu_support"},
- "home": {"text": "🏠 На главную", "callback": "back_to_menu"},
+ "balance": {
+ "default_text": "💰 Пополнить баланс",
+ "text_key": "ADMIN_BROADCAST_BUTTON_BALANCE",
+ "callback": "balance_topup",
+ },
+ "referrals": {
+ "default_text": "🤝 Партнерка",
+ "text_key": "ADMIN_BROADCAST_BUTTON_REFERRALS",
+ "callback": "menu_referrals",
+ },
+ "promocode": {
+ "default_text": "🎫 Промокод",
+ "text_key": "ADMIN_BROADCAST_BUTTON_PROMOCODE",
+ "callback": "menu_promocode",
+ },
+ "connect": {
+ "default_text": "🔗 Подключиться",
+ "text_key": "ADMIN_BROADCAST_BUTTON_CONNECT",
+ "callback": "subscription_connect",
+ },
+ "subscription": {
+ "default_text": "📱 Подписка",
+ "text_key": "ADMIN_BROADCAST_BUTTON_SUBSCRIPTION",
+ "callback": "menu_subscription",
+ },
+ "support": {
+ "default_text": "🛠️ Техподдержка",
+ "text_key": "ADMIN_BROADCAST_BUTTON_SUPPORT",
+ "callback": "menu_support",
+ },
+ "home": {
+ "default_text": "🏠 На главную",
+ "text_key": "ADMIN_BROADCAST_BUTTON_HOME",
+ "callback": "back_to_menu",
+ },
}
BROADCAST_BUTTON_ROWS: tuple[tuple[str, ...], ...] = (
@@ -1178,48 +1794,84 @@ BROADCAST_BUTTON_ROWS: tuple[tuple[str, ...], ...] = (
("home",),
)
-BROADCAST_BUTTON_LABELS = {key: value["text"] for key, value in BROADCAST_BUTTONS.items()}
+
+def get_broadcast_button_config(language: str) -> dict[str, dict[str, str]]:
+ texts = get_texts(language)
+ return {
+ key: {
+ "text": texts.t(config["text_key"], config["default_text"]),
+ "callback": config["callback"],
+ }
+ for key, config in BROADCAST_BUTTONS.items()
+ }
+
+
+def get_broadcast_button_labels(language: str) -> dict[str, str]:
+ return {key: value["text"] for key, value in get_broadcast_button_config(language).items()}
def get_message_buttons_selector_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return get_updated_message_buttons_selector_keyboard_with_media(list(DEFAULT_BROADCAST_BUTTONS), False, language)
def get_broadcast_media_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="📷 Добавить фото", callback_data="add_media_photo"),
- InlineKeyboardButton(text="🎥 Добавить видео", callback_data="add_media_video")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_ADD_PHOTO", "📷 Добавить фото"),
+ callback_data="add_media_photo"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_ADD_VIDEO", "🎥 Добавить видео"),
+ callback_data="add_media_video"
+ )
],
[
- InlineKeyboardButton(text="📄 Добавить документ", callback_data="add_media_document"),
- InlineKeyboardButton(text="⏭️ Пропустить медиа", callback_data="skip_media")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_ADD_DOCUMENT", "📄 Добавить документ"),
+ callback_data="add_media_document"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_SKIP_MEDIA", "⏭️ Пропустить медиа"),
+ callback_data="skip_media"
+ )
],
- [
- InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
- ]
+ [InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_messages")]
])
def get_media_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(text="✅ Использовать это медиа", callback_data="confirm_media"),
- InlineKeyboardButton(text="🔄 Заменить медиа", callback_data="replace_media")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_USE_MEDIA", "✅ Использовать это медиа"),
+ callback_data="confirm_media"
+ ),
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_REPLACE_MEDIA", "🔄 Заменить медиа"),
+ callback_data="replace_media"
+ )
],
[
- InlineKeyboardButton(text="⏭️ Без медиа", callback_data="skip_media"),
- InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_NO_MEDIA", "⏭️ Без медиа"),
+ callback_data="skip_media"
+ ),
+ InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_messages")
]
])
def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: list, has_media: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
selected_buttons = selected_buttons or []
+ texts = get_texts(language)
+ button_config_map = get_broadcast_button_config(language)
keyboard: list[list[InlineKeyboardButton]] = []
for row in BROADCAST_BUTTON_ROWS:
row_buttons: list[InlineKeyboardButton] = []
for button_key in row:
- button_config = BROADCAST_BUTTONS[button_key]
+ button_config = button_config_map[button_key]
base_text = button_config["text"]
if button_key in selected_buttons:
if " " in base_text:
@@ -1236,15 +1888,21 @@ def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: l
if has_media:
keyboard.append([
- InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_BROADCAST_CHANGE_MEDIA", "🖼️ Изменить медиа"),
+ callback_data="change_media"
+ )
])
keyboard.extend([
[
- InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_CONTINUE", "✅ Продолжить"),
+ callback_data="buttons_confirm"
+ )
],
[
- InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
+ InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_messages")
]
])
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index b71ef1df..84750376 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -1,19 +1,41 @@
{
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
+ "COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Server management is unavailable — only one server is accessible",
+ "COUNTRY_MANAGEMENT_PROMPT": "🌍 Manage subscription countries\n\n📋 Current countries ({current_count}):\n{current_list}\n\n💡 How it works:\n✅ — currently connected\n➕ — will be added (paid)\n➖ — will be removed (free)\n⚪ — not selected\n\n⚠️ Important: Reconnecting removed countries will be charged again!",
+ "COUNTRY_MANAGEMENT_NONE": "No countries connected",
+ "PAID_FEATURE_ONLY": "⚠ This feature is available only for paid subscriptions",
+ "PAID_FEATURE_ONLY_SHORT": "⚠ Paid subscriptions only",
+ "COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ This server is not available for your promo group",
+ "COUNTRY_CHANGES_NOT_FOUND": "⚠️ No changes detected",
+ "COUNTRY_CHANGES_SUCCESS_HEADER": "✅ Countries updated!\n\n",
+ "COUNTRY_CHANGES_ADDED_HEADER": "➕ Added countries:\n",
+ "COUNTRY_CHANGES_CHARGED": "💰 Charged: {amount} (for {months} mo)",
+ "COUNTRY_CHANGES_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
+ "COUNTRY_CHANGES_REMOVED_HEADER": "➖ Removed countries:\n",
+ "COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Reconnecting later will be charged",
+ "COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 Active countries: {count}",
"ADMIN_MAIN_MENU": "🏠 Main menu",
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
"AUTOPAY_BUTTON": "💳 Auto payment",
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
+ "AUTOPAY_STATUS_ENABLED": "enabled",
+ "AUTOPAY_STATUS_DISABLED": "disabled",
+ "AUTOPAY_MENU_TEXT": "💳 Auto payment\n\n📊 Status: {status}\n⏰ Charge: {days} days before expiry\n\nChoose an action:",
+ "AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
+ "AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Choose how many days before expiry to charge the payment:",
+ "AUTOPAY_DAYS_SET": "✅ Set to {days} days!",
"BACK": "⬅️ Back",
"BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription",
"BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}",
"CANCEL": "❌ Cancel",
"CHANGE_DEVICES_BUTTON": "📱 Change devices",
+ "CHANGE_DEVICES_PROMPT": "📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
+ "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Access paused\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
"CHECK_STATUS_BUTTON": "📊 Check status",
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
"CONFIRM": "✅ Confirm",
@@ -55,6 +77,26 @@
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
"MAIN_MENU_BUTTON": "🏠 Main menu",
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
+ "DEVICE_UUID_NOT_FOUND": "❌ User UUID not found",
+ "DEVICE_NONE_CONNECTED": "ℹ️ You have no connected devices",
+ "DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
+ "DEVICE_MANAGEMENT_OVERVIEW": "🔄 Device management\n\n📊 Total connected: {total} devices\n📄 Page {page} of {pages}\n\n",
+ "DEVICE_MANAGEMENT_CONNECTED_HEADER": "Connected devices:\n",
+ "DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n",
+ "DEVICE_MANAGEMENT_ACTIONS": "\n💡 Actions:\n• Select a device to reset\n• Or reset all devices at once",
+ "DEVICE_FETCH_ERROR": "❌ Failed to load devices",
+ "DEVICE_PAGE_LOAD_ERROR": "❌ Failed to open the page",
+ "DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request",
+ "DEVICE_RESET_PARSE_ERROR": "❌ Failed to process the request",
+ "DEVICE_RESET_SUCCESS": "✅ Device {device} has been reset!",
+ "DEVICE_RESET_ALL_DONE": "ℹ️ All devices have been reset",
+ "DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID",
+ "DEVICE_RESET_NOT_FOUND": "❌ Device not found",
+ "DEVICE_RESET_ERROR": "❌ Failed to reset the device",
+ "DEVICE_LIST_FETCH_ERROR": "❌ Failed to load device list",
+ "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ All devices have been reset!\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect",
+ "DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ Devices reset partially\n\n✅ Removed: {success} devices\n❌ Failed to remove: {failed} devices\n\nTry again or contact support.",
+ "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Couldn't reset devices\n\nPlease try again later or contact support.\n\nTotal devices: {total}",
"MENU_BALANCE": "💰 Balance",
"MENU_SUBSCRIPTION": "📱 Subscription",
"MENU_TRIAL": "🎁 Trial subscription",
@@ -97,6 +139,9 @@
"SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link",
"SKIP_BUTTON": "Skip ➡️",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
+ "SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
+ "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Subscription settings\n\n📊 Current parameters:\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:",
+ "SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ You don't have an active subscription!",
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days",
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
@@ -162,6 +207,22 @@
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.",
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
+ "ADMIN_USER_MANAGEMENT_PROFILE": "👤 User management\n\nMain information:\n• Name: {name}\n• ID: {telegram_id}\n• Username: {username}\n• Status: {status}\n• Language: {language}\n\nFinances:\n• Balance: {balance}\n• Transactions: {transactions}\n\nActivity:\n• Registration: {registration}\n• Last activity: {last_activity}\n• Days since registration: {registration_days}",
+ "ADMIN_USER_USERNAME_NOT_SET": "not set",
+ "ADMIN_USER_STATUS_ACTIVE": "✅ Active",
+ "ADMIN_USER_STATUS_BLOCKED": "🚫 Blocked",
+ "ADMIN_USER_STATUS_DELETED": "🗑️ Deleted",
+ "ADMIN_USER_STATUS_UNKNOWN": "❓ Unknown",
+ "ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Unknown",
+ "ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Trial",
+ "ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Paid",
+ "ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Active",
+ "ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Inactive",
+ "ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} GB",
+ "ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "Subscription:\n• Type: {type}\n• Status: {status}\n• Until: {end_date}\n• Traffic: {traffic}\n• Devices: {devices}\n• Countries: {countries}",
+ "ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Subscription: None",
+ "ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Promo group:\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%",
+ "ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Promo group: Not assigned",
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}",
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}",
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.",
@@ -219,6 +280,19 @@
"DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})",
"DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}",
"DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed",
+ "PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
+ "DEVICE_CHANGE_ACTION_INCREASE": "increase to {count}",
+ "DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {months} mo)",
+ "DEVICE_CHANGE_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
+ "DEVICE_CHANGE_FREE": "Free",
+ "DEVICE_CHANGE_ACTION_DECREASE": "decrease to {count}",
+ "DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
+ "DEVICE_CHANGE_CONFIRMATION": "📱 Confirm change\n\nCurrent amount: {current} devices\nNew amount: {new} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?",
+ "DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Device limit increased!\n\n",
+ "DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
+ "DEVICE_CHANGE_CHARGED": "💰 Charged: {amount}",
+ "DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Device limit decreased!\n\n",
+ "DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Payments are not refunded",
"INVALID_AMOUNT": "❌ Invalid amount",
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n",
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n",
@@ -299,6 +373,9 @@
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
"TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB\n📱 Devices: {devices} pcs\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
+ "TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
+ "TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
+ "ADD_TRAFFIC_PROMPT": "📈 Add traffic to your subscription\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
"USER_NOT_FOUND": "❌ User not found",
"MENU_LANGUAGE": "🌐 Language",
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
@@ -426,6 +503,200 @@
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
- "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
-
+ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
+ "ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings",
+ "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled",
+ "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽",
+ "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.",
+ "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.",
+ "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount",
+ "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:",
+ "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”",
+ "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.",
+ "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.",
+ "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.",
+ "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
+ "ADMIN_REPORTS": "📊 Reports",
+ "ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
+ "ADMIN_TICKETS_TITLE_CLOSED": "🎫 Closed support tickets:",
+ "ADMIN_TICKETS_TITLE_OPEN": "🎫 Open support tickets:",
+ "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:",
+ "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!",
+ "ATTACHMENTS_SENT": "✅ Attachments sent.",
+ "BACK_TO_MENU": "🏠 Back to menu",
+ "BACK_TO_OPEN_TICKETS": "🔴 Open tickets",
+ "BACK_TO_SUPPORT": "⬅️ Back to support",
+ "BACK_TO_TICKETS": "⬅️ Back to tickets",
+ "BALANCE_TOPUP": "💳 Top up balance",
+ "BLOCK_BY_TIME": "⏳ Temporary block",
+ "BLOCK_FOREVER": "🚫 Block permanently",
+ "CAMPAIGN_EXISTING_USERL": "ℹ️ This promotional link is available to new users only.",
+ "CANCEL_REPLY": "❌ Cancel reply",
+ "CANCEL_TICKET_CREATION": "❌ Cancel ticket creation",
+ "CLOSED_TICKETS": "🟢 Closed",
+ "CLOSED_TICKETS_HEADER": "🟢 Closed tickets",
+ "CLOSED_TICKETS_TITLE": "🟢 Closed tickets:",
+ "CLOSE_NOTIFICATION": "❌ Close notification",
+ "CLOSE_TICKET": "🔒 Close ticket",
+ "CONTACT_SUPPORT_BUTTON": "💬 Contact support",
+ "CREATE_TICKET_BUTTON": "🎫 Create ticket",
+ "DELETE_MESSAGE": "🗑 Delete",
+ "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
+ "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.",
+ "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
+ "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
+ "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
+ "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
+ "ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):",
+ "LANGUAGE_SELECTION_DISABLED": "⚙️ Language selection is temporarily unavailable. Using the default language.",
+ "MARK_AS_ANSWERED": "✅ Mark as answered",
+ "MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.",
+ "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Mulen Pay payment\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
+ "MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay",
+ "MULENPAY_TOPUP_PROMPT": "💳 Mulen Pay payment\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.",
+ "MY_TICKETS_BUTTON": "📋 My tickets",
+ "MY_TICKETS_TITLE": "📋 Your tickets:",
+ "NOTIFICATION_CLOSED": "Notification closed.",
+ "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
+ "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
+ "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):",
+ "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):",
+ "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):",
+ "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):",
+ "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):",
+ "NO_ATTACHMENTS": "No attachments.",
+ "NO_CLOSED_TICKETS": "There are no closed tickets yet.",
+ "NO_TICKETS": "You don't have any tickets yet.",
+ "NO_TICKETS_ADMIN": "No tickets to display.",
+ "OPEN_TICKETS": "🔴 Open",
+ "OPEN_TICKETS_HEADER": "🔴 Open tickets",
+ "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
+ "PAL24_PAYMENT_INSTRUCTIONS": "🏦 PayPalych (SBP) payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
+ "PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
+ "PAL24_TOPUP_PROMPT": "🏦 PayPalych (SBP) payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
+ "PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
+ "PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)",
+ "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
+ "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card (Mulen Pay)",
+ "PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
+ "PAYMENT_METHOD_PAL24_NAME": "🏦 SBP (PayPalych)",
+ "REPLY_TO_TICKET": "💬 Reply",
+ "REPORT_CLOSE": "❌ Close",
+ "REPORT_CLOSED": "✅ Report closed.",
+ "REPORT_CLOSE_ERROR": "❌ Failed to close the report.",
+ "SENDING_ATTACHMENTS": "📎 Sending attachments...",
+ "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
+ "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.",
+ "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.",
+ "SUBSCRIPTION_EXTEND": "💎 Extend subscription",
+ "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "
{crypto_link}",
+ "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.",
+ "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.",
+ "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually:",
+ "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ",
+ "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ",
+ "SUPPORT_BUTTON": "🆘 Support",
+ "TICKET_ALREADY_OPEN": "You already have an open ticket. Please close it first.",
+ "TICKET_ATTACHMENTS": "📎 Attachments",
+ "TICKET_CLOSED": "✅ Ticket closed.",
+ "TICKET_CLOSED_NO_REPLY": "❌ The ticket is closed; replying is not possible.",
+ "TICKET_CLOSE_ERROR": "❌ Error closing ticket.",
+ "TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.",
+ "TICKET_CREATE_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
+ "TICKET_CREATION_CANCELLED": "Ticket creation cancelled.",
+ "TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
+ "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.",
+ "TICKET_MESSAGE_INPUT": "Now describe your problem or question:",
+ "TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:",
+ "TICKET_NOT_FOUND": "Ticket not found.",
+ "TICKET_PRIORITY_HIGH": "🟠 High",
+ "TICKET_PRIORITY_LOW": "🟢 Low",
+ "TICKET_PRIORITY_NORMAL": "🟡 Normal",
+ "TICKET_PRIORITY_SELECT": "Select ticket priority:",
+ "TICKET_PRIORITY_URGENT": "🔴 Urgent",
+ "TICKET_REPLY_CANCELLED": "Reply cancelled.",
+ "TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.",
+ "TICKET_REPLY_INPUT": "Enter your reply:",
+ "TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:",
+ "TICKET_REPLY_SENT": "✅ Your reply has been sent!",
+ "TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:",
+ "TICKET_STATUS_ANSWERED": "Answered",
+ "TICKET_STATUS_CLOSED": "Closed",
+ "TICKET_STATUS_OPEN": "Open",
+ "TICKET_STATUS_PENDING": "Pending",
+ "TICKET_TITLE_INPUT": "Enter ticket title:",
+ "TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:",
+ "TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:",
+ "TICKET_UPDATE_ERROR": "❌ Error updating ticket.",
+ "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!",
+ "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!",
+ "UNBLOCK": "✅ Unblock",
+ "USER_BLOCKED_FOREVER": "You are blocked from contacting support.",
+ "USER_BLOCKED_UNTIL": "You are blocked until {time}",
+ "VIEW_CLOSED_TICKETS": "🟢 Closed tickets",
+ "VIEW_TICKET": "👁️ View ticket",
+ "ADMIN_USERS_SUBMENU_TITLE": "👥 **User and subscription management**\n\n",
+ "ADMIN_PROMO_SUBMENU_TITLE": "💰 **Promo codes and statistics**\n\n",
+ "ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Communications**\n\n",
+ "ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Manage broadcasts and interface texts:",
+ "ADMIN_SUBMENU_SELECT_SECTION": "Choose a section:",
+ "ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Support**\n\n",
+ "ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Manage tickets and support settings:",
+ "ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Ticket access only.",
+ "ADMIN_SUPPORT_MODERATION_TITLE": "🧑⚖️ Support moderation",
+ "ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Access to support tickets.",
+ "ADMIN_SUPPORT_AUDIT_TITLE": "🧾 Moderator audit",
+ "ADMIN_SUPPORT_AUDIT_EMPTY": "Nothing here yet",
+ "ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Moderator",
+ "ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Admin",
+ "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Ticket closed",
+ "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Timed block",
+ "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Permanent block",
+ "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock",
+ "ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
+ "ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
+ "ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n",
+ "ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:",
+ "ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Enabled",
+ "ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Disabled",
+ "ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "\"Support\" menu item",
+ "ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Tickets",
+ "ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Contact",
+ "ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Both",
+ "ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Edit description",
+ "ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Admin notifications",
+ "ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "User notifications",
+ "ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
+ "ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ SLA time: {minutes} min",
+ "ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑⚖️ Moderators: {count}",
+ "ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Assign moderator",
+ "ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Remove moderator",
+ "ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 Support settings",
+ "ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Working hours and menu visibility. Current support menu description:",
+ "ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ SLA configuration\n\nEnter the response wait time in minutes (integer > 0):",
+ "ADMIN_SUPPORT_SLA_INVALID": "❌ Enter a valid number of minutes (1-1440)",
+ "ADMIN_SUPPORT_SLA_SAVED": "✅ SLA value saved",
+ "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑⚖️ Assign moderator\n\nSend the user's Telegram ID (number)",
+ "ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑⚖️ Remove moderator\n\nSend the user's Telegram ID (number)",
+ "ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Enter a valid Telegram ID (number)",
+ "ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Moderator {tid} removed",
+ "ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Failed to remove moderator",
+ "ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ User {tid} assigned as moderator",
+ "ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Failed to assign moderator",
+ "ADMIN_SUPPORT_MODERATORS_EMPTY": "List is empty",
+ "ADMIN_SUPPORT_MODERATORS_TITLE": "🧑⚖️ Moderators",
+ "ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Send description",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 Editing support description",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Current description:",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "Contact for \"Contact\" mode",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.",
+ "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.",
+ "ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below",
+ "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted"
}
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index 5824f49d..4ca51d14 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -1,6 +1,20 @@
{
"ACCESS_DENIED": "❌ Доступ запрещен",
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
+ "COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Управление серверами недоступно - доступен только один сервер",
+ "COUNTRY_MANAGEMENT_PROMPT": "🌍 Управление странами подписки\n\n📋 Текущие страны ({current_count}):\n{current_list}\n\n💡 Инструкция:\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪ - не выбрана\n\n⚠️ Важно: Повторное подключение отключенных стран будет платным!",
+ "COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
+ "PAID_FEATURE_ONLY": "⚠ Эта функция доступна только для платных подписок",
+ "PAID_FEATURE_ONLY_SHORT": "⚠ Только для платных подписок",
+ "COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ Сервер недоступен для вашей промогруппы",
+ "COUNTRY_CHANGES_NOT_FOUND": "⚠️ Изменения не обнаружены",
+ "COUNTRY_CHANGES_SUCCESS_HEADER": "✅ Страны успешно обновлены!\n\n",
+ "COUNTRY_CHANGES_ADDED_HEADER": "➕ Добавлены страны:\n",
+ "COUNTRY_CHANGES_CHARGED": "💰 Списано: {amount} (за {months} мес)",
+ "COUNTRY_CHANGES_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
+ "COUNTRY_CHANGES_REMOVED_HEADER": "➖ Отключены страны:\n",
+ "COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Повторное подключение будет платным",
+ "COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 Активных стран: {count}",
"ADMIN_MAIN_MENU": "🏠 Главное меню",
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
"ADMIN_MESSAGES": "📨 Рассылки",
@@ -38,6 +52,22 @@
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
+ "ADMIN_USER_MANAGEMENT_PROFILE": "👤 Управление пользователем\n\nОсновная информация:\n• Имя: {name}\n• ID: {telegram_id}\n• Username: {username}\n• Статус: {status}\n• Язык: {language}\n\nФинансы:\n• Баланс: {balance}\n• Транзакций: {transactions}\n\nАктивность:\n• Регистрация: {registration}\n• Последняя активность: {last_activity}\n• Дней с регистрации: {registration_days}",
+ "ADMIN_USER_USERNAME_NOT_SET": "не указан",
+ "ADMIN_USER_STATUS_ACTIVE": "✅ Активен",
+ "ADMIN_USER_STATUS_BLOCKED": "🚫 Заблокирован",
+ "ADMIN_USER_STATUS_DELETED": "🗑️ Удален",
+ "ADMIN_USER_STATUS_UNKNOWN": "❓ Неизвестно",
+ "ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Неизвестно",
+ "ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Триал",
+ "ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Платная",
+ "ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Активна",
+ "ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Неактивна",
+ "ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} ГБ",
+ "ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "Подписка:\n• Тип: {type}\n• Статус: {status}\n• До: {end_date}\n• Трафик: {traffic}\n• Устройства: {devices}\n• Стран: {countries}",
+ "ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Подписка: Отсутствует",
+ "ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Промогруппа:\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%",
+ "ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Промогруппа: Не назначена",
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}",
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}",
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.",
@@ -72,6 +102,12 @@
"AUTOPAY_FAILED": "\n❌ Ошибка автоплатежа\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
"AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
+ "AUTOPAY_STATUS_ENABLED": "включен",
+ "AUTOPAY_STATUS_DISABLED": "выключен",
+ "AUTOPAY_MENU_TEXT": "💳 Автоплатеж\n\n📊 Статус: {status}\n⏰ Списание за: {days} дн. до окончания\n\nВыберите действие:",
+ "AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
+ "AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Выберите за сколько дней до окончания списывать средства:",
+ "AUTOPAY_DAYS_SET": "✅ Установлено {days} дней!",
"BACK": "⬅️ Назад",
"BACK_TO_SUBSCRIPTION": "⬅️ К подписке",
"BALANCE_BUTTON": "💰 Баланс: {balance}",
@@ -93,6 +129,7 @@
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
"CANCEL": "❌ Отмена",
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
+ "CHANGE_DEVICES_PROMPT": "📱 Изменение количества устройств\n\nТекущий лимит: {current_devices} устройств\nВыберите новое количество устройств:\n\n💡 Важно:\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
"CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
"CHANGE_DEVICES_INFO": "\n 📱 Изменение количества устройств\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 Важно:\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
@@ -103,6 +140,7 @@
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
+ "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Доступ приостановлен\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
"CONFIRM": "✅ Подтвердить",
@@ -128,6 +166,19 @@
"DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})",
"DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}",
"DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось",
+ "PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
+ "DEVICE_CHANGE_ACTION_INCREASE": "увеличить до {count}",
+ "DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} мес)",
+ "DEVICE_CHANGE_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
+ "DEVICE_CHANGE_FREE": "Бесплатно",
+ "DEVICE_CHANGE_ACTION_DECREASE": "уменьшить до {count}",
+ "DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
+ "DEVICE_CHANGE_CONFIRMATION": "📱 Подтверждение изменения\n\nТекущее количество: {current} устройств\nНовое количество: {new} устройств\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить изменение?",
+ "DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Количество устройств увеличено!\n\n",
+ "DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
+ "DEVICE_CHANGE_CHARGED": "💰 Списано: {amount}",
+ "DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Количество устройств уменьшено!\n\n",
+ "DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится",
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
"DEVICE_GUIDE_ANDROID": "🤖 Android",
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
@@ -153,6 +204,26 @@
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
"MAIN_MENU_BUTTON": "🏠 Главное меню",
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
+ "DEVICE_UUID_NOT_FOUND": "❌ UUID пользователя не найден",
+ "DEVICE_NONE_CONNECTED": "ℹ️ У вас нет подключенных устройств",
+ "DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
+ "DEVICE_MANAGEMENT_OVERVIEW": "🔄 Управление устройствами\n\n📊 Всего подключено: {total} устройств\n📄 Страница {page} из {pages}\n\n",
+ "DEVICE_MANAGEMENT_CONNECTED_HEADER": "Подключенные устройства:\n",
+ "DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n",
+ "DEVICE_MANAGEMENT_ACTIONS": "\n💡 Действия:\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу",
+ "DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
+ "DEVICE_PAGE_LOAD_ERROR": "❌ Ошибка загрузки страницы",
+ "DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос",
+ "DEVICE_RESET_PARSE_ERROR": "❌ Ошибка обработки запроса",
+ "DEVICE_RESET_SUCCESS": "✅ Устройство {device} успешно сброшено!",
+ "DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены",
+ "DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства",
+ "DEVICE_RESET_NOT_FOUND": "❌ Устройство не найдено",
+ "DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства",
+ "DEVICE_LIST_FETCH_ERROR": "❌ Ошибка получения списка устройств",
+ "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Все устройства успешно сброшены!\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения",
+ "DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ Частичный сброс устройств\n\n✅ Удалено: {success} устройств\n❌ Не удалось удалить: {failed} устройств\n\nПопробуйте еще раз или обратитесь в поддержку.",
+ "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не удалось сбросить устройства\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}",
"MENU_ADMIN": "⚙️ Админ-панель",
"MENU_BALANCE": "💰 Баланс",
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
@@ -234,6 +305,9 @@
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
+ "SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
+ "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Настройки подписки\n\n📊 Текущие параметры:\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:",
+ "SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ У вас нет активной подписки!",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
"SUBSCRIPTION_SUMMARY": "\n📋 Итоговая конфигурация\n\n📅 Период: {period} дней\n📈 Трафик: {traffic}\n🌍 Страны: {countries}\n📱 Устройства: {devices}\n\n💰 Итого к оплате: {total_price}\n\nПодтвердить покупку?\n",
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
@@ -297,6 +371,9 @@
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
"TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic} ГБ\n📱 Устройства: {devices} шт.\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
+ "TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
+ "TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик",
+ "ADD_TRAFFIC_PROMPT": "📈 Добавить трафик к подписке\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:",
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
"USER_NOT_FOUND": "❌ Пользователь не найден",
@@ -428,6 +505,198 @@
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
- "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
-
+ "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
+ "ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга",
+ "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена",
+ "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽",
+ "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.",
+ "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
+ "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик",
+ "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:",
+ "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»",
+ "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
+ "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.",
+ "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
+ "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
+ "ADMIN_REPORTS": "📊 Отчеты",
+ "ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
+ "ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
+ "ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!",
+ "ATTACHMENTS_SENT": "✅ Вложения отправлены.",
+ "BACK_TO_MENU": "🏠 В главное меню",
+ "BACK_TO_OPEN_TICKETS": "🔴 Открытые тикеты",
+ "BACK_TO_SUPPORT": "⬅️ К поддержке",
+ "BACK_TO_TICKETS": "⬅️ К тикетам",
+ "BALANCE_TOPUP": "💳 Пополнить баланс",
+ "BLOCK_BY_TIME": "⏳ Блокировка по времени",
+ "BLOCK_FOREVER": "🚫 Заблокировать",
+ "CAMPAIGN_EXISTING_USERL": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
+ "CANCEL_REPLY": "❌ Отменить ответ",
+ "CANCEL_TICKET_CREATION": "❌ Отменить создание тикета",
+ "CLOSED_TICKETS": "🟢 Закрытые",
+ "CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты",
+ "CLOSED_TICKETS_TITLE": "🟢 Закрытые тикеты:",
+ "CLOSE_NOTIFICATION": "❌ Закрыть уведомление",
+ "CLOSE_TICKET": "🔒 Закрыть тикет",
+ "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой",
+ "CREATE_TICKET_BUTTON": "🎫 Создать тикет",
+ "DELETE_MESSAGE": "🗑 Удалить",
+ "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
+ "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
+ "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
+ "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
+ "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
+ "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
+ "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):",
+ "LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
+ "MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
+ "MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
+ "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Оплата через Mulen Pay\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
+ "MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay",
+ "MULENPAY_TOPUP_PROMPT": "💳 Оплата через Mulen Pay\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.",
+ "MY_TICKETS_BUTTON": "📋 Мои тикеты",
+ "MY_TICKETS_TITLE": "📋 Ваши тикеты:",
+ "NOTIFICATION_CLOSED": "Уведомление закрыто.",
+ "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
+ "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
+ "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):",
+ "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
+ "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):",
+ "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):",
+ "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):",
+ "NO_ATTACHMENTS": "Вложений нет.",
+ "NO_CLOSED_TICKETS": "Закрытых тикетов пока нет.",
+ "NO_TICKETS": "У вас пока нет тикетов.",
+ "NO_TICKETS_ADMIN": "Нет тикетов для отображения.",
+ "OPEN_TICKETS": "🔴 Открытые",
+ "OPEN_TICKETS_HEADER": "🔴 Открытые тикеты",
+ "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
+ "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
+ "PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
+ "PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
+ "PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
+ "PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
+ "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
+ "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта (Mulen Pay)",
+ "PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
+ "PAYMENT_METHOD_PAL24_NAME": "🏦 СБП (PayPalych)",
+ "REPLY_TO_TICKET": "💬 Ответить",
+ "REPORT_CLOSE": "❌ Закрыть",
+ "REPORT_CLOSED": "✅ Отчет закрыт.",
+ "REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.",
+ "SENDING_ATTACHMENTS": "📎 Отправляю вложения...",
+ "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
+ "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
+ "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.",
+ "SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
+ "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "{crypto_link}",
+ "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
+ "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
+ "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
+ "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ",
+ "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ",
+ "SUPPORT_BUTTON": "🆘 Поддержка",
+ "TICKET_ALREADY_OPEN": "У вас уже есть незакрытый тикет. Сначала закройте его.",
+ "TICKET_ATTACHMENTS": "📎 Вложения",
+ "TICKET_CLOSED": "✅ Тикет закрыт.",
+ "TICKET_CLOSED_NO_REPLY": "❌ Тикет закрыт, ответить невозможно.",
+ "TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.",
+ "TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.",
+ "TICKET_CREATE_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
+ "TICKET_CREATION_CANCELLED": "Создание тикета отменено.",
+ "TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
+ "TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.",
+ "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:",
+ "TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:",
+ "TICKET_NOT_FOUND": "Тикет не найден.",
+ "TICKET_PRIORITY_HIGH": "🟠 Высокий",
+ "TICKET_PRIORITY_LOW": "🟢 Низкий",
+ "TICKET_PRIORITY_NORMAL": "🟡 Обычный",
+ "TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:",
+ "TICKET_PRIORITY_URGENT": "🔴 Срочный",
+ "TICKET_REPLY_CANCELLED": "Ответ отменен.",
+ "TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.",
+ "TICKET_REPLY_INPUT": "Введите ваш ответ:",
+ "TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:",
+ "TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!",
+ "TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:",
+ "TICKET_STATUS_ANSWERED": "Отвечен",
+ "TICKET_STATUS_CLOSED": "Закрыт",
+ "TICKET_STATUS_OPEN": "Открыт",
+ "TICKET_STATUS_PENDING": "В ожидании",
+ "TICKET_TITLE_INPUT": "Введите заголовок тикета:",
+ "TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
+ "TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
+ "TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
+ "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
+ "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
+ "UNBLOCK": "✅ Разблокировать",
+ "USER_BLOCKED_FOREVER": "Вы заблокированы для обращений в поддержку.",
+ "USER_BLOCKED_UNTIL": "Вы заблокированы до {time}",
+ "VIEW_CLOSED_TICKETS": "🟢 Закрытые тикеты",
+ "VIEW_TICKET": "👁️ Посмотреть тикет",
+ "ADMIN_USERS_SUBMENU_TITLE": "👥 **Управление пользователями и подписками**\n\n",
+ "ADMIN_PROMO_SUBMENU_TITLE": "💰 **Промокоды и статистика**\n\n",
+ "ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Коммуникации**\n\n",
+ "ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Управление рассылками и текстами интерфейса:",
+ "ADMIN_SUBMENU_SELECT_SECTION": "Выберите нужный раздел:",
+ "ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
+ "ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Управление тикетами и настройками поддержки:",
+ "ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Доступ к тикетам.",
+ "ADMIN_SUPPORT_MODERATION_TITLE": "🧑⚖️ Модерация поддержки",
+ "ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Доступ к тикетам поддержки.",
+ "ADMIN_SUPPORT_AUDIT_TITLE": "🧾 Аудит модераторов",
+ "ADMIN_SUPPORT_AUDIT_EMPTY": "Пока пусто",
+ "ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Модератор",
+ "ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Админ",
+ "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Закрытие тикета",
+ "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Блокировка (время)",
+ "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Блокировка (навсегда)",
+ "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
+ "ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
+ "ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
+ "ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
+ "ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
+ "ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
+ "ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Отключены",
+ "ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "Пункт «Техподдержка» в меню",
+ "ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Тикеты",
+ "ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Контакт",
+ "ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Оба",
+ "ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Изменить описание",
+ "ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Админ-уведомления",
+ "ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "Пользовательские уведомления",
+ "ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
+ "ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ Время SLA: {minutes} мин",
+ "ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑⚖️ Модераторы: {count}",
+ "ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Назначить модератора",
+ "ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Удалить модератора",
+ "ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 Настройки поддержки",
+ "ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:",
+ "ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):",
+ "ADMIN_SUPPORT_SLA_INVALID": "❌ Введите корректное число минут (1-1440)",
+ "ADMIN_SUPPORT_SLA_SAVED": "✅ Значение SLA сохранено",
+ "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)",
+ "ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)",
+ "ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Введите корректный Telegram ID (число)",
+ "ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Модератор {tid} удалён",
+ "ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Не удалось удалить модератора",
+ "ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ Пользователь {tid} назначен модератором",
+ "ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Не удалось назначить модератора",
+ "ADMIN_SUPPORT_MODERATORS_EMPTY": "Список пуст",
+ "ADMIN_SUPPORT_MODERATORS_TITLE": "🧑⚖️ Модераторы",
+ "ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Прислать текст",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 Редактирование описания поддержки",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Текущее описание:",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "Контакт для режима «Контакт»",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.",
+ "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.",
+ "ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже",
+ "ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено"
}
diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py
index a87638c3..873385a9 100644
--- a/app/services/monitoring_service.py
+++ b/app/services/monitoring_service.py
@@ -18,6 +18,7 @@ from app.database.crud.discount_offer import (
upsert_discount_offer,
)
from app.database.crud.notification import (
+ clear_notification_by_type,
notification_sent,
record_notification,
)
@@ -472,6 +473,10 @@ class MonitoringService:
try:
now = datetime.utcnow()
+ notifications_allowed = (
+ NotificationSettingsService.are_notifications_globally_enabled()
+ and NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
+ )
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
@@ -550,6 +555,22 @@ class MonitoringService:
user.remnawave_uuid,
api_error,
)
+
+ if notifications_allowed:
+ if not await notification_sent(
+ db,
+ user.id,
+ subscription.id,
+ "trial_channel_unsubscribed",
+ ):
+ sent = await self._send_trial_channel_unsubscribed_notification(user)
+ if sent:
+ await record_notification(
+ db,
+ user.id,
+ subscription.id,
+ "trial_channel_unsubscribed",
+ )
elif subscription.status == SubscriptionStatus.DISABLED.value and is_member:
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.utcnow()
@@ -575,6 +596,12 @@ class MonitoringService:
api_error,
)
+ await clear_notification_by_type(
+ db,
+ subscription.id,
+ "trial_channel_unsubscribed",
+ )
+
if disabled_count or restored_count:
await self._log_monitoring_event(
db,
@@ -1040,6 +1067,69 @@ class MonitoringService:
)
return False
+ async def _send_trial_channel_unsubscribed_notification(self, user: User) -> bool:
+ try:
+ texts = get_texts(user.language)
+ template = texts.get(
+ "TRIAL_CHANNEL_UNSUBSCRIBED",
+ (
+ "🚫 Доступ приостановлен\n\n"
+ "Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
+ "Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
+ ),
+ )
+
+ check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
+ message = template.format(check_button=check_button)
+
+ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+
+ buttons = []
+ if settings.CHANNEL_LINK:
+ buttons.append(
+ [
+ InlineKeyboardButton(
+ text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
+ url=settings.CHANNEL_LINK,
+ )
+ ]
+ )
+ buttons.append(
+ [
+ InlineKeyboardButton(
+ text=check_button,
+ callback_data="sub_channel_check",
+ )
+ ]
+ )
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
+
+ await self._send_message_with_logo(
+ chat_id=user.telegram_id,
+ text=message,
+ parse_mode="HTML",
+ reply_markup=keyboard,
+ )
+ return True
+
+ except (TelegramForbiddenError, TelegramBadRequest) as exc:
+ if self._handle_unreachable_user(user, exc, "уведомление об отписке от канала"):
+ return True
+ logger.error(
+ "Ошибка Telegram API при отправке уведомления об отписке от канала пользователю %s: %s",
+ user.telegram_id,
+ exc,
+ )
+ return False
+ except Exception as error:
+ logger.error(
+ "Ошибка отправки уведомления об отписке от канала пользователю %s: %s",
+ user.telegram_id,
+ error,
+ )
+ return False
+
async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool:
try:
texts = get_texts(user.language)
diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py
index 959bf93e..457b1c00 100644
--- a/app/services/notification_settings_service.py
+++ b/app/services/notification_settings_service.py
@@ -20,6 +20,7 @@ class NotificationSettingsService:
_DEFAULTS: Dict[str, Dict[str, Any]] = {
"trial_inactive_1h": {"enabled": True},
"trial_inactive_24h": {"enabled": True},
+ "trial_channel_unsubscribed": {"enabled": True},
"expired_1d": {"enabled": True},
"expired_second_wave": {
"enabled": True,
@@ -138,6 +139,14 @@ class NotificationSettingsService:
def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled("trial_inactive_24h", enabled)
+ @classmethod
+ def is_trial_channel_unsubscribed_enabled(cls) -> bool:
+ return cls.is_enabled("trial_channel_unsubscribed")
+
+ @classmethod
+ def set_trial_channel_unsubscribed_enabled(cls, enabled: bool) -> bool:
+ return cls.set_enabled("trial_channel_unsubscribed", enabled)
+
# Expired subscription notifications
@classmethod
def is_expired_1d_enabled(cls) -> bool:
diff --git a/app/services/payment_service.py b/app/services/payment_service.py
index c615e8cc..6523375d 100644
--- a/app/services/payment_service.py
+++ b/app/services/payment_service.py
@@ -872,9 +872,55 @@ class PaymentService:
logger.error("Pal24 не вернул bill_id: %s", response)
return None
- link_url = response.get("link_url")
- link_page_url = response.get("link_page_url")
- primary_link = link_page_url or link_url
+ def _pick_url(*keys: str) -> Optional[str]:
+ for key in keys:
+ value = response.get(key)
+ if value:
+ return str(value)
+ return None
+
+ transfer_url = _pick_url(
+ "transfer_url",
+ "transferUrl",
+ "transfer_link",
+ "transferLink",
+ "transfer",
+ "sbp_url",
+ "sbpUrl",
+ "sbp_link",
+ "sbpLink",
+ )
+ card_url = _pick_url(
+ "link_url",
+ "linkUrl",
+ "link",
+ "card_url",
+ "cardUrl",
+ "card_link",
+ "cardLink",
+ "payment_url",
+ "paymentUrl",
+ "url",
+ )
+ link_page_url = _pick_url(
+ "link_page_url",
+ "linkPageUrl",
+ "page_url",
+ "pageUrl",
+ )
+
+ primary_link = transfer_url or link_page_url or card_url
+ secondary_link = link_page_url or card_url or transfer_url
+
+ metadata_links = {
+ key: value
+ for key, value in {
+ "sbp": transfer_url,
+ "card": card_url,
+ "page": link_page_url,
+ }.items()
+ if value
+ }
payment = await create_pal24_payment(
db,
@@ -887,11 +933,12 @@ class PaymentService:
type_=response.get("type", "normal"),
currency=response.get("currency", "RUB"),
link_url=primary_link,
- link_page_url=link_page_url or link_url,
+ link_page_url=secondary_link,
ttl=ttl_seconds,
metadata={
"raw_response": response,
"language": language,
+ **({"links": metadata_links} if metadata_links else {}),
},
)
@@ -899,9 +946,12 @@ class PaymentService:
"bill_id": bill_id,
"order_id": order_id,
"link_url": primary_link,
- "link_page_url": link_page_url or link_url,
+ "link_page_url": secondary_link,
"local_payment_id": payment.id,
"amount_kopeks": amount_kopeks,
+ "sbp_url": transfer_url or primary_link,
+ "card_url": card_url,
+ "transfer_url": transfer_url,
}
logger.info(
diff --git a/app/services/user_service.py b/app/services/user_service.py
index 3a20b857..89a6139a 100644
--- a/app/services/user_service.py
+++ b/app/services/user_service.py
@@ -2,13 +2,14 @@ import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import delete, select, update
+from sqlalchemy import delete, select, update, func
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from app.database.crud.user import (
get_user_by_id, get_user_by_telegram_id, get_users_list,
get_users_count, get_users_statistics, get_inactive_users,
- add_user_balance, subtract_user_balance, update_user, delete_user
+ add_user_balance, subtract_user_balance, update_user, delete_user,
+ get_users_spending_stats
)
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.transaction import get_user_transactions_count
@@ -18,7 +19,8 @@ from app.database.models import (
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
- AdvertisingCampaign, PaymentMethod
+ AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
+ TransactionType
)
from app.config import settings
@@ -141,20 +143,32 @@ class UserService:
"has_next": False,
"has_prev": False
}
-
+
async def get_users_page(
self,
db: AsyncSession,
page: int = 1,
limit: int = 20,
status: Optional[UserStatus] = None,
- order_by_balance: bool = False
+ order_by_balance: bool = False,
+ order_by_traffic: bool = False,
+ order_by_last_activity: bool = False,
+ order_by_total_spent: bool = False,
+ order_by_purchase_count: bool = False
) -> Dict[str, Any]:
try:
offset = (page - 1) * limit
users = await get_users_list(
- db, offset=offset, limit=limit, status=status, order_by_balance=order_by_balance
+ db,
+ offset=offset,
+ limit=limit,
+ status=status,
+ order_by_balance=order_by_balance,
+ order_by_traffic=order_by_traffic,
+ order_by_last_activity=order_by_last_activity,
+ order_by_total_spent=order_by_total_spent,
+ order_by_purchase_count=order_by_purchase_count,
)
total_count = await get_users_count(db, status=status)
@@ -179,7 +193,110 @@ class UserService:
"has_next": False,
"has_prev": False
}
-
+
+ async def get_user_spending_stats_map(
+ self,
+ db: AsyncSession,
+ user_ids: List[int]
+ ) -> Dict[int, Dict[str, int]]:
+ try:
+ return await get_users_spending_stats(db, user_ids)
+ except Exception as e:
+ logger.error(f"Ошибка получения статистики трат пользователей: {e}")
+ return {}
+
+ async def get_users_by_campaign_page(
+ self,
+ db: AsyncSession,
+ page: int = 1,
+ limit: int = 20
+ ) -> Dict[str, Any]:
+ try:
+ offset = (page - 1) * limit
+
+ campaign_ranked = (
+ select(
+ AdvertisingCampaignRegistration.user_id.label("user_id"),
+ AdvertisingCampaignRegistration.campaign_id.label("campaign_id"),
+ AdvertisingCampaignRegistration.created_at.label("created_at"),
+ func.row_number()
+ .over(
+ partition_by=AdvertisingCampaignRegistration.user_id,
+ order_by=AdvertisingCampaignRegistration.created_at.desc(),
+ )
+ .label("rn"),
+ )
+ .cte("campaign_ranked")
+ )
+
+ latest_campaign = (
+ select(
+ campaign_ranked.c.user_id,
+ campaign_ranked.c.campaign_id,
+ campaign_ranked.c.created_at,
+ )
+ .where(campaign_ranked.c.rn == 1)
+ .subquery()
+ )
+
+ query = (
+ select(
+ User,
+ AdvertisingCampaign.name.label("campaign_name"),
+ latest_campaign.c.created_at,
+ )
+ .join(latest_campaign, latest_campaign.c.user_id == User.id)
+ .join(
+ AdvertisingCampaign,
+ AdvertisingCampaign.id == latest_campaign.c.campaign_id,
+ )
+ .order_by(
+ AdvertisingCampaign.name.asc(),
+ latest_campaign.c.created_at.desc(),
+ )
+ .offset(offset)
+ .limit(limit)
+ )
+
+ result = await db.execute(query)
+ rows = result.all()
+
+ users = [row[0] for row in rows]
+ campaign_map = {
+ row[0].id: {
+ "campaign_name": row[1],
+ "registered_at": row[2],
+ }
+ for row in rows
+ }
+
+ total_stmt = select(func.count()).select_from(latest_campaign)
+ total_result = await db.execute(total_stmt)
+ total_count = total_result.scalar() or 0
+ total_pages = (total_count + limit - 1) // limit if total_count else 1
+
+ return {
+ "users": users,
+ "campaigns": campaign_map,
+ "current_page": page,
+ "total_pages": total_pages,
+ "total_count": total_count,
+ "has_next": page < total_pages,
+ "has_prev": page > 1,
+ }
+
+ except Exception as e:
+ logger.error(f"Ошибка получения пользователей по кампаниям: {e}")
+ return {
+ "users": [],
+ "campaigns": {},
+ "current_page": 1,
+ "total_pages": 1,
+ "total_count": 0,
+ "has_next": False,
+ "has_prev": False,
+ }
+
async def update_user_balance(
self,
db: AsyncSession,
diff --git a/app/states.py b/app/states.py
index 7dbc74e6..43655b35 100644
--- a/app/states.py
+++ b/app/states.py
@@ -108,6 +108,11 @@ class AdminStates(StatesGroup):
# Состояния для отслеживания источника перехода
viewing_user_from_balance_list = State()
+ viewing_user_from_traffic_list = State()
+ viewing_user_from_last_activity_list = State()
+ viewing_user_from_spending_list = State()
+ viewing_user_from_purchases_list = State()
+ viewing_user_from_campaign_list = State()
class SupportStates(StatesGroup):
waiting_for_message = State()
diff --git a/app/utils/formatters.py b/app/utils/formatters.py
index 07b3dc74..c98d2f65 100644
--- a/app/utils/formatters.py
+++ b/app/utils/formatters.py
@@ -28,7 +28,7 @@ def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str:
return dt.strftime(format_str)
-def format_time_ago(dt: Union[datetime, str]) -> str:
+def format_time_ago(dt: Union[datetime, str], language: str = "ru") -> str:
if isinstance(dt, str):
if dt == "now" or dt == "":
dt = datetime.now()
@@ -40,32 +40,51 @@ def format_time_ago(dt: Union[datetime, str]) -> str:
now = datetime.utcnow()
diff = now - dt
-
+
+ language_code = (language or "ru").split("-")[0].lower()
+
if diff.days > 0:
if diff.days == 1:
- return "вчера"
- elif diff.days < 7:
- return f"{diff.days} дн. назад"
- elif diff.days < 30:
- weeks = diff.days // 7
- return f"{weeks} нед. назад"
- elif diff.days < 365:
- months = diff.days // 30
- return f"{months} мес. назад"
- else:
- years = diff.days // 365
- return f"{years} г. назад"
-
- elif diff.seconds > 3600:
- hours = diff.seconds // 3600
- return f"{hours} ч. назад"
-
- elif diff.seconds > 60:
- minutes = diff.seconds // 60
- return f"{minutes} мин. назад"
-
- else:
- return "только что"
+ return "yesterday" if language_code == "en" else "вчера"
+ if diff.days < 7:
+ value = diff.days
+ if language_code == "en":
+ suffix = "day" if value == 1 else "days"
+ return f"{value} {suffix} ago"
+ return f"{value} дн. назад"
+ if diff.days < 30:
+ value = diff.days // 7
+ if language_code == "en":
+ suffix = "week" if value == 1 else "weeks"
+ return f"{value} {suffix} ago"
+ return f"{value} нед. назад"
+ if diff.days < 365:
+ value = diff.days // 30
+ if language_code == "en":
+ suffix = "month" if value == 1 else "months"
+ return f"{value} {suffix} ago"
+ return f"{value} мес. назад"
+ value = diff.days // 365
+ if language_code == "en":
+ suffix = "year" if value == 1 else "years"
+ return f"{value} {suffix} ago"
+ return f"{value} г. назад"
+
+ if diff.seconds > 3600:
+ value = diff.seconds // 3600
+ if language_code == "en":
+ suffix = "hour" if value == 1 else "hours"
+ return f"{value} {suffix} ago"
+ return f"{value} ч. назад"
+
+ if diff.seconds > 60:
+ value = diff.seconds // 60
+ if language_code == "en":
+ suffix = "minute" if value == 1 else "minutes"
+ return f"{value} {suffix} ago"
+ return f"{value} мин. назад"
+
+ return "just now" if language_code == "en" else "только что"
def format_days_declension(days: int, language: str = "ru") -> str:
if language != "ru":
diff --git a/locales/en.json b/locales/en.json
index b4efddaf..d970b566 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -15,6 +15,7 @@
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
+ "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Access paused\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
"CHECK_STATUS_BUTTON": "📊 Check status",
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
"CONFIRM": "✅ Confirm",
@@ -85,7 +86,13 @@
"PAL24_TOPUP_PROMPT": "🏦 PayPalych (SBP) payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
- "PAL24_PAYMENT_INSTRUCTIONS": "🏦 PayPalych (SBP) payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
+ "PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
+ "PAL24_CARD_PAY_BUTTON": "💳 Pay with a bank card (PayPalych)",
+ "PAL24_PAYMENT_INSTRUCTIONS": "🏦 PayPalych payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n{steps}\n\n❓ Need help? Contact {support}",
+ "PAL24_INSTRUCTION_BUTTON": "{step}. Press “{button}”",
+ "PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions",
+ "PAL24_INSTRUCTION_CONFIRM": "{step}. Confirm the transfer",
+ "PAL24_INSTRUCTION_COMPLETE": "{step}. The funds will be credited automatically",
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
@@ -207,7 +214,6 @@
"NO_TICKETS_ADMIN": "No tickets to display.",
"ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
"ADMIN_TICKET_REPLY_INPUT": "Enter support reply:",
-
"ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!",
"TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.",
"TICKET_UPDATE_ERROR": "❌ Error updating ticket.",
@@ -535,5 +541,279 @@
"NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):",
"NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):",
"NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):",
- "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):"
+ "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):",
+ "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
+ "ADMIN_MAIN_PROMO_STATS": "💰 Promo codes / Stats",
+ "ADMIN_MAIN_SUPPORT": "🛟 Support",
+ "ADMIN_MAIN_MESSAGES": "📨 Messages",
+ "ADMIN_MAIN_SETTINGS": "⚙️ Settings",
+ "ADMIN_MAIN_SYSTEM": "🛠️ System",
+ "ADMIN_USERS_SUBMENU_TITLE": "👥 **User and subscription management**\n\n",
+ "ADMIN_PROMO_SUBMENU_TITLE": "💰 **Promo codes and statistics**\n\n",
+ "ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Communications**\n\n",
+ "ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Manage broadcasts and interface texts:",
+ "ADMIN_SUBMENU_SELECT_SECTION": "Choose a section:",
+ "ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Welcome message",
+ "ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages",
+ "ADMIN_SUPPORT_TICKETS": "🎫 Support tickets",
+ "ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit",
+ "ADMIN_SUPPORT_SETTINGS": "🛟 Support settings",
+ "ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Enabled",
+ "ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Disabled",
+ "ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "\"Support\" menu item",
+ "ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Tickets",
+ "ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Contact",
+ "ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Both",
+ "ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Edit description",
+ "ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Admin notifications",
+ "ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "User notifications",
+ "ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
+ "ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ SLA time: {minutes} min",
+ "ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑⚖️ Moderators: {count}",
+ "ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Assign moderator",
+ "ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Remove moderator",
+ "ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 Support settings",
+ "ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Working hours and menu visibility. Current support menu description:",
+ "ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ SLA configuration\n\nEnter the response wait time in minutes (integer > 0):",
+ "ADMIN_SUPPORT_SLA_INVALID": "❌ Enter a valid number of minutes (1-1440)",
+ "ADMIN_SUPPORT_SLA_SAVED": "✅ SLA value saved",
+ "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑⚖️ Assign moderator\n\nSend the user's Telegram ID (number)",
+ "ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑⚖️ Remove moderator\n\nSend the user's Telegram ID (number)",
+ "ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Enter a valid Telegram ID (number)",
+ "ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Moderator {tid} removed",
+ "ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Failed to remove moderator",
+ "ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ User {tid} assigned as moderator",
+ "ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Failed to assign moderator",
+ "ADMIN_SUPPORT_MODERATORS_EMPTY": "List is empty",
+ "ADMIN_SUPPORT_MODERATORS_TITLE": "🧑⚖️ Moderators",
+ "ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Send description",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 Editing support description",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Current description:",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "Contact for \"Contact\" mode",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.",
+ "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.",
+ "ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below",
+ "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted",
+ "ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Support**\n\n",
+ "ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Manage tickets and support settings:",
+ "ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Ticket access only.",
+ "ADMIN_SUPPORT_MODERATION_TITLE": "🧑⚖️ Support moderation",
+ "ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Access to support tickets.",
+ "ADMIN_SUPPORT_AUDIT_TITLE": "🧾 Moderator audit",
+ "ADMIN_SUPPORT_AUDIT_EMPTY": "Nothing here yet",
+ "ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Moderator",
+ "ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Admin",
+ "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Ticket closed",
+ "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Timed block",
+ "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Permanent block",
+ "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock",
+ "ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
+ "ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
+ "ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n",
+ "ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:",
+ "ADMIN_SETTINGS_BOT_CONFIG": "🧩 Bot configuration",
+ "ADMIN_SETTINGS_MAINTENANCE": "🔧 Maintenance",
+ "ADMIN_SYSTEM_UPDATES": "📄 Updates",
+ "ADMIN_SYSTEM_BACKUPS": "🗄️ Backups",
+ "ADMIN_SYSTEM_LOGS": "🧾 Logs",
+ "ADMIN_REPORTS_PREVIOUS_DAY": "📆 Previous day",
+ "ADMIN_REPORTS_LAST_WEEK": "🗓️ Last week",
+ "ADMIN_REPORTS_LAST_MONTH": "📅 Last month",
+ "ADMIN_USERS_ALL": "👥 All users",
+ "ADMIN_USERS_SEARCH": "🔍 Search",
+ "ADMIN_USERS_INACTIVE": "🗑️ Inactive",
+ "ADMIN_USERS_FILTERS": "⚙️ Filters",
+ "ADMIN_USERS_FILTER_BALANCE": "💰 By balance",
+ "ADMIN_USERS_FILTER_TRAFFIC": "📶 By traffic",
+ "ADMIN_USERS_FILTER_ACTIVITY": "🕒 By activity",
+ "ADMIN_USERS_FILTER_SPENDING": "💳 By spending",
+ "ADMIN_USERS_FILTER_PURCHASES": "🛒 By purchases",
+ "ADMIN_USERS_FILTER_CAMPAIGN": "📢 By campaign",
+ "ADMIN_SUBSCRIPTIONS_ALL": "📱 All subscriptions",
+ "ADMIN_SUBSCRIPTIONS_EXPIRING": "⏰ Expiring soon",
+ "ADMIN_SUBSCRIPTIONS_PRICING": "⚙️ Pricing settings",
+ "ADMIN_SUBSCRIPTIONS_COUNTRIES": "🌍 Manage countries",
+ "ADMIN_PROMOCODES_ALL": "🎫 All promo codes",
+ "ADMIN_PROMOCODES_CREATE": "➕ Create",
+ "ADMIN_PROMOCODES_GENERAL_STATS": "📊 Overall statistics",
+ "ADMIN_CAMPAIGNS_LIST": "📋 Campaign list",
+ "ADMIN_CAMPAIGNS_CREATE": "➕ Create",
+ "ADMIN_CAMPAIGNS_GENERAL_STATS": "📊 Overall statistics",
+ "ADMIN_CAMPAIGN_DISABLE": "🔴 Disable",
+ "ADMIN_CAMPAIGN_ENABLE": "🟢 Enable",
+ "ADMIN_CAMPAIGN_STATS": "📊 Statistics",
+ "ADMIN_CAMPAIGN_EDIT": "✏️ Edit",
+ "ADMIN_CAMPAIGN_DELETE": "🗑️ Delete",
+ "ADMIN_BACK_TO_LIST": "⬅️ Back to list",
+ "ADMIN_CAMPAIGN_EDIT_NAME": "✏️ Name",
+ "ADMIN_CAMPAIGN_EDIT_START": "🔗 Parameter",
+ "ADMIN_CAMPAIGN_BONUS_BALANCE": "💰 Balance bonus",
+ "ADMIN_CAMPAIGN_DURATION": "📅 Duration",
+ "ADMIN_CAMPAIGN_TRAFFIC": "🌐 Traffic",
+ "ADMIN_CAMPAIGN_DEVICES": "📱 Devices",
+ "ADMIN_CAMPAIGN_SERVERS": "🌍 Servers",
+ "ADMIN_CAMPAIGN_BONUS_SUBSCRIPTION": "📱 Subscription bonus",
+ "ADMIN_PROMOCODE_EDIT": "✏️ Edit",
+ "ADMIN_PROMOCODE_TOGGLE": "🔄 Status",
+ "ADMIN_PROMOCODE_STATS": "📊 Statistics",
+ "ADMIN_PROMOCODE_DELETE": "🗑️ Delete",
+ "ADMIN_MESSAGES_ALL_USERS": "📨 All users",
+ "ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 By subscriptions",
+ "ADMIN_MESSAGES_BY_CRITERIA": "🔍 By criteria",
+ "ADMIN_MESSAGES_HISTORY": "📋 History",
+ "ADMIN_MONITORING_START": "▶️ Start",
+ "ADMIN_MONITORING_STOP": "⏸️ Stop",
+ "ADMIN_MONITORING_STATUS": "📊 Status",
+ "ADMIN_MONITORING_LOGS": "📋 Logs",
+ "ADMIN_MONITORING_SETTINGS_BUTTON": "⚙️ Settings",
+ "ADMIN_REMNAWAVE_SYSTEM_STATS": "📊 System statistics",
+ "ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Manage nodes",
+ "ADMIN_REMNAWAVE_SYNC": "🔄 Synchronization",
+ "ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Manage squads",
+ "ADMIN_REMNAWAVE_TRAFFIC": "📈 Traffic",
+ "ADMIN_STATS_USERS": "👥 Users",
+ "ADMIN_STATS_SUBSCRIPTIONS": "📱 Subscriptions",
+ "ADMIN_STATS_REVENUE": "💰 Revenue",
+ "ADMIN_STATS_REFERRALS": "🤝 Referrals",
+ "ADMIN_STATS_SUMMARY": "📊 Summary",
+ "ADMIN_STATS_BUTTON": "📊 Statistics",
+ "ADMIN_USER_BALANCE": "💰 Balance",
+ "ADMIN_USER_SUBSCRIPTION_SETTINGS": "📱 Subscription & settings",
+ "ADMIN_USER_STATISTICS": "📊 Statistics",
+ "ADMIN_USER_TRANSACTIONS": "📋 Transactions",
+ "ADMIN_USER_BLOCK": "🚫 Block",
+ "ADMIN_USER_DELETE": "🗑️ Delete",
+ "ADMIN_USER_UNBLOCK": "✅ Unblock",
+ "ADMIN_USER_ALREADY_DELETED": "❌ User deleted",
+ "ADMIN_USER_MANAGEMENT_PROFILE": "👤 User management\n\nMain information:\n• Name: {name}\n• ID: {telegram_id}\n• Username: {username}\n• Status: {status}\n• Language: {language}\n\nFinances:\n• Balance: {balance}\n• Transactions: {transactions}\n\nActivity:\n• Registration: {registration}\n• Last activity: {last_activity}\n• Days since registration: {registration_days}",
+ "ADMIN_USER_USERNAME_NOT_SET": "not set",
+ "ADMIN_USER_STATUS_ACTIVE": "✅ Active",
+ "ADMIN_USER_STATUS_BLOCKED": "🚫 Blocked",
+ "ADMIN_USER_STATUS_DELETED": "🗑️ Deleted",
+ "ADMIN_USER_STATUS_UNKNOWN": "❓ Unknown",
+ "ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Unknown",
+ "ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Trial",
+ "ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Paid",
+ "ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Active",
+ "ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Inactive",
+ "ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} GB",
+ "ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "Subscription:\n• Type: {type}\n• Status: {status}\n• Until: {end_date}\n• Traffic: {traffic}\n• Devices: {devices}\n• Countries: {countries}",
+ "ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Subscription: None",
+ "ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Promo group:\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%",
+ "ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Promo group: Not assigned",
+ "ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance",
+ "ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days",
+ "ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial",
+ "ADMIN_BROADCAST_TARGET_ALL": "👥 Everyone",
+ "ADMIN_BROADCAST_TARGET_ACTIVE": "📱 With subscription",
+ "ADMIN_BROADCAST_TARGET_TRIAL": "🎁 Trial",
+ "ADMIN_BROADCAST_TARGET_NO_SUB": "❌ No subscription",
+ "ADMIN_BROADCAST_TARGET_EXPIRING": "⏰ Expiring",
+ "ADMIN_BROADCAST_TARGET_EXPIRED": "🔚 Expired",
+ "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO": "🧊 Active 0 GB",
+ "ADMIN_BROADCAST_TARGET_TRIAL_ZERO": "🥶 Trial 0 GB",
+ "ADMIN_CRITERIA_TODAY": "📅 Today",
+ "ADMIN_CRITERIA_WEEK": "📅 Last 7 days",
+ "ADMIN_CRITERIA_MONTH": "📅 Last month",
+ "ADMIN_CRITERIA_ACTIVE_TODAY": "⚡ Active today",
+ "ADMIN_CRITERIA_INACTIVE_WEEK": "💤 Inactive 7+ days",
+ "ADMIN_CRITERIA_INACTIVE_MONTH": "💤 Inactive 30+ days",
+ "ADMIN_CRITERIA_REFERRALS": "🤝 Via referrals",
+ "ADMIN_CRITERIA_PROMOCODES": "🎫 Used promo codes",
+ "ADMIN_CRITERIA_DIRECT": "🎯 Direct registration",
+ "ADMIN_HISTORY_REFRESH": "🔄 Refresh",
+ "ADMIN_SYNC_FULL": "🔄 Full sync",
+ "ADMIN_SYNC_ONLY_NEW": "🆕 Only new",
+ "ADMIN_SYNC_UPDATE": "📈 Update data",
+ "ADMIN_SYNC_VALIDATE": "🔍 Validate",
+ "ADMIN_SYNC_CLEANUP": "🧹 Cleanup",
+ "ADMIN_SYNC_RECOMMENDATIONS": "💡 Recommendations",
+ "ADMIN_SYNC_CONFIRM": "✅ Confirm",
+ "ADMIN_SYNC_RETRY": "🔄 Retry",
+ "ADMIN_SYNC_BACK": "⬅️ Back to sync",
+ "ADMIN_BACK_TO_MAIN": "🏠 Back to main menu",
+ "ADMIN_CANCEL": "❌ Cancel",
+ "ADMIN_CONTINUE": "✅ Continue",
+ "ADMIN_PERIOD_TODAY": "📅 Today",
+ "ADMIN_PERIOD_YESTERDAY": "📅 Yesterday",
+ "ADMIN_PERIOD_WEEK": "📅 Week",
+ "ADMIN_PERIOD_MONTH": "📅 Month",
+ "ADMIN_PERIOD_ALL": "📅 All time",
+ "ADMIN_NODE_ENABLE": "▶️ Enable",
+ "ADMIN_NODE_DISABLE": "⏸️ Disable",
+ "ADMIN_NODE_RESTART": "🔄 Restart",
+ "ADMIN_NODE_STATS": "📊 Statistics",
+ "ADMIN_SQUAD_ADD_ALL": "👥 Add all users",
+ "ADMIN_SQUAD_REMOVE_ALL": "❌ Remove all users",
+ "ADMIN_SQUAD_EDIT": "✏️ Edit",
+ "ADMIN_SQUAD_DELETE": "🗑️ Delete squad",
+ "ADMIN_SQUAD_EDIT_INBOUNDS": "🔧 Edit inbounds",
+ "ADMIN_SQUAD_RENAME": "✏️ Rename",
+ "ADMIN_BACK_TO_SQUADS": "⬅️ Back to squads",
+ "ADMIN_MONITORING_STOP_HARD": "⏹️ Stop",
+ "ADMIN_MONITORING_FORCE_CHECK": "🔄 Force check",
+ "ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications",
+ "ADMIN_MONITORING_STATISTICS": "📊 Statistics",
+ "ADMIN_BACK_TO_ADMIN": "⬅️ Back to admin",
+ "ADMIN_MONITORING_CLEAR_OLD": "🗑️ Clear old",
+ "ADMIN_MONITORING_CLEAR": "🗑️ Clear",
+ "ADMIN_BACK_TO_MONITORING": "⬅️ Back to monitoring",
+ "ADMIN_MONITORING_DELETE_LOG": "🗑️ Delete this log",
+ "ADMIN_MONITORING_BACK_TO_LOGS": "⬅️ Back to log list",
+ "ADMIN_MONITORING_CONFIRM_CLEAR": "✅ Yes, clear",
+ "ADMIN_MONITORING_CLEAR_ALL": "🗑️ Clear ALL logs",
+ "ADMIN_MONITORING_RESTART": "🔄 Restart",
+ "ADMIN_MONITORING_CHECK_NOW": "🔄 Check now",
+ "ADMIN_MONITORING_SET_INTERVAL": "⏱️ Check interval",
+ "ADMIN_MONITORING_NOTIFICATIONS": "🔔 Notifications",
+ "ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Auto-pay settings",
+ "ADMIN_MONITORING_AUTO_CLEANUP": "🧹 Auto-clean logs",
+ "ADMIN_MONITORING_FILTER_SUCCESS": "✅ Success",
+ "ADMIN_MONITORING_FILTER_ERRORS": "❌ Errors",
+ "ADMIN_MONITORING_FILTER_CYCLES": "🔄 Monitoring cycles",
+ "ADMIN_MONITORING_FILTER_AUTOPAY": "💳 Auto-payments",
+ "ADMIN_MONITORING_ALL_LOGS": "📋 All logs",
+ "ADMIN_SERVERS_LIST": "📋 Server list",
+ "ADMIN_SERVERS_SYNC": "🔄 Synchronization",
+ "ADMIN_SERVERS_ADD": "➕ Add server",
+ "ADMIN_SERVERS_STATS": "📊 Statistics",
+ "ADMIN_SERVER_DISABLE": "❌ Disable",
+ "ADMIN_SERVER_ENABLE": "✅ Enable",
+ "ADMIN_SERVER_EDIT_NAME": "✏️ Name",
+ "ADMIN_SERVER_EDIT_PRICE": "💰 Price",
+ "ADMIN_SERVER_EDIT_COUNTRY": "🌍 Country",
+ "ADMIN_SERVER_EDIT_LIMIT": "👥 Limit",
+ "ADMIN_SERVER_EDIT_DESCRIPTION": "📝 Description",
+ "ADMIN_SERVER_DELETE": "🗑️ Delete",
+ "ADMIN_MAINTENANCE_DISABLE": "🟢 Disable maintenance",
+ "ADMIN_MAINTENANCE_ENABLE": "🔧 Enable maintenance",
+ "ADMIN_MAINTENANCE_STOP_MONITORING": "⏹️ Stop monitoring",
+ "ADMIN_MAINTENANCE_START_MONITORING": "▶️ Start monitoring",
+ "ADMIN_MAINTENANCE_CHECK_API": "🔍 Check API",
+ "ADMIN_MAINTENANCE_PANEL_STATUS": "🌐 Panel status",
+ "ADMIN_MAINTENANCE_SEND_NOTIFICATION": "📢 Send notification",
+ "ADMIN_REFRESH": "🔄 Refresh",
+ "ADMIN_WELCOME_DISABLE": "🔴 Disable",
+ "ADMIN_WELCOME_ENABLE": "🟢 Enable",
+ "ADMIN_WELCOME_EDIT": "📝 Edit text",
+ "ADMIN_WELCOME_SHOW": "👁️ Show current",
+ "ADMIN_WELCOME_PREVIEW": "👁️ Preview",
+ "ADMIN_WELCOME_RESET": "🔄 Reset",
+ "ADMIN_WELCOME_HTML": "🏷️ HTML formatting",
+ "ADMIN_WELCOME_PLACEHOLDERS": "💡 Placeholders",
+ "ADMIN_BROADCAST_ADD_PHOTO": "📷 Add photo",
+ "ADMIN_BROADCAST_ADD_VIDEO": "🎥 Add video",
+ "ADMIN_BROADCAST_ADD_DOCUMENT": "📄 Add document",
+ "ADMIN_BROADCAST_SKIP_MEDIA": "⏭️ Skip media",
+ "ADMIN_BROADCAST_USE_MEDIA": "✅ Use this media",
+ "ADMIN_BROADCAST_REPLACE_MEDIA": "🔄 Replace media",
+ "ADMIN_BROADCAST_NO_MEDIA": "⏭️ No media",
+ "ADMIN_BROADCAST_CHANGE_MEDIA": "🖼️ Change media",
+ "ADMIN_BROADCAST_BUTTON_BALANCE": "💰 Top up balance",
+ "ADMIN_BROADCAST_BUTTON_REFERRALS": "🤝 Referrals",
+ "ADMIN_BROADCAST_BUTTON_PROMOCODE": "🎫 Promo code",
+ "ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Connect",
+ "ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Subscription",
+ "ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Support",
+ "ADMIN_BROADCAST_BUTTON_HOME": "🏠 Main menu"
}
diff --git a/locales/ru.json b/locales/ru.json
index 0692f952..13c5c434 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -72,7 +72,6 @@
"NO_TICKETS_ADMIN": "Нет тикетов для отображения.",
"ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
"ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
-
"ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!",
"TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.",
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
@@ -187,6 +186,7 @@
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
+ "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Доступ приостановлен\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
"CONFIRM": "✅ Подтвердить",
@@ -275,7 +275,13 @@
"PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
- "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
+ "PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
+ "PAL24_CARD_PAY_BUTTON": "💳 Оплатить банковской картой (PayPalych)",
+ "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n{steps}\n\n❓ Если возникнут проблемы, обратитесь в {support}",
+ "PAL24_INSTRUCTION_BUTTON": "{step}. Нажмите кнопку «{button}»",
+ "PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
+ "PAL24_INSTRUCTION_CONFIRM": "{step}. Подтвердите перевод",
+ "PAL24_INSTRUCTION_COMPLETE": "{step}. Средства зачислятся автоматически",
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
@@ -535,5 +541,279 @@
"NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):",
"NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):",
"NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):",
- "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):"
+ "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):",
+ "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
+ "ADMIN_MAIN_PROMO_STATS": "💰 Промокоды/Статистика",
+ "ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
+ "ADMIN_MAIN_MESSAGES": "📨 Сообщения",
+ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
+ "ADMIN_MAIN_SYSTEM": "🛠️ Система",
+ "ADMIN_USERS_SUBMENU_TITLE": "👥 **Управление пользователями и подписками**\n\n",
+ "ADMIN_PROMO_SUBMENU_TITLE": "💰 **Промокоды и статистика**\n\n",
+ "ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Коммуникации**\n\n",
+ "ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Управление рассылками и текстами интерфейса:",
+ "ADMIN_SUBMENU_SELECT_SECTION": "Выберите нужный раздел:",
+ "ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Приветственный текст",
+ "ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню",
+ "ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
+ "ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов",
+ "ADMIN_SUPPORT_SETTINGS": "🛟 Настройки поддержки",
+ "ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
+ "ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Отключены",
+ "ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "Пункт «Техподдержка» в меню",
+ "ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Тикеты",
+ "ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Контакт",
+ "ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Оба",
+ "ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Изменить описание",
+ "ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Админ-уведомления",
+ "ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "Пользовательские уведомления",
+ "ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
+ "ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ Время SLA: {minutes} мин",
+ "ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑⚖️ Модераторы: {count}",
+ "ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Назначить модератора",
+ "ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Удалить модератора",
+ "ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 Настройки поддержки",
+ "ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:",
+ "ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):",
+ "ADMIN_SUPPORT_SLA_INVALID": "❌ Введите корректное число минут (1-1440)",
+ "ADMIN_SUPPORT_SLA_SAVED": "✅ Значение SLA сохранено",
+ "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)",
+ "ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)",
+ "ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Введите корректный Telegram ID (число)",
+ "ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Модератор {tid} удалён",
+ "ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Не удалось удалить модератора",
+ "ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ Пользователь {tid} назначен модератором",
+ "ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Не удалось назначить модератора",
+ "ADMIN_SUPPORT_MODERATORS_EMPTY": "Список пуст",
+ "ADMIN_SUPPORT_MODERATORS_TITLE": "🧑⚖️ Модераторы",
+ "ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Прислать текст",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 Редактирование описания поддержки",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Текущее описание:",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "Контакт для режима «Контакт»",
+ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.",
+ "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.",
+ "ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже",
+ "ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено",
+ "ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
+ "ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Управление тикетами и настройками поддержки:",
+ "ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Доступ к тикетам.",
+ "ADMIN_SUPPORT_MODERATION_TITLE": "🧑⚖️ Модерация поддержки",
+ "ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Доступ к тикетам поддержки.",
+ "ADMIN_SUPPORT_AUDIT_TITLE": "🧾 Аудит модераторов",
+ "ADMIN_SUPPORT_AUDIT_EMPTY": "Пока пусто",
+ "ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Модератор",
+ "ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Админ",
+ "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Закрытие тикета",
+ "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Блокировка (время)",
+ "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Блокировка (навсегда)",
+ "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
+ "ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
+ "ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
+ "ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
+ "ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
+ "ADMIN_SETTINGS_BOT_CONFIG": "🧩 Конфигурация бота",
+ "ADMIN_SETTINGS_MAINTENANCE": "🔧 Техработы",
+ "ADMIN_SYSTEM_UPDATES": "📄 Обновления",
+ "ADMIN_SYSTEM_BACKUPS": "🗄️ Резервные копии",
+ "ADMIN_SYSTEM_LOGS": "🧾 Логи",
+ "ADMIN_REPORTS_PREVIOUS_DAY": "📆 За вчера",
+ "ADMIN_REPORTS_LAST_WEEK": "🗓️ За неделю",
+ "ADMIN_REPORTS_LAST_MONTH": "📅 За месяц",
+ "ADMIN_USERS_ALL": "👥 Все пользователи",
+ "ADMIN_USERS_SEARCH": "🔍 Поиск",
+ "ADMIN_USERS_INACTIVE": "🗑️ Неактивные",
+ "ADMIN_USERS_FILTERS": "⚙️ Фильтры",
+ "ADMIN_USERS_FILTER_BALANCE": "💰 По балансу",
+ "ADMIN_USERS_FILTER_TRAFFIC": "📶 По трафику",
+ "ADMIN_USERS_FILTER_ACTIVITY": "🕒 По активности",
+ "ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат",
+ "ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок",
+ "ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании",
+ "ADMIN_SUBSCRIPTIONS_ALL": "📱 Все подписки",
+ "ADMIN_SUBSCRIPTIONS_EXPIRING": "⏰ Истекающие",
+ "ADMIN_SUBSCRIPTIONS_PRICING": "⚙️ Настройки цен",
+ "ADMIN_SUBSCRIPTIONS_COUNTRIES": "🌍 Управление странами",
+ "ADMIN_PROMOCODES_ALL": "🎫 Все промокоды",
+ "ADMIN_PROMOCODES_CREATE": "➕ Создать",
+ "ADMIN_PROMOCODES_GENERAL_STATS": "📊 Общая статистика",
+ "ADMIN_CAMPAIGNS_LIST": "📋 Список кампаний",
+ "ADMIN_CAMPAIGNS_CREATE": "➕ Создать",
+ "ADMIN_CAMPAIGNS_GENERAL_STATS": "📊 Общая статистика",
+ "ADMIN_CAMPAIGN_DISABLE": "🔴 Выключить",
+ "ADMIN_CAMPAIGN_ENABLE": "🟢 Включить",
+ "ADMIN_CAMPAIGN_STATS": "📊 Статистика",
+ "ADMIN_CAMPAIGN_EDIT": "✏️ Редактировать",
+ "ADMIN_CAMPAIGN_DELETE": "🗑️ Удалить",
+ "ADMIN_BACK_TO_LIST": "⬅️ К списку",
+ "ADMIN_CAMPAIGN_EDIT_NAME": "✏️ Название",
+ "ADMIN_CAMPAIGN_EDIT_START": "🔗 Параметр",
+ "ADMIN_CAMPAIGN_BONUS_BALANCE": "💰 Бонус на баланс",
+ "ADMIN_CAMPAIGN_DURATION": "📅 Длительность",
+ "ADMIN_CAMPAIGN_TRAFFIC": "🌐 Трафик",
+ "ADMIN_CAMPAIGN_DEVICES": "📱 Устройства",
+ "ADMIN_CAMPAIGN_SERVERS": "🌍 Серверы",
+ "ADMIN_CAMPAIGN_BONUS_SUBSCRIPTION": "📱 Бонус на подписку",
+ "ADMIN_PROMOCODE_EDIT": "✏️ Редактировать",
+ "ADMIN_PROMOCODE_TOGGLE": "🔄 Статус",
+ "ADMIN_PROMOCODE_STATS": "📊 Статистика",
+ "ADMIN_PROMOCODE_DELETE": "🗑️ Удалить",
+ "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям",
+ "ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 По подпискам",
+ "ADMIN_MESSAGES_BY_CRITERIA": "🔍 По критериям",
+ "ADMIN_MESSAGES_HISTORY": "📋 История",
+ "ADMIN_MONITORING_START": "▶️ Запустить",
+ "ADMIN_MONITORING_STOP": "⏸️ Остановить",
+ "ADMIN_MONITORING_STATUS": "📊 Статус",
+ "ADMIN_MONITORING_LOGS": "📋 Логи",
+ "ADMIN_MONITORING_SETTINGS_BUTTON": "⚙️ Настройки",
+ "ADMIN_REMNAWAVE_SYSTEM_STATS": "📊 Системная статистика",
+ "ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Управление нодами",
+ "ADMIN_REMNAWAVE_SYNC": "🔄 Синхронизация",
+ "ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Управление сквадами",
+ "ADMIN_REMNAWAVE_TRAFFIC": "📈 Трафик",
+ "ADMIN_STATS_USERS": "👥 Пользователи",
+ "ADMIN_STATS_SUBSCRIPTIONS": "📱 Подписки",
+ "ADMIN_STATS_REVENUE": "💰 Доходы",
+ "ADMIN_STATS_REFERRALS": "🤝 Партнерка",
+ "ADMIN_STATS_SUMMARY": "📊 Общая сводка",
+ "ADMIN_STATS_BUTTON": "📊 Статистика",
+ "ADMIN_USER_BALANCE": "💰 Баланс",
+ "ADMIN_USER_SUBSCRIPTION_SETTINGS": "📱 Подписка и настройки",
+ "ADMIN_USER_STATISTICS": "📊 Статистика",
+ "ADMIN_USER_TRANSACTIONS": "📋 Транзакции",
+ "ADMIN_USER_BLOCK": "🚫 Заблокировать",
+ "ADMIN_USER_DELETE": "🗑️ Удалить",
+ "ADMIN_USER_UNBLOCK": "✅ Разблокировать",
+ "ADMIN_USER_ALREADY_DELETED": "❌ Пользователь удален",
+ "ADMIN_USER_MANAGEMENT_PROFILE": "👤 Управление пользователем\n\nОсновная информация:\n• Имя: {name}\n• ID: {telegram_id}\n• Username: {username}\n• Статус: {status}\n• Язык: {language}\n\nФинансы:\n• Баланс: {balance}\n• Транзакций: {transactions}\n\nАктивность:\n• Регистрация: {registration}\n• Последняя активность: {last_activity}\n• Дней с регистрации: {registration_days}",
+ "ADMIN_USER_USERNAME_NOT_SET": "не указан",
+ "ADMIN_USER_STATUS_ACTIVE": "✅ Активен",
+ "ADMIN_USER_STATUS_BLOCKED": "🚫 Заблокирован",
+ "ADMIN_USER_STATUS_DELETED": "🗑️ Удален",
+ "ADMIN_USER_STATUS_UNKNOWN": "❓ Неизвестно",
+ "ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Неизвестно",
+ "ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Триал",
+ "ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Платная",
+ "ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Активна",
+ "ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Неактивна",
+ "ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} ГБ",
+ "ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "Подписка:\n• Тип: {type}\n• Статус: {status}\n• До: {end_date}\n• Трафик: {traffic}\n• Устройства: {devices}\n• Стран: {countries}",
+ "ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Подписка: Отсутствует",
+ "ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Промогруппа:\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%",
+ "ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Промогруппа: Не назначена",
+ "ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс",
+ "ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки",
+ "ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал",
+ "ADMIN_BROADCAST_TARGET_ALL": "👥 Всем",
+ "ADMIN_BROADCAST_TARGET_ACTIVE": "📱 С подпиской",
+ "ADMIN_BROADCAST_TARGET_TRIAL": "🎁 Триал",
+ "ADMIN_BROADCAST_TARGET_NO_SUB": "❌ Без подписки",
+ "ADMIN_BROADCAST_TARGET_EXPIRING": "⏰ Истекающие",
+ "ADMIN_BROADCAST_TARGET_EXPIRED": "🔚 Истекшие",
+ "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO": "🧊 Активна 0 ГБ",
+ "ADMIN_BROADCAST_TARGET_TRIAL_ZERO": "🥶 Триал 0 ГБ",
+ "ADMIN_CRITERIA_TODAY": "📅 Сегодня",
+ "ADMIN_CRITERIA_WEEK": "📅 За неделю",
+ "ADMIN_CRITERIA_MONTH": "📅 За месяц",
+ "ADMIN_CRITERIA_ACTIVE_TODAY": "⚡ Активные сегодня",
+ "ADMIN_CRITERIA_INACTIVE_WEEK": "💤 Неактивные 7+ дней",
+ "ADMIN_CRITERIA_INACTIVE_MONTH": "💤 Неактивные 30+ дней",
+ "ADMIN_CRITERIA_REFERRALS": "🤝 Через рефералов",
+ "ADMIN_CRITERIA_PROMOCODES": "🎫 Использовали промокоды",
+ "ADMIN_CRITERIA_DIRECT": "🎯 Прямая регистрация",
+ "ADMIN_HISTORY_REFRESH": "🔄 Обновить",
+ "ADMIN_SYNC_FULL": "🔄 Полная синхронизация",
+ "ADMIN_SYNC_ONLY_NEW": "🆕 Только новые",
+ "ADMIN_SYNC_UPDATE": "📈 Обновить данные",
+ "ADMIN_SYNC_VALIDATE": "🔍 Валидация",
+ "ADMIN_SYNC_CLEANUP": "🧹 Очистка",
+ "ADMIN_SYNC_RECOMMENDATIONS": "💡 Рекомендации",
+ "ADMIN_SYNC_CONFIRM": "✅ Подтвердить",
+ "ADMIN_SYNC_RETRY": "🔄 Повторить",
+ "ADMIN_SYNC_BACK": "⬅️ К синхронизации",
+ "ADMIN_BACK_TO_MAIN": "🏠 В главное меню",
+ "ADMIN_CANCEL": "❌ Отмена",
+ "ADMIN_CONTINUE": "✅ Продолжить",
+ "ADMIN_PERIOD_TODAY": "📅 Сегодня",
+ "ADMIN_PERIOD_YESTERDAY": "📅 Вчера",
+ "ADMIN_PERIOD_WEEK": "📅 Неделя",
+ "ADMIN_PERIOD_MONTH": "📅 Месяц",
+ "ADMIN_PERIOD_ALL": "📅 Все время",
+ "ADMIN_NODE_ENABLE": "▶️ Включить",
+ "ADMIN_NODE_DISABLE": "⏸️ Отключить",
+ "ADMIN_NODE_RESTART": "🔄 Перезагрузить",
+ "ADMIN_NODE_STATS": "📊 Статистика",
+ "ADMIN_SQUAD_ADD_ALL": "👥 Добавить всех пользователей",
+ "ADMIN_SQUAD_REMOVE_ALL": "❌ Удалить всех пользователей",
+ "ADMIN_SQUAD_EDIT": "✏️ Редактировать",
+ "ADMIN_SQUAD_DELETE": "🗑️ Удалить сквад",
+ "ADMIN_SQUAD_EDIT_INBOUNDS": "🔧 Изменить инбаунды",
+ "ADMIN_SQUAD_RENAME": "✏️ Переименовать",
+ "ADMIN_BACK_TO_SQUADS": "⬅️ Назад к сквадам",
+ "ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить",
+ "ADMIN_MONITORING_FORCE_CHECK": "🔄 Принудительная проверка",
+ "ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений",
+ "ADMIN_MONITORING_STATISTICS": "📊 Статистика",
+ "ADMIN_BACK_TO_ADMIN": "⬅️ Назад в админку",
+ "ADMIN_MONITORING_CLEAR_OLD": "🗑️ Очистить старые",
+ "ADMIN_MONITORING_CLEAR": "🗑️ Очистить",
+ "ADMIN_BACK_TO_MONITORING": "⬅️ Назад к мониторингу",
+ "ADMIN_MONITORING_DELETE_LOG": "🗑️ Удалить этот лог",
+ "ADMIN_MONITORING_BACK_TO_LOGS": "⬅️ К списку логов",
+ "ADMIN_MONITORING_CONFIRM_CLEAR": "✅ Да, очистить",
+ "ADMIN_MONITORING_CLEAR_ALL": "🗑️ Очистить ВСЕ логи",
+ "ADMIN_MONITORING_RESTART": "🔄 Перезапустить",
+ "ADMIN_MONITORING_CHECK_NOW": "🔄 Проверить сейчас",
+ "ADMIN_MONITORING_SET_INTERVAL": "⏱️ Интервал проверки",
+ "ADMIN_MONITORING_NOTIFICATIONS": "🔔 Уведомления",
+ "ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Настройки автооплаты",
+ "ADMIN_MONITORING_AUTO_CLEANUP": "🧹 Автоочистка логов",
+ "ADMIN_MONITORING_FILTER_SUCCESS": "✅ Успешные",
+ "ADMIN_MONITORING_FILTER_ERRORS": "❌ Ошибки",
+ "ADMIN_MONITORING_FILTER_CYCLES": "🔄 Циклы мониторинга",
+ "ADMIN_MONITORING_FILTER_AUTOPAY": "💳 Автооплаты",
+ "ADMIN_MONITORING_ALL_LOGS": "📋 Все логи",
+ "ADMIN_SERVERS_LIST": "📋 Список серверов",
+ "ADMIN_SERVERS_SYNC": "🔄 Синхронизация",
+ "ADMIN_SERVERS_ADD": "➕ Добавить сервер",
+ "ADMIN_SERVERS_STATS": "📊 Статистика",
+ "ADMIN_SERVER_DISABLE": "❌ Отключить",
+ "ADMIN_SERVER_ENABLE": "✅ Включить",
+ "ADMIN_SERVER_EDIT_NAME": "✏️ Название",
+ "ADMIN_SERVER_EDIT_PRICE": "💰 Цена",
+ "ADMIN_SERVER_EDIT_COUNTRY": "🌍 Страна",
+ "ADMIN_SERVER_EDIT_LIMIT": "👥 Лимит",
+ "ADMIN_SERVER_EDIT_DESCRIPTION": "📝 Описание",
+ "ADMIN_SERVER_DELETE": "🗑️ Удалить",
+ "ADMIN_MAINTENANCE_DISABLE": "🟢 Выключить техработы",
+ "ADMIN_MAINTENANCE_ENABLE": "🔧 Включить техработы",
+ "ADMIN_MAINTENANCE_STOP_MONITORING": "⏹️ Остановить мониторинг",
+ "ADMIN_MAINTENANCE_START_MONITORING": "▶️ Запустить мониторинг",
+ "ADMIN_MAINTENANCE_CHECK_API": "🔍 Проверить API",
+ "ADMIN_MAINTENANCE_PANEL_STATUS": "🌐 Статус панели",
+ "ADMIN_MAINTENANCE_SEND_NOTIFICATION": "📢 Отправить уведомление",
+ "ADMIN_REFRESH": "🔄 Обновить",
+ "ADMIN_WELCOME_DISABLE": "🔴 Отключить",
+ "ADMIN_WELCOME_ENABLE": "🟢 Включить",
+ "ADMIN_WELCOME_EDIT": "📝 Изменить текст",
+ "ADMIN_WELCOME_SHOW": "👁️ Показать текущий",
+ "ADMIN_WELCOME_PREVIEW": "👁️ Предпросмотр",
+ "ADMIN_WELCOME_RESET": "🔄 Сбросить",
+ "ADMIN_WELCOME_HTML": "🏷️ HTML форматирование",
+ "ADMIN_WELCOME_PLACEHOLDERS": "💡 Плейсхолдеры",
+ "ADMIN_BROADCAST_ADD_PHOTO": "📷 Добавить фото",
+ "ADMIN_BROADCAST_ADD_VIDEO": "🎥 Добавить видео",
+ "ADMIN_BROADCAST_ADD_DOCUMENT": "📄 Добавить документ",
+ "ADMIN_BROADCAST_SKIP_MEDIA": "⏭️ Пропустить медиа",
+ "ADMIN_BROADCAST_USE_MEDIA": "✅ Использовать это медиа",
+ "ADMIN_BROADCAST_REPLACE_MEDIA": "🔄 Заменить медиа",
+ "ADMIN_BROADCAST_NO_MEDIA": "⏭️ Без медиа",
+ "ADMIN_BROADCAST_CHANGE_MEDIA": "🖼️ Изменить медиа",
+ "ADMIN_BROADCAST_BUTTON_BALANCE": "💰 Пополнить баланс",
+ "ADMIN_BROADCAST_BUTTON_REFERRALS": "🤝 Партнерка",
+ "ADMIN_BROADCAST_BUTTON_PROMOCODE": "🎫 Промокод",
+ "ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Подключиться",
+ "ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Подписка",
+ "ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Техподдержка",
+ "ADMIN_BROADCAST_BUTTON_HOME": "🏠 На главную"
}