Промежуточный этап локализации

This commit is contained in:
yazhog
2025-09-18 09:09:55 +03:00
parent 728bd0450d
commit 31c560093d
19 changed files with 630 additions and 625 deletions

View File

@@ -33,6 +33,7 @@ POSTGRES_PASSWORD=secure_password_123
# SQLite настройки (для локального запуска)
SQLITE_PATH=./data/bot.db
LOCALES_PATH=./locales
# Redis
REDIS_URL=redis://redis:6379/0

2
.gitignore vendored
View File

@@ -12,6 +12,8 @@
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
!locales/
!locales/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md

View File

@@ -629,6 +629,16 @@ WEBHOOK_PATH=/webhook
</tr>
</table>
## <20>?<3F>?<3F>?<3F>?<3F>?<3F>?<3F>?
- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> `locales/` <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> `.yml`-<2D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> (`ru.yml`, `en.yml`). <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
- <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> YAML <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> `app/localization/texts.py`. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> (<28><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> `MENU:` + `BALANCE: "Баланс"`  <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> `MENU_BALANCE`). <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>, <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>, <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
- <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>:
1. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> `locales/<<3C><><EFBFBD>>.yml` <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
2. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> (`docker compose restart bot` <20><><EFBFBD> Docker, <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>).
- <20><><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> `LOCALES_PATH` (`./locales` <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>; <20> Docker <20><><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> `/app/locales` <20> `docker-compose.yml`).
- <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> `ru.yml`/`en.yml` <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>, <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD>, <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
---
## 🐳 Docker развертывание

View File

@@ -30,6 +30,7 @@ class Settings(BaseSettings):
POSTGRES_PASSWORD: str = "secure_password_123"
SQLITE_PATH: str = "./data/bot.db"
LOCALES_PATH: str = "./locales"
DATABASE_MODE: str = "auto"

View File

@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
from app.keyboards.inline import get_main_menu_keyboard
from app.localization.texts import get_texts
from app.localization.texts import get_texts, get_rules_sync
from app.database.models import User
from app.utils.user_utils import mark_user_as_had_paid_subscription
from app.database.crud.user_message import get_random_active_message
@@ -71,24 +71,12 @@ async def show_service_rules(
if not rules_text:
texts = get_texts(db_user.language)
rules_text = texts._get_default_rules(db_user.language) if hasattr(texts, '_get_default_rules') else """
📋 <b>Правила использования сервиса</b>
rules_text = get_rules_sync(db_user.language)
1. Запрещается использование сервиса для незаконной деятельности
2. Запрещается нарушение авторских прав
3. Запрещается спам и рассылка вредоносного ПО
4. Запрещается использование сервиса для DDoS атак
5. Один аккаунт - один пользователь
6. Возврат средств производится только в исключительных случаях
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
"""
await callback.message.edit_text(
f"📋 <b>Правила сервиса</b>\n\n{rules_text}",
f"{texts.t('RULES_HEADER', '📋 <b>Правила сервиса</b>')}\n\n{rules_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
[types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
)
await callback.answer()
@@ -130,33 +118,63 @@ async def handle_back_to_menu(
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
return "❌ Отсутствует"
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
subscription = user.subscription
current_time = datetime.utcnow()
if subscription.end_date <= current_time:
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
days_left = (subscription.end_date - current_time).days
if subscription.is_trial:
if days_left > 1:
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
days=days_left,
)
elif days_left == 1:
return f"🎁 Тестовая подписка\n⚠️ истекает завтра!"
return texts.t(
"SUB_STATUS_TRIAL_TOMORROW",
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
)
else:
return f"🎁 Тестовая подписка\n⚠️ истекает сегодня!"
return texts.t(
"SUB_STATUS_TRIAL_TODAY",
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
else:
if days_left > 7:
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
days=days_left,
)
elif days_left > 1:
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
return texts.t(
"SUB_STATUS_ACTIVE_FEW_DAYS",
"💎 Активна\n⚠️ истекает через {days} дн.",
).format(days=days_left)
elif days_left == 1:
return f"💎 Активна\n⚠️ истекает завтра!"
return texts.t(
"SUB_STATUS_ACTIVE_TOMORROW",
"💎 Активна\n⚠️ истекает завтра!",
)
else:
return f"💎 Активна\n⚠️ истекает сегодня!"
return texts.t(
"SUB_STATUS_ACTIVE_TODAY",
"💎 Активна\n⚠️ истекает сегодня!",
)
async def get_main_menu_text(user, texts, db: AsyncSession):

View File

@@ -6,6 +6,7 @@ from app.database.models import User
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.pricing_utils import format_period_description
import logging
@@ -21,31 +22,39 @@ def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
]
])
def get_channel_sub_keyboard(channel_link: str) -> InlineKeyboardMarkup:
def get_channel_sub_keyboard(
channel_link: str,
language: str = DEFAULT_LANGUAGE,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="🔗 Подписаться", url=channel_link
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
url=channel_link,
)
],
[
InlineKeyboardButton(
text="✅ Я подписался", callback_data="sub_channel_check"
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
callback_data="sub_channel_check",
)
]
],
]
)
def get_post_registration_keyboard() -> InlineKeyboardMarkup:
def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🚀 Подключиться бесплатно 🚀", callback_data="trial_activate"
text=texts.t("POST_REGISTRATION_TRIAL_BUTTON", "🚀 Подключиться бесплатно 🚀"),
callback_data="trial_activate"
)
],
[InlineKeyboardButton(text="Пропустить ➡️", callback_data="back_to_menu")],
[InlineKeyboardButton(text=texts.t("SKIP_BUTTON", "Пропустить ➡️"), callback_data="back_to_menu")],
])
@@ -66,7 +75,10 @@ def get_main_menu_keyboard(
if hasattr(texts, 'BALANCE_BUTTON') and balance_kopeks > 0:
balance_button_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(balance_kopeks))
else:
balance_button_text = f"💰 Баланс: {texts.format_price(balance_kopeks)}"
balance_button_text = texts.t(
"BALANCE_BUTTON_DEFAULT",
"💰 Баланс: {balance}",
).format(balance=texts.format_price(balance_kopeks))
keyboard = []
@@ -75,28 +87,28 @@ def get_main_menu_keyboard(
if connect_mode == "miniapp_subscription":
keyboard.append([
InlineKeyboardButton(
text="🔗 Подключиться",
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=subscription.subscription_url)
)
])
elif connect_mode == "miniapp_custom":
keyboard.append([
InlineKeyboardButton(
text="🔗 Подключиться",
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
)
])
elif connect_mode == "link":
keyboard.append([
InlineKeyboardButton(
text="🔗 Подключиться",
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
url=subscription.subscription_url
)
])
else:
keyboard.append([
InlineKeyboardButton(
text="🔗 Подключиться",
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="subscription_connect"
)
])
@@ -197,7 +209,7 @@ def get_subscription_keyboard(
if connect_mode == "miniapp_subscription":
keyboard.append([
InlineKeyboardButton(
text="🔗 Подключиться",
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=subscription.subscription_url)
)
])
@@ -205,21 +217,21 @@ def get_subscription_keyboard(
if settings.MINIAPP_CUSTOM_URL:
keyboard.append([
InlineKeyboardButton(
text="🔗 Подключиться",
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
)
])
else:
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")
])
elif connect_mode == "link":
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)
])
else:
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")
])
if not is_trial:

