diff --git a/.env.example b/.env.example index 03dd1be4..b660b146 100644 --- a/.env.example +++ b/.env.example @@ -267,6 +267,7 @@ MAINTENANCE_AUTO_ENABLE=true MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже. # ===== ЛОКАЛИЗАЦИЯ ===== +# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru. DEFAULT_LANGUAGE=ru AVAILABLE_LANGUAGES=ru,en diff --git a/README.md b/README.md index 8b4d6f76..6ff49d95 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,7 @@ MAINTENANCE_AUTO_ENABLE=true MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже. # ===== ЛОКАЛИЗАЦИЯ ===== +# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru. DEFAULT_LANGUAGE=ru AVAILABLE_LANGUAGES=ru,en diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 136482f3..e116b1f7 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -13,7 +13,7 @@ import logging logger = logging.getLogger(__name__) -def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ @@ -59,7 +59,7 @@ def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKe def get_main_menu_keyboard( - language: str = "ru", + language: str = DEFAULT_LANGUAGE, is_admin: bool = False, has_had_paid_subscription: bool = False, has_active_subscription: bool = False, @@ -171,14 +171,14 @@ def get_main_menu_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_back_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_back_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] ]) -def get_insufficient_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_insufficient_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ @@ -192,7 +192,7 @@ def get_insufficient_balance_keyboard(language: str = "ru") -> InlineKeyboardMar def get_subscription_keyboard( - language: str = "ru", + language: str = DEFAULT_LANGUAGE, has_subscription: bool = False, is_trial: bool = False, subscription=None @@ -264,7 +264,7 @@ def get_subscription_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_trial_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ @@ -274,7 +274,7 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -305,7 +305,7 @@ def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMark return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_traffic_packages_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: import logging logger = logging.getLogger(__name__) @@ -364,7 +364,7 @@ def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_countries_keyboard(countries: List[dict], selected: List[str], language: str = "ru") -> InlineKeyboardMarkup: +def get_countries_keyboard(countries: List[dict], selected: List[str], language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -402,7 +402,7 @@ def get_countries_keyboard(countries: List[dict], selected: List[str], language: return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMarkup: +def get_devices_keyboard(current: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -447,7 +447,7 @@ def _get_device_declension(count: int) -> str: else: return "устройств" -def get_subscription_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_subscription_confirm_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ @@ -457,7 +457,7 @@ def get_subscription_confirm_keyboard(language: str = "ru") -> InlineKeyboardMar ]) -def get_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [ @@ -473,7 +473,7 @@ def get_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup: +def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -543,7 +543,7 @@ def get_yookassa_payment_keyboard( payment_id: str, amount_kopeks: int, confirmation_url: str, - language: str = "ru" + language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -567,7 +567,7 @@ def get_yookassa_payment_keyboard( ] ]) -def get_autopay_notification_keyboard(subscription_id: int, language: str = "ru") -> InlineKeyboardMarkup: +def get_autopay_notification_keyboard(subscription_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -585,7 +585,7 @@ def get_autopay_notification_keyboard(subscription_id: int, language: str = "ru" ] ]) -def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru") -> InlineKeyboardMarkup: +def get_subscription_expiring_keyboard(subscription_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -609,7 +609,7 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru ] ]) -def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_referral_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [ @@ -648,7 +648,7 @@ def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_support_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_support_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ @@ -664,7 +664,7 @@ def get_pagination_keyboard( current_page: int, total_pages: int, callback_prefix: str, - language: str = "ru" + language: str = DEFAULT_LANGUAGE ) -> List[List[InlineKeyboardButton]]: texts = get_texts(language) keyboard = [] @@ -696,7 +696,7 @@ def get_pagination_keyboard( def get_confirmation_keyboard( confirm_data: str, cancel_data: str = "cancel", - language: str = "ru" + language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -707,7 +707,7 @@ def get_confirmation_keyboard( ]) -def get_autopay_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_autopay_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ @@ -723,7 +723,7 @@ def get_autopay_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_autopay_days_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -752,7 +752,7 @@ def _get_days_suffix(days: int) -> str: -def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -775,7 +775,7 @@ def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMark return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_add_traffic_keyboard(language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup: +def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings texts = get_texts(language) @@ -833,7 +833,7 @@ def get_add_traffic_keyboard(language: str = "ru", subscription_end_date: dateti return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_change_devices_keyboard(current_devices: int, language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup: +def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings texts = get_texts(language) @@ -902,7 +902,7 @@ def get_change_devices_keyboard(current_devices: int, language: str = "ru", subs return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, language: str = "ru") -> InlineKeyboardMarkup: +def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -921,7 +921,7 @@ def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, lang ]) -def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup: +def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: from app.config import settings if settings.is_traffic_fixed(): @@ -947,7 +947,7 @@ def get_manage_countries_keyboard( countries: List[dict], selected: List[str], current_subscription_countries: List[str], - language: str = "ru", + language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None ) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months @@ -1014,7 +1014,7 @@ def get_manage_countries_keyboard( return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_device_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: from app.config import settings texts = get_texts(language) @@ -1047,7 +1047,7 @@ def get_device_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup: def get_connection_guide_keyboard( subscription_url: str, app: dict, - language: str = "ru" + language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: from app.handlers.subscription import create_deep_link texts = get_texts(language) @@ -1087,7 +1087,7 @@ def get_connection_guide_keyboard( def get_app_selection_keyboard( device_type: str, apps: list, - language: str = "ru" + language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -1120,7 +1120,7 @@ def get_specific_app_keyboard( subscription_url: str, app: dict, device_type: str, - language: str = "ru" + language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: from app.handlers.subscription import create_deep_link texts = get_texts(language) @@ -1194,7 +1194,7 @@ def get_cryptobot_payment_keyboard( amount_usd: float, asset: str, bot_invoice_url: str, - language: str = "ru" + language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -1221,7 +1221,7 @@ def get_cryptobot_payment_keyboard( def get_devices_management_keyboard( devices: List[dict], pagination, - language: str = "ru" + language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -1287,7 +1287,7 @@ def get_devices_management_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_updated_subscription_settings_keyboard(language: str = "ru", show_countries_management: bool = True) -> InlineKeyboardMarkup: +def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, show_countries_management: bool = True) -> InlineKeyboardMarkup: from app.config import settings texts = get_texts(language) @@ -1322,7 +1322,7 @@ def get_updated_subscription_settings_keyboard(language: str = "ru", show_countr return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_device_reset_confirm_keyboard(device_info: str, device_index: int, page: int, language: str = "ru") -> InlineKeyboardMarkup: +def get_device_reset_confirm_keyboard(device_info: str, device_index: int, page: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ @@ -1341,7 +1341,7 @@ def get_device_reset_confirm_keyboard(device_info: str, device_index: int, page: ]) -def get_device_management_help_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_device_management_help_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ diff --git a/app/localization/loader.py b/app/localization/loader.py index 7fa2d1b7..a586d791 100644 --- a/app/localization/loader.py +++ b/app/localization/loader.py @@ -9,14 +9,22 @@ from typing import Any, Dict from app.config import settings -DEFAULT_LANGUAGE = "ru" - _logger = logging.getLogger(__name__) +_FALLBACK_LANGUAGE = "ru" + _BASE_DIR = Path(__file__).resolve().parent _DEFAULT_LOCALES_DIR = _BASE_DIR / "locales" +def _normalize_language_code(value: Any) -> str: + if isinstance(value, str): + return value.strip().lower() + if value is None: + return "" + return str(value).strip().lower() + + def _resolve_user_locales_dir() -> Path: path = Path(settings.LOCALES_PATH).expanduser() if not path.is_absolute(): @@ -24,6 +32,106 @@ def _resolve_user_locales_dir() -> Path: return path +def _locale_file_exists(language: str) -> bool: + code = _normalize_language_code(language) + if not code: + return False + + default_candidate = _DEFAULT_LOCALES_DIR / f"{code}.json" + if default_candidate.exists(): + return True + + user_dir = _resolve_user_locales_dir() + for extension in (".json", ".yml", ".yaml"): + if (user_dir / f"{code}{extension}").exists(): + return True + return False + + +def _select_fallback_language(available_map: Dict[str, str]) -> str: + candidates = [] + if _FALLBACK_LANGUAGE: + candidates.append(_FALLBACK_LANGUAGE) + candidates.extend(available_map.values()) + + seen = set() + for candidate in candidates: + normalized = _normalize_language_code(candidate) + if not normalized or normalized in seen: + continue + seen.add(normalized) + + if normalized in available_map: + return available_map[normalized] + + if _locale_file_exists(normalized): + return normalized + + if _FALLBACK_LANGUAGE and _locale_file_exists(_FALLBACK_LANGUAGE): + return _FALLBACK_LANGUAGE + + return _FALLBACK_LANGUAGE or "ru" + + +def _determine_default_language() -> str: + try: + raw_default = settings.DEFAULT_LANGUAGE + except AttributeError: + raw_default = None + + configured = raw_default.strip() if isinstance(raw_default, str) else "" + + try: + available_languages = settings.get_available_languages() + except Exception as error: # pragma: no cover - defensive logging + _logger.warning("Failed to load available languages from settings: %s", error) + available_languages = [] + + available_map = { + _normalize_language_code(lang): lang.strip() + for lang in available_languages + if isinstance(lang, str) and lang.strip() + } + + if configured: + normalized_configured = _normalize_language_code(configured) + + if normalized_configured in available_map: + return available_map[normalized_configured] + + if not available_map and _locale_file_exists(normalized_configured): + return normalized_configured + + if _locale_file_exists(normalized_configured): + _logger.warning( + "Configured default language '%s' is not listed in AVAILABLE_LANGUAGES. Falling back to '%s'.", + configured, + _FALLBACK_LANGUAGE, + ) + else: + _logger.warning( + "Configured default language '%s' is not available. Falling back to '%s'.", + configured, + _FALLBACK_LANGUAGE, + ) + else: + _logger.debug("DEFAULT_LANGUAGE is not set. Falling back to '%s'.", _FALLBACK_LANGUAGE) + + fallback_language = _select_fallback_language(available_map) + + if _normalize_language_code(fallback_language) != _normalize_language_code(_FALLBACK_LANGUAGE): + _logger.warning( + "Fallback language '%s' is not available. Using '%s' instead.", + _FALLBACK_LANGUAGE, + fallback_language, + ) + + return fallback_language or _FALLBACK_LANGUAGE + + +DEFAULT_LANGUAGE = _determine_default_language() + + def _normalize_key(raw_key: Any) -> str: key = str(raw_key).strip().replace(" ", "_") return key.upper() diff --git a/app/localization/texts.py b/app/localization/texts.py index f7b66881..f7dcbffb 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -17,7 +17,9 @@ _cached_rules: Dict[str, str] = {} def _build_dynamic_values(language: str) -> Dict[str, Any]: - if language == "ru": + language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower() + + if language_code == "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)}", @@ -44,6 +46,35 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]: "⏰ Время ответа: обычно в течение 1-2 часов\n" ), } + + if language_code == "en": + return { + "PERIOD_14_DAYS": f"📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}", + "PERIOD_30_DAYS": f"📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}", + "PERIOD_60_DAYS": f"📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}", + "PERIOD_90_DAYS": f"📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}", + "PERIOD_180_DAYS": f"📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}", + "PERIOD_360_DAYS": f"📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}", + "TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", + "TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", + "TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", + "TRAFFIC_50GB": f"📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}", + "TRAFFIC_100GB": f"📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}", + "TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", + "TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", + "SUPPORT_INFO": ( + "\n🛠️ Technical support\n\n" + "For any questions contact our support:\n\n" + f"👤 {settings.SUPPORT_USERNAME}\n\n" + "We can help with:\n" + "• Connection setup\n" + "• Troubleshooting issues\n" + "• Payment questions\n" + "• Other requests\n\n" + "⏰ Response time: usually within 1-2 hours\n" + ), + } + return {}