"""Локализация серверных текстов API по Accept-Language (i18n.md хендбука).
Бэкенд формулирует ошибки по-русски (как в коде), а на границе ответа текст
переводится для en/uk: точные строки — по словарю EXACT, шаблонные (f-строки)
— регэксп-правилами PATTERNS. Нет перевода или язык ru — текст уходит как есть.
HTTPException-обработчик вешает main.py; labels/help реестра настроек
локализует localize_settings() в admin_routes.
Единственное место серверных переводов у Synapse: документы и README
остаются по-русски (решение владельца).
"""
import re
from typing import Callable
from fastapi import Request
SUPPORTED = ("ru", "en", "uk")
DEFAULT_LOCALE = "ru"
def locale_from_request(request: Request) -> str:
"""Первый поддерживаемый язык Accept-Language; без заголовка — ru."""
header = request.headers.get("accept-language") or ""
for part in header.split(","):
tag = part.split(";")[0].strip().lower()
prefix = tag.split("-")[0]
if prefix in SUPPORTED:
return prefix
return DEFAULT_LOCALE
# Точные строки (как они встречаются в HTTPException и валидации настроек).
EXACT: dict[str, dict[str, str]] = {
"API-ключ неизвестен или отозван": {
"en": "API key is unknown or revoked",
"uk": "API-ключ невідомий або відкликаний",
},
"GAUTH_WEBHOOK_SECRET не настроен": {
"en": "GAUTH_WEBHOOK_SECRET is not configured",
"uk": "GAUTH_WEBHOOK_SECRET не налаштований",
},
"Refresh-токен невалиден": {
"en": "Refresh token is invalid",
"uk": "Refresh-токен недійсний",
},
"В архиве нет источника с таким id": {
"en": "No source with this id in the archive",
"uk": "У архіві немає джерела з таким id",
},
"В архиве нет правила с таким id": {
"en": "No rule with this id in the archive",
"uk": "У архіві немає правила з таким id",
},
"В архиве нет типа с таким id": {
"en": "No type with this id in the archive",
"uk": "У архіві немає типу з таким id",
},
"В архиве нет цели с таким id": {
"en": "No target with this id in the archive",
"uk": "У архіві немає цілі з таким id",
},
"Источник в архиве — сначала restore": {
"en": "Source is archived — restore it first",
"uk": "Джерело в архіві — спершу restore",
},
"Источник ключа в архиве — события не принимаются": {
"en": "The key's source is archived — events are not accepted",
"uk": "Джерело ключа в архіві — події не приймаються",
},
"Источник ключа удалён": {
"en": "The key's source is deleted",
"uk": "Джерело ключа видалено",
},
"Источник не найден": {
"en": "Source not found",
"uk": "Джерело не знайдено",
},
"Источник типа в архиве — restore невозможен": {
"en": "The type's source is archived — restore is impossible",
"uk": "Джерело типу в архіві — restore неможливий",
},
"Ключ не найден": {
"en": "Key not found",
"uk": "Ключ не знайдено",
},
"Недостаточно прав: админ-панель доступна роли admin и выше": {
"en": "Not enough rights: the admin area requires the admin role or higher",
"uk": "Недостатньо прав: адмін-панель доступна ролі admin і вище",
},
"Некорректная подпись вебхука": {
"en": "Invalid webhook signature",
"uk": "Некоректний підпис вебхука",
},
"Подписка не найдена": {
"en": "Subscription not found",
"uk": "Підписку не знайдено",
},
"Правило не найдено": {
"en": "Rule not found",
"uk": "Правило не знайдено",
},
"Событие не найдено": {
"en": "Event not found",
"uk": "Подію не знайдено",
},
"Тип не найден": {
"en": "Type not found",
"uk": "Тип не знайдено",
},
"Токен невалиден или истёк": {
"en": "Token is invalid or expired",
"uk": "Токен недійсний або закінчився",
},
"Токен не найден": {
"en": "Token not found",
"uk": "Токен не знайдено",
},
"Требуется Bearer-токен": {
"en": "Bearer token required",
"uk": "Потрібен Bearer-токен",
},
"Требуется Bearer-токен gnexus-auth": {
"en": "A gnexus-auth Bearer token is required",
"uk": "Потрібен Bearer-токен gnexus-auth",
},
"Цель не найдена": {
"en": "Target not found",
"uk": "Ціль не знайдено",
},
"Локаль не поддерживается: en, uk, ru": {
"en": "Unsupported locale: en, uk, ru",
"uk": "Локаль не підтримується: en, uk, ru",
},
}
# Шаблонные формы: regex на русском тексте -> генератор перевода по захватам.
# Генераторы замыкаются на целевой язык (make_* ниже).
PATTERN_BUILDERS: list[tuple[str, Callable[[], dict[str, str]]]] = [
(
r"^Достигнут лимит активных MCP-токенов \((\d+)\) — отзовите лишний$",
lambda: {
"en": "MCP token limit reached ({n}) — revoke some first",
"uk": "Досягнуто ліміт активних MCP-токенів ({n}) — відкличте зайві",
},
),
(
r"^Поле source='(.+?)' не совпадает с источником ключа '(.+?)'$",
lambda: {
"en": "source='{a}' does not match the key's source '{b}'",
"uk": "source='{a}' не збігається з джерелом ключа '{b}'",
},
),
(
r"^Тип \((.+?)\) не зарегистрирован — обратись к админу Synapse$",
lambda: {
"en": "Type ({a}) is not registered — ask the Synapse admin",
"uk": "Тип ({a}) не зареєстрований — звертайся до адміна Synapse",
},
),
(
r"^(.+?): значение должно быть числом$",
lambda: {
"en": "{a}: value must be a number",
"uk": "{a}: значення має бути числом",
},
),
(
r"^(.+?): значение вне допустимого диапазона (\d+)\.\.(\d+)$",
lambda: {
"en": "{a}: value out of the allowed range {b}..{c}",
"uk": "{a}: значення поза допустимим діапазоном {b}..{c}",
},
),
]
def _apply(matched: re.Match, template: dict[str, str], lang: str, keys: list[str]) -> str:
out = template[lang]
for i, name in enumerate(keys, 1):
out = out.replace("{" + name + "}", matched.group(i))
return out
# У шаблонов именованные группы-подстановки: индекс в PATTERN_BUILDERS → ключи
# (regex-группируются по порядку и подставляются в текст шаблона).
_PATTERN_KEYS: dict[int, list[str]] = {
0: ["n"],
1: ["a", "b"],
2: ["a"],
3: ["a"],
4: ["a", "b", "c"],
}
def localize_detail(request: Request | None, detail: object) -> object:
"""Перевод detail ответа; не-строки уходят как есть."""
if not isinstance(detail, str):
return detail
lang = locale_from_request(request) if request is not None else DEFAULT_LOCALE
if lang == "ru":
return detail
exact = EXACT.get(detail)
if exact:
return exact[lang]
for idx, (pattern, build) in enumerate(PATTERN_BUILDERS):
matched = re.match(pattern, detail)
if matched:
text = build()[lang]
for i, key in enumerate(_PATTERN_KEYS.get(idx, ["a", "b", "c"]), 1):
text = text.replace("{" + key + "}", matched.group(i))
return text
return detail
# --- Реестр настроек: перевод label/help по ключу ---
SETTINGS: dict[str, dict[str, str]] = {
"routing_match_mode.label": {"en": "Routing match mode", "uk": "Режим маршрутизації"},
"routing_match_mode.help": {
"en": "all — every matching rule; first — only the top one by weight",
"uk": "all — усі підходящі правила; first — лише перше за weight",
},
"dedup_window_seconds.label": {"en": "Dedup window, seconds", "uk": "Вікно дедуплікації, сек"},
"dedup_window_seconds.help": {
"en": "A duplicate dedup_key within the window returns the first event's id",
"uk": "Дубль з тим самим dedup_key у вікні поверне id першої події",
},
"retention_days.label": {"en": "Retention, days", "uk": "Ретеншн, днів"},
"retention_days.help": {
"en": "Events older than N days are deleted; 0 disables the window",
"uk": "Події старші N днів видаляються; 0 вимикає вікно",
},
"vapid_public_key.label": {"en": "VAPID public key", "uk": "VAPID public key"},
"vapid_public_key.help": {"en": "URLsafe base64 public key (p256dh)", "uk": "URLsafe base64 публічний ключ (p256dh)"},
"vapid_private_key.label": {"en": "VAPID private key", "uk": "VAPID private key"},
"vapid_private_key.help": {
"en": "The secret is stored write-only in the DB; the API only reports whether it is set",
"uk": "Секрет зберігається в БД write-only, API повертає лише факт «задано»",
},
"push_subject.label": {"en": "Subject (sender)", "uk": "Subject (відправник)"},
"push_subject.help": {
"en": "Sender contact for web-push, e.g. mailto:ops@gnexus.space",
"uk": "Контакти відправника для веб-пушів, наприклад mailto:ops@gnexus.space",
},
"telegram_bot_token.label": {"en": "Telegram bot token", "uk": "Токен тг-бота"},
"telegram_bot_token.help": {
"en": "Channel is not implemented yet (#29) — the token is stored for later",
"uk": "Канал ще не реалізований (#29) — токен зберігається наперед",
},
"smtp_from.label": {"en": "From address", "uk": "From-адреса"},
"smtp_from.help": {
"en": 'For example "Synapse <synapse@gnexus.space>"',
"uk": 'Наприклад "Synapse <synapse@gnexus.space>"',
},
}
# smtp_host/port/user/password — технические имена, не переводятся.
def localize_settings(defs, lang: str) -> list:
"""Те же SettingDef с заменёнными label/help (для en/uk); простая обёртка.
Реестр иммутабелен (frozen dataclass в EDITABLE) — создаём лёгкие шимы,
исходные объекты не трогаем.
"""
if lang == "ru":
return defs
localized = []
for d in defs:
label = SETTINGS.get(f"{d.key}.label", {}).get(lang, d.label)
help_text = SETTINGS.get(f"{d.key}.help", {}).get(lang, d.help) if d.help else d.help
localized.append(_SettingShim(d, label, help_text))
return localized
class _SettingShim:
"""С теми же полями, что SettingDef, но своим label/help."""
def __init__(self, base, label, help_text):
self.key = base.key
self.section = base.section
self.label = label
self.kind = base.kind
self.options = base.options
self.help = help_text