View File

@@ -33,19 +33,19 @@ def get_admin_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text="👥 Пользователи"),
KeyboardButton(text="📱 Подписки")
KeyboardButton(text=texts.ADMIN_USERS),
KeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS)
],
[
KeyboardButton(text="🎫 Промокоды"),
KeyboardButton(text="📨 Рассылки")
KeyboardButton(text=texts.ADMIN_PROMOCODES),
KeyboardButton(text=texts.ADMIN_MESSAGES)
],
[
KeyboardButton(text="📊 Статистика"),
KeyboardButton(text="🔧 Мониторинг")
KeyboardButton(text=texts.ADMIN_STATISTICS),
KeyboardButton(text=texts.ADMIN_MONITORING)
],
[
KeyboardButton(text="🏠 Главное меню")
KeyboardButton(text=texts.t("ADMIN_MAIN_MENU", "🏠 Главное меню"))
]
],
resize_keyboard=True,
@@ -81,9 +81,10 @@ def get_confirmation_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup
def get_skip_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="⏭️ Пропустить")]
[KeyboardButton(text=texts.REFERRAL_CODE_SKIP)]
],
resize_keyboard=True,
one_time_keyboard=True
@@ -95,10 +96,11 @@ def remove_keyboard() -> ReplyKeyboardRemove:
def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="📱 Отправить контакт", request_contact=True)],
[KeyboardButton(text="❌ Отмена")]
[KeyboardButton(text=texts.t("SEND_CONTACT_BUTTON", "📱 Отправить контакт"), request_contact=True)],
[KeyboardButton(text=texts.CANCEL)]
],
resize_keyboard=True,
one_time_keyboard=True
@@ -106,11 +108,12 @@ def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
def get_location_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)],
[KeyboardButton(text="❌ Отмена")]
[KeyboardButton(text=texts.t("SEND_LOCATION_BUTTON", "📍 Отправить геолокацию"), request_location=True)],
[KeyboardButton(text=texts.CANCEL)]
],
resize_keyboard=True,
one_time_keyboard=True
)
)

View File

@@ -0,0 +1,16 @@
# Sample localization for English language.
# Keys correspond to constants defined in app/localization/texts.py.
WELCOME: |
Welcome to Remnawave Bedolaga Bot!
Update this text to greet users in your own style.
MENU:
BALANCE: "Balance"
SUBSCRIPTION: "Subscription"
RULES_TEXT: |
Remnawave service rules:
1. Follow the law of your jurisdiction.
2. Do not distribute spam or malicious content.
3. Respect other community members.

View File

@@ -0,0 +1,16 @@
# Пример локализации на русском языке.
# Ключи соответствуют именам констант из app/localization/texts.py.
WELCOME: |
Добро пожаловать в Remnawave Bedolaga Bot!
Эти строки заменят стандартное приветствие бота.
MENU:
BALANCE: "Баланс"
SUBSCRIPTION: "Подписка"
RULES_TEXT: |
Правила сервиса Remnawave:
1. Следуйте законам своей страны.
2. Не распространяйте спам и вредоносный контент.
3. Уважайте других пользователей.

126
app/localization/loader.py Normal file
View File

