import json import logging import os import re import time from pathlib import Path from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple from aiogram import BaseMiddleware from aiogram.types import Update, User from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings from db.dal import user_dal LocaleOverrides = Dict[str, Dict[str, str]] _LOCALE_LANGUAGE_CODE_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$") LANGUAGE_LABELS: Dict[str, str] = { "ru": "Русский", "en": "English", "de": "Deutsch", "es": "Español", "fr": "Français", "pt-br": "Português (BR)", "tr": "Türkçe", "uk": "Українська", } LANGUAGE_FLAGS: Dict[str, str] = { "ru": "🇷🇺", "en": "🇬🇧", "de": "🇩🇪", "es": "🇪🇸", "fr": "🇫🇷", "pt-br": "🇧🇷", "tr": "🇹🇷", "uk": "🇺🇦", } DEFAULT_LANGUAGE_ORDER = ("ru", "en") LOCALE_KEY_ALIASES: Dict[str, str] = { "admin_apply": "wa_apply", "admin_ads_col_status": "admin_status", "admin_ad_label_source": "admin_ads_col_source", "admin_back": "wa_back", "admin_btn_refresh": "admin_refresh", "admin_btn_save": "admin_save", "admin_btn_saving": "admin_saving", "admin_close": "wa_close", "admin_copied": "wa_copied", "admin_copy": "wa_copy", "admin_csv_amount": "admin_amount", "admin_csv_description": "admin_description", "admin_csv_payment_id": "admin_id", "admin_csv_status": "admin_status", "admin_link_copied": "wa_link_copied", "admin_next": "wa_next", "admin_payment_detail_copied": "wa_copied", "admin_payment_detail_provider": "admin_provider", "admin_payment_detail_provider_section": "admin_provider", "admin_payment_detail_user_section": "admin_user", "admin_payments_col_user_id": "admin_id", "admin_promo_col_code": "admin_promo_csv_code", "admin_promo_col_status": "admin_status", "admin_promo_csv_is_active": "admin_badge_active", "admin_promo_csv_status": "admin_status", "admin_promo_label_code": "admin_promo_csv_code", "admin_promo_unlimited_validity": "admin_promo_unlimited", "admin_stats_revenue_custom_range_apply": "wa_apply", "admin_stats_revenue_tooltip_amount": "admin_amount", "admin_stats_sync_status": "admin_status", "admin_status_active": "admin_badge_active", "admin_support_category": "wa_support_category", "admin_support_category_account": "wa_support_category_account", "admin_support_category_billing": "wa_support_category_billing", "admin_support_category_other": "wa_support_category_other", "admin_support_category_technical": "wa_support_category_technical", "admin_support_close_ticket": "wa_close", "admin_support_empty": "wa_support_empty", "admin_support_filter_active": "wa_support_filter_active", "admin_support_filter_all": "wa_support_filter_all", "admin_support_internal_note": "wa_support_internal_note", "admin_support_no_messages": "wa_support_no_messages", "admin_support_priority": "wa_support_priority", "admin_support_priority_high": "wa_support_priority_high", "admin_support_priority_low": "wa_support_priority_low", "admin_support_priority_normal": "wa_support_priority_normal", "admin_support_priority_urgent": "wa_support_priority_urgent", "admin_support_role_system": "wa_support_role_system", "admin_support_role_user": "admin_user", "admin_support_search": "admin_search", "admin_support_status": "admin_status", "admin_support_status_awaiting_admin": "wa_support_status_awaiting_admin", "admin_support_status_awaiting_user": "wa_support_status_awaiting_user", "admin_support_status_closed": "wa_support_status_closed", "admin_support_status_open": "wa_support_status_open", "admin_support_status_resolved": "wa_support_status_resolved", "admin_support_ticket_number": "wa_support_ticket_number", "admin_support_user_context": "admin_user", "admin_tariffs_legacy_traffic_packages": "admin_tariff_traffic_packages", "admin_tariffs_stat_enabled": "admin_enabled", "admin_user_btn_cancel": "wa_cancel", "admin_user_history_until": "wa_until_date", "admin_user_label_provider": "admin_provider", "admin_user_short": "admin_user", "admin_user_stats_total_label": "admin_total", "back_to_autopay_method_choice_button": "back_to_main_menu_button", "back_to_payment_methods_button": "back_to_main_menu_button", "cancel_broadcast_button": "cancel_button", "csv_no": "no_button", "csv_yes": "yes_button", "user_premium_override_status_unlimited": "user_regular_override_status_unlimited", "user_regular_override_save": "admin_save", "wa_devices_disconnect_title": "wa_devices_disconnect", "wa_install_link_copied": "wa_link_copied", "wa_link_email_modal_title": "wa_settings_link_email_action", } def resolve_locale_key(key: object) -> str: value = str(key or "").strip() seen: Set[str] = set() while value in LOCALE_KEY_ALIASES and value not in seen: seen.add(value) value = LOCALE_KEY_ALIASES[value] return value def is_valid_locale_language_code(value: str) -> bool: return 2 <= len(value) <= 16 and bool(_LOCALE_LANGUAGE_CODE_RE.fullmatch(value)) def normalize_locale_language_code( raw: object, valid_languages: Optional[Set[str]] = None, *, prefer_known_base: bool = True, ) -> str: value = str(raw or "").strip().lower().replace("_", "-") if not value: return "" if prefer_known_base and valid_languages and value not in valid_languages: base = value.split("-", 1)[0] if base in valid_languages: return base return value def _normalize_language_code(raw: object, valid_languages: Optional[Set[str]] = None) -> str: return normalize_locale_language_code(raw, valid_languages) def locale_language_label(code: object) -> str: value = normalize_locale_language_code(code, prefer_known_base=False) return LANGUAGE_LABELS.get(value, value.upper()) def locale_language_flag(code: object) -> str: value = normalize_locale_language_code(code, prefer_known_base=False) return LANGUAGE_FLAGS.get(value, "🏳️") def sort_locale_language_codes(codes: Iterable[object]) -> List[str]: normalized = {normalize_locale_language_code(code, prefer_known_base=False) for code in codes} normalized = {code for code in normalized if code and is_valid_locale_language_code(code)} preferred = [code for code in DEFAULT_LANGUAGE_ORDER if code in normalized] rest = sorted(code for code in normalized if code not in DEFAULT_LANGUAGE_ORDER) return [*preferred, *rest] def locale_language_options( codes: Iterable[object], *, base_languages: Iterable[object] = (), ) -> List[Dict[str, Any]]: base_set = set(sort_locale_language_codes(base_languages)) return [ { "code": code, "label": locale_language_label(code), "flag": locale_language_flag(code), "base": code in base_set, } for code in sort_locale_language_codes(codes) ] def _valid_locale_keys_by_language( locales_data: Dict[str, Dict[str, str]], ) -> Dict[str, Set[str]]: return { lang: {str(key) for key in messages.keys()} for lang, messages in locales_data.items() if isinstance(messages, dict) } def normalize_locale_overrides_payload( payload: object, *, valid_languages: Optional[Iterable[str]] = None, valid_keys_by_language: Optional[Dict[str, Set[str]]] = None, allow_extra_languages: bool = False, key_aliases: Optional[Dict[str, str]] = None, ) -> Tuple[LocaleOverrides, Dict[str, str]]: """Normalize a user/admin supplied locale override JSON payload. The canonical shape is ``{"ru": {"welcome": "..."}, "en": {...}}``. For convenience, files may also wrap it as ``{"overrides": {...}}`` or ``{"locales": {...}}``. """ if not isinstance(payload, dict): return {}, {"_payload": "invalid_payload"} raw_payload = payload for wrapper_key in ("overrides", "locales"): wrapped = raw_payload.get(wrapper_key) if isinstance(wrapped, dict): raw_payload = wrapped break valid_lang_set = {str(lang).lower() for lang in valid_languages or []} aliases = key_aliases or LOCALE_KEY_ALIASES def resolve_payload_key(raw_key: str) -> str: value = raw_key seen: Set[str] = set() while value in aliases and value not in seen: seen.add(value) value = aliases[value] return value all_valid_keys: Set[str] = set() if valid_keys_by_language: for keys in valid_keys_by_language.values(): all_valid_keys.update(str(key) for key in keys) overrides: LocaleOverrides = {} errors: Dict[str, str] = {} for raw_lang, raw_messages in raw_payload.items(): lang = normalize_locale_language_code( raw_lang, valid_lang_set or None, prefer_known_base=not allow_extra_languages, ) error_key = str(raw_lang or "_language") if not lang: errors[error_key] = "invalid_language" continue if valid_lang_set and lang not in valid_lang_set: if not allow_extra_languages: errors[error_key] = "unknown_language" continue if not is_valid_locale_language_code(lang): errors[error_key] = "invalid_language" continue elif allow_extra_languages and not is_valid_locale_language_code(lang): errors[error_key] = "invalid_language" continue if not isinstance(raw_messages, dict): errors[lang] = "invalid_language_bucket" continue lang_keys = valid_keys_by_language.get(lang, set()) if valid_keys_by_language else set() bucket: Dict[str, str] = {} for raw_key, raw_value in raw_messages.items(): raw_key_text = str(raw_key or "").strip() key = resolve_payload_key(raw_key_text) item_error_key = f"{lang}.{raw_key_text or '_key'}" if not raw_key_text or not key: errors[item_error_key] = "invalid_key" continue if all_valid_keys and key not in all_valid_keys and key not in lang_keys: errors[item_error_key] = "unknown_key" continue if raw_value is None: continue if not isinstance(raw_value, str): errors[item_error_key] = "invalid_value" continue if len(raw_value) > 20000: errors[item_error_key] = "value_too_long" continue if raw_key_text in aliases and key in bucket: continue bucket[key] = raw_value if bucket: overrides[lang] = dict(sorted(bucket.items())) return dict(sorted(overrides.items())), errors class JsonI18n: def __init__( self, path: str, default: str = "en", domain: str = "bot", overrides_path: Optional[str] = None, ): self.domain = domain self.path = path self.default_lang = default self.base_locales_data: Dict[str, Dict[str, str]] = {} self.locale_overrides: LocaleOverrides = {} self.locales_data: Dict[str, Dict[str, str]] = {} self._overrides_path: Optional[Path] = None self._overrides_file_mtime_ns: Optional[int] = None self._overrides_file_content: Optional[str] = None self._overrides_file_next_check = 0.0 self._overrides_file_check_interval_seconds = 1.0 self._load_locales() if overrides_path: self.configure_overrides_file(overrides_path) self.reload_overrides_from_file(force=True) logging.info( f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501 ) def _load_locales(self): if not os.path.isdir(self.path): logging.error(f"Locales path not found or not a directory: {self.path}") return loaded: Dict[str, Dict[str, str]] = {} for item in os.listdir(self.path): if item.endswith(".json"): lang_code = item.split(".")[0] file_path = os.path.join(self.path, item) try: with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): loaded[lang_code] = { str(key): str(value) for key, value in data.items() if isinstance(value, str) } else: logging.error( "Locale %s from %s is not a JSON object", lang_code, file_path, ) except json.JSONDecodeError as e_json_load: logging.error( f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501 ) except Exception as e_load: logging.error( f"Error loading locale {lang_code} from {file_path}: {e_load}", exc_info=True, ) self.base_locales_data = loaded self._rebuild_effective_locales() def _rebuild_effective_locales(self) -> None: effective: Dict[str, Dict[str, str]] = {} for lang, messages in self.base_locales_data.items(): merged = dict(messages) merged.update(self.locale_overrides.get(lang, {})) effective[lang] = merged fallback_base = ( self.base_locales_data.get(self.default_lang) or self.base_locales_data.get("en") or next(iter(self.base_locales_data.values()), {}) ) for lang, messages in self.locale_overrides.items(): if lang in effective: continue merged = dict(fallback_base) merged.update(messages) effective[lang] = merged self.locales_data = effective def _valid_keys_by_language(self) -> Dict[str, Set[str]]: return _valid_locale_keys_by_language(self.base_locales_data) def language_options(self) -> List[Dict[str, Any]]: self.reload_overrides_from_file() return locale_language_options( self.locales_data.keys(), base_languages=self.base_locales_data.keys(), ) def set_locale_overrides(self, overrides: object) -> Dict[str, str]: normalized, errors = normalize_locale_overrides_payload( overrides, valid_languages=set(self.base_locales_data.keys()), valid_keys_by_language=self._valid_keys_by_language(), allow_extra_languages=True, ) if errors: logging.warning("Some locale overrides were skipped: %s", errors) self.locale_overrides = normalized self._rebuild_effective_locales() return errors def configure_overrides_file(self, path: str | Path) -> None: self._overrides_path = Path(path) try: self._overrides_file_mtime_ns = self._overrides_path.stat().st_mtime_ns except FileNotFoundError: self._overrides_file_mtime_ns = None except OSError as exc: logging.warning("Failed to stat locale overrides file %s: %s", path, exc) self._overrides_file_mtime_ns = None def reload_overrides_from_file(self, *, force: bool = False) -> bool: if self._overrides_path is None: return False now = time.monotonic() if not force and now < self._overrides_file_next_check: return False self._overrides_file_next_check = now + self._overrides_file_check_interval_seconds try: stat = self._overrides_path.stat() except FileNotFoundError: if self._overrides_file_mtime_ns is None: return False self._overrides_file_mtime_ns = None self._overrides_file_content = None logging.info( "Locale overrides file removed; keeping current in-memory overrides until " "the DB fallback is reloaded" ) return False except OSError as exc: logging.warning( "Failed to stat locale overrides file %s: %s", self._overrides_path, exc, ) return False try: content = self._overrides_path.read_text(encoding="utf-8") except OSError as exc: logging.warning( "Failed to read locale overrides file %s: %s", self._overrides_path, exc, ) return False if ( not force and stat.st_mtime_ns == self._overrides_file_mtime_ns and content == self._overrides_file_content ): return False try: payload = json.loads(content) except json.JSONDecodeError as exc: logging.warning( "Failed to parse locale overrides file %s: %s", self._overrides_path, exc, ) self._overrides_file_mtime_ns = stat.st_mtime_ns self._overrides_file_content = content return False self._overrides_file_mtime_ns = stat.st_mtime_ns self._overrides_file_content = content self.set_locale_overrides(payload) logging.info("Locale overrides reloaded from %s", self._overrides_path) return True def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str: self.reload_overrides_from_file() lookup_key = resolve_locale_key(key) requested_lang_code = normalize_locale_language_code( lang_code, set(self.locales_data.keys()), prefer_known_base=False, ) requested_base_lang_code = requested_lang_code.split("-", 1)[0] # Determine effective language with robust fallback if requested_lang_code and requested_lang_code in self.locales_data: effective_lang_code = requested_lang_code elif requested_base_lang_code and requested_base_lang_code in self.locales_data: effective_lang_code = requested_base_lang_code elif self.default_lang in self.locales_data: effective_lang_code = self.default_lang elif "en" in self.locales_data: effective_lang_code = "en" else: effective_lang_code = requested_lang_code or self.default_lang lang_data = self.locales_data.get(effective_lang_code) if lang_data is None: # Try explicit fallback to English if available fallback_data = self.locales_data.get("en") if fallback_data is not None: text = fallback_data.get(lookup_key) if text is not None: try: return text.format(**kwargs) if kwargs else text except Exception: return text logging.warning( f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is." # noqa: E501 ) return key.format(**kwargs) if kwargs else key text = lang_data.get(lookup_key) if text is None: if effective_lang_code != self.default_lang: default_lang_data = self.locales_data.get(self.default_lang, {}) text = default_lang_data.get(lookup_key) if text is None: logging.warning( f"Translation key '{key}' not found for lang '{effective_lang_code}' or default '{self.default_lang}'. Returning key." # noqa: E501 ) return key.format(**kwargs) if kwargs else key try: return text.format(**kwargs) if kwargs else text except KeyError as e_format: logging.warning( f"Missing format key '{e_format}' for i18n key '{key}' (lang: {effective_lang_code}). Original text: '{text}'" # noqa: E501 ) return text except Exception as e_general_format: logging.error( f"General error formatting i18n key '{key}' (lang: {effective_lang_code}): {e_general_format}. Original text: '{text}'", # noqa: E501 exc_info=True, ) return text _i18n_instance_singleton: Optional[JsonI18n] = None def get_i18n_instance(path: str = "locales", default: str = "en", domain: str = "bot") -> JsonI18n: global _i18n_instance_singleton if _i18n_instance_singleton is None: if not os.path.exists(path) or not os.path.isdir(path): logging.error( f"CRITICAL: Locales directory '{path}' not found. i18n will not work correctly." ) _i18n_instance_singleton = JsonI18n(path=path, default=default, domain=domain) else: _i18n_instance_singleton = JsonI18n(path=path, default=default, domain=domain) return _i18n_instance_singleton class I18nMiddleware(BaseMiddleware): def __init__(self, i18n: JsonI18n, settings: Settings): super().__init__() self.i18n = i18n self.settings = settings async def __call__( self, handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], event: Update, data: Dict[str, Any], ) -> Any: session: AsyncSession = data["session"] event_user: Optional[User] = data.get("event_from_user") current_language = self.i18n.default_lang if event_user: try: user_db_model = await user_dal.get_user_by_id(session, event_user.id) if ( user_db_model and user_db_model.language_code and user_db_model.language_code in self.i18n.locales_data ): current_language = user_db_model.language_code elif event_user.language_code: lang_prefix = event_user.language_code.split("-")[0].lower() if lang_prefix in self.i18n.locales_data: current_language = lang_prefix elif event_user.language_code.lower() in self.i18n.locales_data: current_language = event_user.language_code.lower() except Exception as e_db_lang: logging.error( f"I18nMiddleware: Error fetching user lang from DB for {event_user.id}: {e_db_lang}. Falling back.", # noqa: E501 exc_info=True, ) if event_user.language_code: lang_prefix = event_user.language_code.split("-")[0].lower() if lang_prefix in self.i18n.locales_data: current_language = lang_prefix elif event_user.language_code.lower() in self.i18n.locales_data: current_language = event_user.language_code.lower() data["i18n_data"] = {"i18n_instance": self.i18n, "current_language": current_language} return await handler(event, data)