@@ -0,0 +1,126 @@
from __future__ import annotations
import json
import logging
import shutil
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict
from app.config import settings
DEFAULT_LANGUAGE = "ru"
_logger = logging.getLogger(__name__)
_BASE_DIR = Path(__file__).resolve().parent
_DEFAULT_LOCALES_DIR = _BASE_DIR / "locales"
def _resolve_user_locales_dir() -> Path:
path = Path(settings.LOCALES_PATH).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
return path
def ensure_locale_templates() -> None:
destination = _resolve_user_locales_dir()
try:
destination.mkdir(parents=True, exist_ok=True)
except Exception as error:
_logger.warning("Unable to create locales directory %s: %s", destination, error)
return
if any(destination.glob("*")):
return
if not _DEFAULT_LOCALES_DIR.exists():
_logger.debug("Default locales directory %s is missing", _DEFAULT_LOCALES_DIR)
return
for template in _DEFAULT_LOCALES_DIR.iterdir():
if not template.is_file():
continue
target_path = destination / template.name
try:
shutil.copyfile(template, target_path)
except Exception as error:
_logger.warning(
"Failed to copy default locale %s to %s: %s",
template,
target_path,
error,
)
def _load_default_locale(language: str) -> Dict[str, Any]:
default_path = _DEFAULT_LOCALES_DIR / f"{language}.json"
if not default_path.exists():
return {}
return _load_locale_file(default_path)
def _load_user_locale(language: str) -> Dict[str, Any]:
user_dir = _resolve_user_locales_dir()
for extension in (".json", ".yml", ".yaml"):
candidate = user_dir / f"{language}{extension}"
if candidate.exists():
return _load_locale_file(candidate)
return {}
def _load_locale_file(path: Path) -> Dict[str, Any]:
suffix = path.suffix.lower()
try:
if suffix == ".json":
return json.loads(path.read_text(encoding="utf-8"))
if suffix in {".yml", ".yaml"}:
try:
import yaml # type: ignore
except ModuleNotFoundError as import_error:
raise RuntimeError(
"PyYAML is required to load YAML locale files. Install PyYAML or provide JSON files."
) from import_error
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as error:
_logger.warning("Failed to parse locale file %s: %s", path, error)
return {}
_logger.warning("Unsupported locale format for %s", path)
return {}
def _merge_dicts(base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
result = dict(base)
for key, value in overrides.items():
if (
key in result
and isinstance(result[key], dict)
and isinstance(value, dict)
):
result[key] = _merge_dicts(result[key], value)
else:
result[key] = value
return result
@lru_cache(maxsize=None)
def load_locale(language: str) -> Dict[str, Any]:
language = language or DEFAULT_LANGUAGE
defaults = _load_default_locale(language)
overrides = _load_user_locale(language)
merged = _merge_dicts(defaults, overrides)
if not merged and language != DEFAULT_LANGUAGE:
_logger.warning(
"Locale %s not found. Falling back to default language %s.",
language,
DEFAULT_LANGUAGE,
)
return load_locale(DEFAULT_LANGUAGE)
return merged
def clear_locale_cache() -> None:
load_locale.cache_clear()

View File

@@ -0,0 +1,32 @@
{
"WELCOME": "\n🎉 <b>Welcome to VPN Service!</b>\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 <b>Advantages:</b>\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n",
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
"BACK": "⬅️ Back",
"CANCEL": "❌ Cancel",
"CONFIRM": "✅ Confirm",
"CONTINUE": "➡️ Continue",
"YES": "✅ Yes",
"NO": "❌ No",
"MENU_BALANCE": "💰 Balance",
"MENU_SUBSCRIPTION": "📱 Subscription",
"MENU_TRIAL": "🎁 Trial subscription",
"INSUFFICIENT_BALANCE": "❌ Insufficient balance. \" \n Top up {amount} and try again.",
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
"ADMIN_MAIN_MENU": "🏠 Main menu",
"SEND_CONTACT_BUTTON": "📱 Share contact",
"SEND_LOCATION_BUTTON": "📍 Share location",
"RULES_HEADER": "📋 <b>Service Rules</b>",
"SUB_STATUS_NONE": "❌ Not available",
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠ expires tomorrow!",
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠ expires today!",
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠ expires in {days} days",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠ expires tomorrow!",
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠ expires today!"
}

View File

@@ -0,0 +1,130 @@
{
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
"RULES_ACCEPT": "✅ Принимаю правила",
"RULES_DECLINE": "❌ Не принимаю",
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
"MENU_BALANCE": "💰 Баланс",
"MENU_SUBSCRIPTION": "📱 Подписка",
"MENU_TRIAL": "🧪 Тестовая подписка",
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
"MENU_PROMOCODE": "🎫 Промокод",
"MENU_REFERRALS": "🤝 Партнерка",
"MENU_SUPPORT": "🛠️ Техподдержка",
"MENU_RULES": "📋 Правила сервиса",
"MENU_LANGUAGE": "🌐 Язык",
"MENU_ADMIN": "⚙️ Админ-панель",
"BALANCE_BUTTON": "💰 Баланс: {balance}",
"BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽",
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
"SUBSCRIPTION_ACTIVE": "✅ Активна",
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Подписка истекла</b>\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n",
"SUBSCRIPTION_INFO": "\n📱 <b>Информация о подписке</b>\n\n📊 <b>Статус:</b> {status}\n🎭 <b>Тип:</b> {type}\n📅 <b>Действует до:</b> {end_date}\n⏰ <b>Осталось дней:</b> {days_left}\n\n📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Серверы:</b> {countries_count} стран\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n Возврат средств не производится\n ",
"DEVICES_NO_CHANGE": " Количество устройств не изменилось",
"DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}",
"DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})",
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
"BUY_SUBSCRIPTION_START": "\n💎 <b>Настройка подписки</b>\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n",
"SELECT_PERIOD": "Выберите период:",
"SELECT_TRAFFIC": "Выберите пакет трафика:",
"SELECT_COUNTRIES": "Выберите страны:",
"SELECT_DEVICES": "Количество устройств:",
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
"BALANCE_HISTORY": "📊 История операций",
"BALANCE_TOP_UP": "💳 Пополнить",
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
"TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):",
"TOP_UP_METHODS": "\n💳 <b>Выберите способ оплаты</b>\n\nСумма: {amount}\n",
"TOP_UP_STARS": "⭐ Telegram Stars",
"TOP_UP_TRIBUTE": "💎 Банковская карта",
"PROMOCODE_ENTER": "🎫 Введите промокод:",
"PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}",
"PROMOCODE_INVALID": "❌ Неверный промокод",
"PROMOCODE_EXPIRED": "❌ Промокод истек",
"PROMOCODE_USED": "❌ Промокод уже использован",
"REFERRAL_INFO": "\n🤝 <b>Реферальная программа</b>\n\n👥 <b>Приглашено:</b> {referrals_count} друзей\n💰 <b>Заработано:</b> {earned_amount}\n\n🔗 <b>Ваша реферальная ссылка:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Условия:</b>\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Приглашение в VPN сервис</b>\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n",
"CREATE_INVITE": "📝 Создать приглашение",
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡ Успейте оформить до окончания тестового периода!\n",
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠ <b>Подписка истекает через {days_text}!</b>\n\nВаша платная подписка истекает {end_date}.\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n\n{action_text}\n",
"AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически",
"AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!",
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
"AUTOPAY_FAILED": "\n❌ <b>Ошибка автоплатежа</b>\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
"SUPPORT_INFO": "\n🛠 <b>Техническая поддержка</b>\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n",
"CONTACT_SUPPORT": "💬 Написать в поддержку",
"ADMIN_PANEL": "\n⚙ <b>Административная панель</b>\n\nВыберите раздел для управления:\n",
"ADMIN_USERS": "👥 Пользователи",
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
"ADMIN_PROMOCODES": "🎫 Промокоды",
"ADMIN_MESSAGES": "📨 Рассылки",
"ADMIN_MONITORING": "🔍 Мониторинг",
"ADMIN_REFERRALS": "🤝 Партнерка",
"ADMIN_RULES": "📋 Правила",
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
"ADMIN_STATISTICS": "📊 Статистика",
"ACCESS_DENIED": "❌ Доступ запрещен",
"USER_NOT_FOUND": "❌ Пользователь не найден",
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
"INVALID_AMOUNT": "❌ Неверная сумма",
"OPERATION_CANCELLED": "❌ Операция отменена",
"SUBSCRIPTION_EXPIRING": "\n⚠ <b>Подписка истекает!</b>\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n",
"SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика",
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Переключение лимита трафика</b>\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n",
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Подтверждение переключения трафика</b>\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n",
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n",
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n Возврат средств не производится\n",
"TRAFFIC_NO_CHANGE": " Лимит трафика не изменился",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
"ADMIN_MAIN_MENU": "🏠 Главное меню",
"SEND_CONTACT_BUTTON": "📱 Отправить контакт",
"SEND_LOCATION_BUTTON": "📍 Отправить геолокацию",
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
"SUB_STATUS_NONE": "❌ Отсутствует",
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠ истекает завтра!",
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠ истекает сегодня!",
"SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)",
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠ истекает через {days} дн.",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠ истекает завтра!",
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠ истекает сегодня!"
}

View File

@@ -1,603 +1,180 @@
from __future__ import annotations
import asyncio
from typing import Dict, Any
import logging
from typing import Any, Dict
from app.config import settings
from app.localization.loader import (
DEFAULT_LANGUAGE,
clear_locale_cache,
load_locale,
)
_cached_rules = {}
_logger = logging.getLogger(__name__)
def _get_default_rules(language: str = "ru") -> str:
if language == "en":
return """
🔒 <b>Service Usage Rules</b>
_cached_rules: Dict[str, str] = {}
1. It is forbidden to use the service for illegal activities
2. Copyright infringement is prohibited
3. Spam and malware distribution are prohibited
4. Using the service for DDoS attacks is prohibited
5. One account - one user
6. Refunds are made only in exceptional cases
7. Administration reserves the right to block an account for violating the rules
<b>By accepting the rules, you agree to comply with them.</b>
"""
else:
return """
📋 <b>Правила использования сервиса</b>
def _build_dynamic_values(language: str) -> Dict[str, Any]:
if language == "ru":
return {
"PERIOD_14_DAYS": f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"SUPPORT_INFO": (
"\n🛠️ <b>Техническая поддержка</b>\n\n"
"По всем вопросам обращайтесь к нашей поддержке:\n\n"
f"👤 {settings.SUPPORT_USERNAME}\n\n"
"Мы поможем с:\n"
"• Настройкой подключения\n"
"• Решением технических проблем \n"
"• Вопросами по оплате\n"
"• Другими вопросами\n\n"
"⏰ Время ответа: обычно в течение 1-2 часов\n"
),
}
return {}
1. Запрещается использование сервиса для незаконной деятельности
2. Запрещается нарушение авторских прав
3. Запрещается спам и рассылка вредоносного ПО
4. Запрещается использование сервиса для DDoS атак
5. Один аккаунт - один пользователь
6. Возврат средств производится только в исключительных случаях
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
"""
class Texts:
def __init__(self, language: str = "ru"):
self.language = language
@property
def RULES_TEXT(self) -> str:
if self.language in _cached_rules:
return _cached_rules[self.language]
return _get_default_rules(self.language)
BACK = "⬅️ Назад"
CANCEL = "❌ Отмена"
CONFIRM = "✅ Подтвердить"
CONTINUE = "➡️ Продолжить"
YES = "✅ Да"
NO = "❌ Нет"
LOADING = "⏳ Загрузка..."
ERROR = "❌ Произошла ошибка"
SUCCESS = "✅ Успешно"
def __init__(self, language: str = DEFAULT_LANGUAGE):
self.language = language or DEFAULT_LANGUAGE
raw_data = load_locale(self.language)
self._values = {key: value for key, value in raw_data.items()}
if self.language != DEFAULT_LANGUAGE:
fallback_data = load_locale(DEFAULT_LANGUAGE)
else:
fallback_data = self._values
self._fallback_values = {
key: value for key, value in fallback_data.items() if key not in self._values
}
self._values.update(_build_dynamic_values(self.language))
def __getattr__(self, item: str) -> Any:
if item == "language":
return super().__getattribute__(item)
try:
return self._get_value(item)
except KeyError as error:
raise AttributeError(item) from error
def __getitem__(self, item: str) -> Any:
return self._get_value(item)
def get(self, item: str, default: Any = None) -> Any:
try:
return self._get_value(item)
except KeyError:
return default
def t(self, key: str, default: Any = None) -> Any:
try:
return self._get_value(key)
except KeyError:
if default is not None:
return default
raise
def _get_value(self, item: str) -> Any:
if item == "RULES_TEXT":
return get_rules_sync(self.language)
if item in self._values:
return self._values[item]
if item in self._fallback_values:
return self._fallback_values[item]
_logger.warning(
"Missing localization key '%s' for language '%s'",
item,
self.language,
)
raise KeyError(item)
@staticmethod
def format_price(kopeks: int) -> str:
return f"{int(kopeks / 100)}"
return settings.format_price(kopeks)
@staticmethod
def format_traffic(gb: float) -> str:
if gb == 0:
return "∞ (безлимит)"
elif gb >= 1024:
return f"{gb/1024:.1f} ТБ"
else:
return f"{gb:.0f} ГБ"
if gb >= 1024:
return f"{gb / 1024:.1f} ТБ"
return f"{gb:.0f} ГБ"
class RussianTexts(Texts):
def __init__(self):
super().__init__("ru")
WELCOME = """
🎉 <b>Добро пожаловать в VPN сервис!</b>
def get_texts(language: str = DEFAULT_LANGUAGE) -> Texts:
return Texts(language)
Наш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.
🔐 <b>Преимущества:</b>
• Высокая скорость подключения
• Серверы в разных странах
• Надежная защита данных
• Круглосуточная поддержка
Для начала работы выберите язык интерфейса:
"""
LANGUAGE_SELECTED = "🌐 Язык интерфейса установлен: <b>Русский</b>"
RULES_ACCEPT = "✅ Принимаю правила"
RULES_DECLINE = "Не принимаю"
RULES_REQUIRED = "❗️ Для использования сервиса необходимо принять правила!"
REFERRAL_CODE_QUESTION = """
🤝 <b>У вас есть реферальный код от друга?</b>
Если у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!
Введите код или нажмите "Пропустить":
"""
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
MAIN_MENU = """👤 <b>{user_name}</b>
📱 <b>Подписка:</b> {subscription_status}
Выберите действие:
"""
MENU_BALANCE = "💰 Баланс"
MENU_SUBSCRIPTION = "📱 Подписка"
MENU_TRIAL = "🧪 Тестовая подписка"
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
MENU_PROMOCODE = "🎫 Промокод"
MENU_REFERRALS = "🤝 Партнерка"
MENU_SUPPORT = "🛠️ Техподдержка"
MENU_RULES = "📋 Правила сервиса"
MENU_LANGUAGE = "🌐 Язык"
MENU_ADMIN = "⚙️ Админ-панель"
BALANCE_BUTTON = "💰 Баланс: {balance}"
BALANCE_BUTTON_ZERO = "💰 Баланс: 0 ₽"
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
SUBSCRIPTION_ACTIVE = "✅ Активна"
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
SUBSCRIPTION_INFO = """
📱 <b>Информация о подписке</b>
📊 <b>Статус:</b> {status}
🎭 <b>Тип:</b> {type}
📅 <b>Действует до:</b> {end_date}
⏰ <b>Осталось дней:</b> {days_left}
📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}
🌍 <b>Серверы:</b> {countries_count} стран
📱 <b>Устройства:</b> {devices_used} / {devices_limit}
💳 <b>Автоплатеж:</b> {autopay_status}
"""
TRIAL_AVAILABLE = """
🎁 <b>Тестовая подписка</b>
Вы можете получить бесплатную тестовую подписку:
⏰ <b>Период:</b> {days} дней
📈 <b>Трафик:</b> {traffic} ГБ
📱 <b>Устройства:</b> {devices} шт.
🌍 <b>Сервер:</b> {server_name}
Активировать тестовую подписку?
"""
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
CHANGE_DEVICES_TITLE = "📱 Изменение количества устройств"
CHANGE_DEVICES_INFO = """
📱 <b>Изменение количества устройств</b>
Текущий лимит: {current_devices} устройств
Выберите новое количество устройств:
💡 <b>Важно:</b>
• При увеличении - доплата пропорционально оставшемуся времени
• При уменьшении - возврат средств не производится
"""
CHANGE_DEVICES_CONFIRM = """
📱 <b>Подтверждение изменения</b>
Текущее количество: {current_devices} устройств
Новое количество: {new_devices} устройств
Действие: {action}
💰 {cost}
Подтвердить изменение?
"""
CHANGE_DEVICES_SUCCESS_INCREASE = """
✅ Количество устройств увеличено!
📱 Было: {old_count} → Стало: {new_count}
💰 Списано: {amount}
"""
CHANGE_DEVICES_SUCCESS_DECREASE = """
✅ Количество устройств уменьшено!
📱 Было: {old_count} → Стало: {new_count}
Возврат средств не производится
"""
DEVICES_NO_CHANGE = " Количество устройств не изменилось"
DEVICES_MINIMUM_LIMIT = "⚠️ Минимальное количество устройств: {limit}"
DEVICES_LIMIT_EXCEEDED = "⚠️ Превышен максимальный лимит устройств ({limit})"
DEVICES_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
BUY_SUBSCRIPTION_START = """
💎 <b>Настройка подписки</b>
Давайте настроим вашу подписку под ваши потребности.
Сначала выберите период подписки:
"""
SELECT_PERIOD = "Выберите период:"
SELECT_TRAFFIC = "Выберите пакет трафика:"
SELECT_COUNTRIES = "Выберите страны:"
SELECT_DEVICES = "Количество устройств:"
PERIOD_14_DAYS = f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"
PERIOD_30_DAYS = f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"
PERIOD_60_DAYS = f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"
PERIOD_90_DAYS = f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}"
PERIOD_180_DAYS = f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}"
PERIOD_360_DAYS = f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}"
TRAFFIC_5GB = f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"
TRAFFIC_10GB = f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"
TRAFFIC_25GB = f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"
TRAFFIC_50GB = f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"
TRAFFIC_100GB = f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"
TRAFFIC_250GB = f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}"
TRAFFIC_UNLIMITED = f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}"
SUBSCRIPTION_SUMMARY = """
📋 <b>Итоговая конфигурация</b>
📅 <b>Период:</b> {period} дней
📈 <b>Трафик:</b> {traffic}
🌍 <b>Страны:</b> {countries}
📱 <b>Устройства:</b> {devices}
💰 <b>Итого к оплате:</b> {total_price}
Подтвердить покупку?
"""
INSUFFICIENT_BALANCE = """❌ Недостаточно средств на балансе.
<b>Пополните баланс на {amount} и попробуйте снова.</b>
"""
GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса"
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
BALANCE_INFO = """
💰 <b>Баланс: {balance}</b>
Выберите действие:
"""
BALANCE_HISTORY = "📊 История операций"
BALANCE_TOP_UP = "💳 Пополнить"
BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку"
TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):"
TOP_UP_METHODS = """
💳 <b>Выберите способ оплаты</b>
Сумма: {amount}
"""
TOP_UP_STARS = "⭐ Telegram Stars"
TOP_UP_TRIBUTE = "💎 Банковская карта"
PROMOCODE_ENTER = "🎫 Введите промокод:"
PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}"
PROMOCODE_INVALID = "❌ Неверный промокод"
PROMOCODE_EXPIRED = "❌ Промокод истек"
PROMOCODE_USED = "❌ Промокод уже использован"
REFERRAL_INFO = """
🤝 <b>Реферальная программа</b>
👥 <b>Приглашено:</b> {referrals_count} друзей
💰 <b>Заработано:</b> {earned_amount}
🔗 <b>Ваша реферальная ссылка:</b>
<code>{referral_link}</code>
🎫 <b>Ваш промокод:</b>
<code>{referral_code}</code>
💰 <b>Условия:</b>
За каждого друга: {registration_bonus}
• Процент с пополнений: {commission_percent}%
"""
REFERRAL_INVITE_MESSAGE = """
🎯 <b>Приглашение в VPN сервис</b>
Привет! Приглашаю тебя в отличный VPN сервис!
🎁 По моей ссылке ты получишь бонус: {bonus}
🔗 Переходи: {link}
🎫 Или используй промокод: {code}
💪 Быстро, надежно, недорого!
"""
CREATE_INVITE = "📝 Создать приглашение"
TRIAL_ENDING_SOON = """
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через несколько часов.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
• 30 дней всего за {price}
• Безлимитный трафик
Все серверы доступны
• Скорость до 1ГБит/сек
⚡️ Успейте оформить до окончания тестового периода!
"""
MAINTENANCE_MODE_ACTIVE = """
🔧 Технические работы!
Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.
⏰ Ориентировочное время завершения: неизвестно
🔄 Попробуйте позже
Приносим извинения за временные неудобства.
"""
MAINTENANCE_MODE_API_ERROR = """
🔧 Технические работы!
Сервис временно недоступен из-за проблем с подключением к серверам.
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
🔄 Последняя проверка: {last_check}
"""
SUBSCRIPTION_EXPIRING_PAID = """
⚠️ <b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {end_date}.
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически"
AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!"
SUBSCRIPTION_EXPIRED = """
❌ <b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
🔧 Доступ к серверам заблокирован до продления.
"""
AUTOPAY_SUCCESS = """
✅ <b>Автоплатеж выполнен</b>
Ваша подписка автоматически продлена на {days} дней.
Списано с баланса: {amount}
Новая дата окончания: {new_end_date}
"""
AUTOPAY_FAILED = """
❌ <b>Ошибка автоплатежа</b>
Не удалось списать средства для продления подписки.
💰 Ваш баланс: {balance}
💳 Требуется: {required}
Пополните баланс и продлите подписку вручную.
"""
SUPPORT_INFO = f"""
🛠️ <b>Техническая поддержка</b>
По всем вопросам обращайтесь к нашей поддержке:
👤 {settings.SUPPORT_USERNAME}
Мы поможем с:
• Настройкой подключения
• Решением технических проблем
• Вопросами по оплате
• Другими вопросами
⏰ Время ответа: обычно в течение 1-2 часов
"""
CONTACT_SUPPORT = "💬 Написать в поддержку"
ADMIN_PANEL = """
⚙️ <b>Административная панель</b>
Выберите раздел для управления:
"""
ADMIN_USERS = "👥 Пользователи"
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
ADMIN_PROMOCODES = "🎫 Промокоды"
ADMIN_MESSAGES = "📨 Рассылки"
ADMIN_MONITORING = "🔍 Мониторинг"
ADMIN_REFERRALS = "🤝 Партнерка"
ADMIN_RULES = "📋 Правила"
ADMIN_REMNAWAVE = "🖥️ Remnawave"
ADMIN_STATISTICS = "📊 Статистика"
ACCESS_DENIED = "❌ Доступ запрещен"
USER_NOT_FOUND = "❌ Пользователь не найден"
SUBSCRIPTION_NOT_FOUND = "❌ Подписка не найдена"
INVALID_AMOUNT = "❌ Неверная сумма"
OPERATION_CANCELLED = "❌ Операция отменена"
SUBSCRIPTION_EXPIRING = """
⚠️ <b>Подписка истекает!</b>
Ваша подписка истекает через {days} дней.
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
"""
SUBSCRIPTION_EXPIRED = """
❌ <b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
"""
AUTOPAY_SUCCESS = """
✅ <b>Автоплатеж выполнен</b>
Ваша подписка автоматически продлена на {days} дней.
Списано с баланса: {amount}
"""
SWITCH_TRAFFIC_TITLE = "🔄 Переключение лимита трафика"
SWITCH_TRAFFIC_INFO = """
🔄 <b>Переключение лимита трафика</b>
Текущий лимит: {current_traffic}
Выберите новый лимит трафика:
💡 <b>Важно:</b>
• При увеличении - доплата за разницу пропорционально оставшемуся времени
• При уменьшении - возврат средств не производится
• Счетчик использованного трафика НЕ сбрасывается
"""
SWITCH_TRAFFIC_CONFIRM = """
🔄 <b>Подтверждение переключения трафика</b>
Текущий лимит: {current_traffic}
Новый лимит: {new_traffic}
Действие: {action}
💰 {cost}
Подтвердить переключение?
"""
SWITCH_TRAFFIC_SUCCESS_INCREASE = """
✅ Лимит трафика увеличен!
📊 Было: {old_traffic} → Стало: {new_traffic}
💰 Списано: {amount}
"""
SWITCH_TRAFFIC_SUCCESS_DECREASE = """
✅ Лимит трафика уменьшен!
📊 Было: {old_traffic} → Стало: {new_traffic}
Возврат средств не производится
"""
TRAFFIC_NO_CHANGE = " Лимит трафика не изменился"
TRAFFIC_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
AUTOPAY_FAILED = """
❌ <b>Ошибка автоплатежа</b>
Не удалось списать средства для продления подписки.
Недостаточно средств на балансе: {balance}
Требуется: {required}
Пополните баланс и продлите подписку вручную.
"""
class EnglishTexts(Texts):
def __init__(self):
super().__init__("en")
WELCOME = """
🎉 <b>Welcome to VPN Service!</b>
Our service provides fast and secure internet access without restrictions.
🔐 <b>Advantages:</b>
• High connection speed
• Servers in different countries
• Reliable data protection
• 24/7 support
To get started, select interface language:
"""
LANGUAGE_SELECTED = "🌐 Interface language set: <b>English</b>"
BACK = "⬅️ Back"
CANCEL = "❌ Cancel"
CONFIRM = "✅ Confirm"
CONTINUE = "➡️ Continue"
YES = "✅ Yes"
NO = "❌ No"
MENU_BALANCE = "💰 Balance"
MENU_SUBSCRIPTION = "📱 Subscription"
MENU_TRIAL = "🎁 Trial subscription"
INSUFFICIENT_BALANCE = """❌ Insufficient balance. " \
Top up {amount} and try again."""
GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up"
LANGUAGES = {
"ru": RussianTexts,
"en": EnglishTexts
}
def get_texts(language: str = "ru") -> Texts:
return LANGUAGES.get(language, RussianTexts)()
async def get_rules_from_db(language: str = "ru") -> str:
async def get_rules_from_db(language: str = DEFAULT_LANGUAGE) -> str:
try:
from app.database.database import get_db
from app.database.crud.rules import get_current_rules_content
async for db in get_db():
rules = await get_current_rules_content(db, language)
if rules:
_cached_rules[language] = rules
return rules
break
except Exception as e:
print(f"Ошибка получения правил из БД: {e}")
default_rules = _get_default_rules(language)
_cached_rules[language] = default_rules
return default_rules
def get_rules_sync(language: str = "ru") -> str:
except Exception as error: # pragma: no cover - defensive logging
_logger.warning("Failed to load rules from DB for %s: %s", language, error)
default = _get_default_rules(language)
_cached_rules[language] = default
return default
def _get_default_rules(language: str = DEFAULT_LANGUAGE) -> str:
default_key = "RULES_TEXT_DEFAULT"
locale = load_locale(language)
if default_key in locale:
return locale[default_key]
fallback = load_locale(DEFAULT_LANGUAGE)
return fallback.get(default_key, "")
def get_rules_sync(language: str = DEFAULT_LANGUAGE) -> str:
if language in _cached_rules:
return _cached_rules[language]
try:
if language in _cached_rules:
return _cached_rules[language]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
rules = loop.run_until_complete(get_rules_from_db(language))
loop.close()
return rules
except Exception as e:
print(f"Ошибка получения правил: {e}")
return _get_default_rules(language)
finally:
asyncio.set_event_loop(None)
loop.close()
async def refresh_rules_cache(language: str = "ru"):
try:
if language in _cached_rules:
del _cached_rules[language]
await get_rules_from_db(language)
print(f"✅ Кеш правил для языка {language} обновлен")
except Exception as e:
print(f"Ошибка обновления кеша правил: {e}")
def clear_rules_cache():
global _cached_rules
async def refresh_rules_cache(language: str = DEFAULT_LANGUAGE) -> None:
if language in _cached_rules:
del _cached_rules[language]
await get_rules_from_db(language)
def clear_rules_cache() -> None:
_cached_rules.clear()
print("✅ Кеш правил очищен")
def reload_locales() -> None:
clear_locale_cache()

View File

@@ -8,6 +8,8 @@ from aiogram.enums import ChatMemberStatus
from app.config import settings
from app.keyboards.inline import get_channel_sub_keyboard
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.check_reg_process import is_registration_process
logger = logging.getLogger(__name__)
@@ -111,9 +113,27 @@ class ChannelCheckerMiddleware(BaseMiddleware):
@staticmethod
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
channel_sub_kb = get_channel_sub_keyboard(channel_link)
text = f"""🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!"""
user = None
if isinstance(event, (Message, CallbackQuery)):
user = getattr(event, "from_user", None)
elif isinstance(event, Update):
if event.message and event.message.from_user:
user = event.message.from_user
elif event.callback_query and event.callback_query.from_user:
user = event.callback_query.from_user
language = DEFAULT_LANGUAGE
if user and user.language_code:
language = user.language_code.split('-')[0]
texts = get_texts(language)
channel_sub_kb = get_channel_sub_keyboard(channel_link, language=language)
text = texts.t(
"CHANNEL_REQUIRED_TEXT",
"🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!",
)
try:
if isinstance(event, Message):
return await event.answer(text, reply_markup=channel_sub_kb)

View File

@@ -57,11 +57,13 @@ services:
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
volumes:
# Логи
- ./logs:/app/logs:rw
# Данные приложения (для SQLite в случае переключения)
- ./data:/app/data:rw
- ./locales:/app/locales:rw
# Конфигурация приложения
# - ./app-config.json:/app/app-config.json:ro
# Timezone

16
locales/en.yml Normal file
View File

@@ -0,0 +1,16 @@
# Sample localization for English language.
# Update values to customize bot messages.
WELCOME: |
Welcome to Remnawave Bedolaga Bot!
Adjust this text to match your tone of voice.
MENU:
BALANCE: "Balance"
SUBSCRIPTION: "Subscription"
RULES_TEXT: |
Remnawave service rules:
1. Follow applicable laws.
2. Avoid sharing spam or malicious content.
3. Treat other users with respect.

16
locales/ru.yml Normal file
View File

@@ -0,0 +1,16 @@
# Пример локализации на русском языке.
# Эти значения можно менять под нужды проекта.
WELCOME: |
Добро пожаловать в Remnawave Bedolaga Bot!
Здесь можно настроить приветственное сообщение.
MENU:
BALANCE: "Баланс"
SUBSCRIPTION: "Подписка"
RULES_TEXT: |
Правила сервиса Remnawave:
1. Соблюдайте законодательство своей страны.
2. Не распространяйте спам или вредоносный контент.
3. Уважайте других пользователей.

View File

@@ -18,6 +18,7 @@ from app.external.webhook_server import WebhookServer
from app.external.yookassa_webhook import start_yookassa_webhook_server
from app.database.universal_migration import run_universal_migration
from app.services.backup_service import backup_service
from app.localization.loader import ensure_locale_templates
class GracefulExit:
@@ -43,6 +44,11 @@ async def main():
logger = logging.getLogger(__name__)
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
try:
ensure_locale_templates()
except Exception as error:
logger.warning("Failed to prepare locale templates: %s", error)
killer = GracefulExit()
signal.signal(signal.SIGINT, killer.exit_gracefully)
signal.signal(signal.SIGTERM, killer.exit_gracefully)

View File

@@ -11,6 +11,7 @@ pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
redis==5.0.1
PyYAML==6.0.2
# YooKassa SDK
yookassa==3.0.0