From ef942b77cc6596453d06f39ae7ba0ac28f944a98 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 25 Apr 2026 14:41:28 +0300 Subject: [PATCH] feat(telegram): per-chat command localization + unified locale resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related Telegram changes: 1. Per-chat command localization. setMyCommands now accepts a scope (BotCommandScopeChat) and deleteMyCommands clears scoped bindings. Command registration runs three tiers: default → per-language (Telegram client language) → per-chat (UI override). Saving a chat's language_override or commands_enabled toggle pushes the binding to Telegram inline rather than waiting on the 30s debounced bot-wide sync. 2. Unified Telegram locale resolution. Three test paths (bot test_chat, target receiver test, target-level fan-out) used to disagree on locale priority — the target receiver test in particular only consulted receiver.locale and ignored the chat's language_override. Introduced pick_telegram_locale (pure) and resolve_telegram_chat_locale (async DB lookup) in services/notifier so all three paths share one priority order: receiver.locale → chat.language_override → chat.language_code → fallback Fan-out keeps batch-loading TelegramChat rows for efficiency, just runs them through the same priority function now. --- .../notifications/telegram/client.py | 31 +++- .../api/target_receivers.py | 26 +++- .../notify_bridge_server/api/telegram_bots.py | 54 ++++--- .../notify_bridge_server/commands/handler.py | 140 ++++++++++++++++-- .../notify_bridge_server/services/notifier.py | 82 ++++++++-- 5 files changed, 286 insertions(+), 47 deletions(-) diff --git a/packages/core/src/notify_bridge_core/notifications/telegram/client.py b/packages/core/src/notify_bridge_core/notifications/telegram/client.py index 7c59d88..e8501f9 100644 --- a/packages/core/src/notify_bridge_core/notifications/telegram/client.py +++ b/packages/core/src/notify_bridge_core/notifications/telegram/client.py @@ -829,12 +829,41 @@ class TelegramClient: async def set_my_commands( self, commands: list[dict[str, str]], language_code: str | None = None, + scope: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Register bot commands with BotFather API.""" + """Register bot commands with BotFather API. + + ``scope`` is a Telegram BotCommandScope object (e.g. + ``{"type": "chat", "chat_id": 123}``). When provided, the + registration applies only to that scope. ``language_code`` and + ``scope`` may be combined to localize per-scope. + """ url = f"{TELEGRAM_API_BASE_URL}{self._token}/setMyCommands" payload: dict[str, Any] = {"commands": commands} if language_code: payload["language_code"] = language_code + if scope: + payload["scope"] = scope + try: + async with self._session.post(url, json=payload) as resp: + data = await resp.json() + if data.get("ok"): + return {"success": True} + return {"success": False, "error": data.get("description", "Unknown error")} + except aiohttp.ClientError as err: + return {"success": False, "error": str(err)} + + async def delete_my_commands( + self, language_code: str | None = None, + scope: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Clear bot commands for the given scope/language via BotFather API.""" + url = f"{TELEGRAM_API_BASE_URL}{self._token}/deleteMyCommands" + payload: dict[str, Any] = {} + if language_code: + payload["language_code"] = language_code + if scope: + payload["scope"] = scope try: async with self._session.post(url, json=payload) as resp: data = await resp.json() diff --git a/packages/server/src/notify_bridge_server/api/target_receivers.py b/packages/server/src/notify_bridge_server/api/target_receivers.py index 2ae6919..eafc426 100644 --- a/packages/server/src/notify_bridge_server/api/target_receivers.py +++ b/packages/server/src/notify_bridge_server/api/target_receivers.py @@ -11,7 +11,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession from ..auth.dependencies import get_current_user from ..database.engine import get_session from ..database.models import NotificationTarget, TargetReceiver, User -from ..services.notifier import send_to_receiver +from ..services.notifier import ( + _get_test_message, + resolve_telegram_chat_locale, + send_to_receiver, +) from .helpers import get_owned_entity _LOGGER = logging.getLogger(__name__) @@ -130,14 +134,28 @@ async def test_receiver( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - """Send a test notification to a single receiver.""" + """Send a test notification to a single receiver. + + For Telegram targets, locale resolution goes through the shared + ``resolve_telegram_chat_locale`` helper so the per-chat ``language_override`` + set in the bot manager is respected here too — previously this endpoint + only consulted ``receiver.locale`` and ignored chat-side overrides. + """ target = await _get_user_target(session, target_id, user.id) receiver = await session.get(TargetReceiver, receiver_id) if not receiver or receiver.target_id != target_id: raise HTTPException(status_code=404, detail="Receiver not found") - from ..services.notifier import _get_test_message - effective_locale = getattr(receiver, 'locale', '') or locale + if target.type == "telegram": + effective_locale = await resolve_telegram_chat_locale( + session, + bot_id=target.config.get("bot_id"), + chat_id=receiver.config.get("chat_id"), + receiver=receiver, + fallback=locale, + ) + else: + effective_locale = (getattr(receiver, "locale", "") or locale)[:2].lower() message = _get_test_message(effective_locale, target.type) return await send_to_receiver(target, dict(receiver.config), message) diff --git a/packages/server/src/notify_bridge_server/api/telegram_bots.py b/packages/server/src/notify_bridge_server/api/telegram_bots.py index e2613bd..8f79ee8 100644 --- a/packages/server/src/notify_bridge_server/api/telegram_bots.py +++ b/packages/server/src/notify_bridge_server/api/telegram_bots.py @@ -10,11 +10,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession from notify_bridge_core.notifications.telegram.client import TelegramClient from ..auth.dependencies import get_current_user -from ..commands.handler import register_commands_with_telegram +from ..commands.handler import register_commands_with_telegram, sync_chat_command_binding from ..commands.webhook import register_webhook, unregister_webhook from ..database.engine import get_session from ..database.models import AppSetting, NotificationTarget, TargetReceiver, TelegramBot, TelegramChat, User -from ..services.notifier import _get_test_message +from ..services.notifier import _get_test_message, resolve_telegram_chat_locale from ..services.telegram_poller import schedule_bot_polling, unschedule_bot_polling from .app_settings import get_setting from .helpers import get_owned_entity @@ -300,26 +300,14 @@ async def test_chat( ): """Send a test message to a chat via the bot. - Locale resolution: prefer the chat row's ``language_override`` (explicit - user choice in the UI), fall back to Telegram's ``language_code`` sent - with the chat, and only use the ``?locale=`` query param if neither is - set. Otherwise users who set RU on a chat would still see an EN test. + Locale resolution is delegated to ``resolve_telegram_chat_locale`` so this + endpoint, the per-receiver fan-out, and the target receiver test all + apply the same priority order (override → language_code → fallback). """ bot = await _get_user_bot(session, bot_id, user.id) - chat_row = (await session.exec( - select(TelegramChat).where( - TelegramChat.bot_id == bot_id, - TelegramChat.chat_id == chat_id, - ) - )).first() - effective_locale = locale - if chat_row: - chat_locale = ( - getattr(chat_row, 'language_override', '') or - getattr(chat_row, 'language_code', '') or '' - ) - if chat_locale: - effective_locale = chat_locale[:2].lower() + effective_locale = await resolve_telegram_chat_locale( + session, bot_id=bot_id, chat_id=chat_id, fallback=locale, + ) from ..services.http_session import get_http_session message = _get_test_message(effective_locale, "telegram") http = await get_http_session() @@ -347,11 +335,37 @@ async def update_chat( if not chat or chat.bot_id != bot_id: raise HTTPException(status_code=404, detail="Chat not found") updates = body.model_dump(exclude_unset=True) + # Track whether anything changed that affects the chat-scoped command + # binding registered with Telegram (so the per-chat language_override + # actually takes effect on the bot's command list, not just the reply + # locale). We push it inline rather than via the debounced auto-sync + # so the user sees the change reflected on Telegram immediately — + # Telegram clients still cache the menu until the next "/" or chat + # re-open, but the source of truth is correct from the moment save + # returns. + sync_relevant_keys = {"language_override", "commands_enabled"} + needs_sync = any( + key in updates and getattr(chat, key) != value + for key, value in updates.items() + if key in sync_relevant_keys + ) for key, value in updates.items(): setattr(chat, key, value) session.add(chat) await session.commit() await session.refresh(chat) + if needs_sync: + bot = await session.get(TelegramBot, bot_id) + if bot is not None: + try: + await sync_chat_command_binding(bot, chat) + except Exception: + # Telegram-side failure shouldn't block the save — the + # debounced bot-wide sync will retry on the next change. + _LOGGER.warning( + "Inline command sync failed for bot=%d chat=%s", + bot_id, chat.chat_id, exc_info=True, + ) return _chat_response(chat) diff --git a/packages/server/src/notify_bridge_server/commands/handler.py b/packages/server/src/notify_bridge_server/commands/handler.py index ec93023..e9306ff 100644 --- a/packages/server/src/notify_bridge_server/commands/handler.py +++ b/packages/server/src/notify_bridge_server/commands/handler.py @@ -25,6 +25,7 @@ from ..database.models import ( NotificationTracker, ServiceProvider, TelegramBot, + TelegramChat, ) from .base import CommandResponse from .parser import parse_command @@ -483,8 +484,87 @@ async def send_media_group( ) +def _normalize_locale(raw: str | None) -> str: + """Mirror the locale normalization used by the message handler.""" + locale = (raw or "")[:2].lower() + if locale not in ("en", "ru"): + locale = "en" + return locale + + +def _build_command_list( + enabled: list[str], templates: dict[str, dict[str, str]], locale: str, +) -> list[dict[str, str]]: + commands: list[dict[str, str]] = [] + for cmd in enabled: + desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd + commands.append({"command": cmd, "description": desc}) + return commands + + +async def sync_chat_command_binding(bot: TelegramBot, chat: TelegramChat) -> bool: + """Push Telegram's per-chat command binding for a single chat. + + Used for immediate refresh when the user toggles a chat's + ``language_override`` or ``commands_enabled`` flag — avoids the + 30 s debounce of the bot-wide sync. Only touches the chat-scoped + binding (one Telegram API call); global per-language registrations + stay untouched. The bot-wide sync (``register_commands_with_telegram``) + remains the source of truth for everything else. + + Returns ``True`` when Telegram acknowledged the change. + """ + from ..services.http_session import get_http_session + http = await get_http_session() + client = TelegramClient(http, bot.token) + + scope = {"type": "chat", "chat_id": chat.chat_id} + + # Chat is opted out of commands → ensure no chat-scoped override + # lingers. Telegram returns ok=true even if there was nothing to + # delete, so this is safe to call unconditionally. + if not chat.commands_enabled or not chat.language_override: + result = await client.delete_my_commands(scope=scope) + if not result.get("success"): + _LOGGER.warning( + "delete_my_commands(immediate) failed bot=%d chat=%s: %s", + bot.id, chat.chat_id, result.get("error"), + ) + return bool(result.get("success")) + + # Override active → resolve the command list for this bot in the + # override locale and push it scoped to this chat. + ctx_tuples, templates_by_config_id = await _resolve_command_context(bot) + enabled, _ = _merge_enabled_commands(ctx_tuples) + templates = _merge_all_templates(templates_by_config_id) + override_locale = _normalize_locale(chat.language_override) + commands = _build_command_list(enabled, templates, override_locale) + + result = await client.set_my_commands(commands, scope=scope) + if not result.get("success"): + _LOGGER.warning( + "set_my_commands(immediate) failed bot=%d chat=%s locale=%s: %s", + bot.id, chat.chat_id, override_locale, result.get("error"), + ) + return bool(result.get("success")) + + async def register_commands_with_telegram(bot: TelegramBot) -> bool: - """Register enabled commands with Telegram BotFather API via TelegramClient.""" + """Register enabled commands with Telegram BotFather API via TelegramClient. + + Registration happens at three levels: + + 1. Default (no scope, no language) — fallback for any user. + 2. Per-language (no scope, ``language_code=en|ru``) — Telegram picks + based on the *user's* Telegram client language. + 3. Per-chat (``scope=BotCommandScopeChat``) — when a chat has + ``language_override`` set, register chat-scoped commands so the + override takes effect regardless of each user's Telegram client + language. This is the only level Telegram honors for "this chat + should use RU even though the user's Telegram is in EN" — the + per-language registration alone is keyed on the client locale, + not on any per-chat preference we store. + """ ctx_tuples, templates_by_config_id = await _resolve_command_context(bot) enabled, _ = _merge_enabled_commands(ctx_tuples) templates = _merge_all_templates(templates_by_config_id) @@ -494,12 +574,9 @@ async def register_commands_with_telegram(bot: TelegramBot) -> bool: client = TelegramClient(http, bot.token) success = False - # Register per-locale commands + # Register per-locale commands (keyed on user's Telegram client language) for locale in ("en", "ru"): - commands = [] - for cmd in enabled: - desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd - commands.append({"command": cmd, "description": desc}) + commands = _build_command_list(enabled, templates, locale) result = await client.set_my_commands(commands, language_code=locale) if result.get("success"): success = True @@ -507,13 +584,56 @@ async def register_commands_with_telegram(bot: TelegramBot) -> bool: _LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("error")) # Register default (no language_code) with EN descriptions - en_commands = [] - for cmd in enabled: - desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd - en_commands.append({"command": cmd, "description": desc}) + en_commands = _build_command_list(enabled, templates, "en") result = await client.set_my_commands(en_commands) if result.get("success"): _LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username) success = True + # Per-chat overrides: apply chat-scoped commands so language_override + # wins over the user's Telegram client language. For chats with + # commands enabled but no override, clear any prior chat-scoped + # binding so they fall back to the per-language registration above. + engine = get_engine() + async with AsyncSession(engine) as session: + chat_result = await session.exec( + select(TelegramChat).where( + TelegramChat.bot_id == bot.id, + TelegramChat.commands_enabled == True, # noqa: E712 — SQLModel needs == for column comparison + ) + ) + chats = list(chat_result.all()) + + override_count = 0 + for chat in chats: + scope = {"type": "chat", "chat_id": chat.chat_id} + if chat.language_override: + override_locale = _normalize_locale(chat.language_override) + commands = _build_command_list(enabled, templates, override_locale) + result = await client.set_my_commands(commands, scope=scope) + if result.get("success"): + override_count += 1 + else: + _LOGGER.warning( + "Failed to register chat-scoped commands for bot=%d chat=%s locale=%s: %s", + bot.id, chat.chat_id, override_locale, result.get("error"), + ) + else: + # Clear any stale chat-scoped binding from a previous override + # so this chat falls back to the per-language registration. + # Telegram returns ok=true even when nothing was set; safe to + # call unconditionally. + result = await client.delete_my_commands(scope=scope) + if not result.get("success"): + _LOGGER.debug( + "delete_my_commands for bot=%d chat=%s returned: %s", + bot.id, chat.chat_id, result.get("error"), + ) + + if override_count: + _LOGGER.info( + "Applied %d per-chat command override(s) for bot @%s", + override_count, bot.bot_username, + ) + return success diff --git a/packages/server/src/notify_bridge_server/services/notifier.py b/packages/server/src/notify_bridge_server/services/notifier.py index d5b669a..ab03a45 100644 --- a/packages/server/src/notify_bridge_server/services/notifier.py +++ b/packages/server/src/notify_bridge_server/services/notifier.py @@ -43,6 +43,62 @@ def _get_test_message(locale: str, target_type: str) -> str: return msgs.get(target_type, msgs.get("webhook", "Test")) +def pick_telegram_locale( + *, + receiver_locale: str = "", + chat_override: str = "", + chat_language_code: str = "", + fallback: str = "en", +) -> str: + """Pick the effective 2-letter locale for a Telegram chat. + + Priority (highest first): + 1. ``receiver_locale`` — explicit per-receiver override on a target. + 2. ``chat_override`` — explicit ``TelegramChat.language_override`` + set in the bot/chat manager UI. + 3. ``chat_language_code`` — Telegram-provided ``language_code``. + 4. ``fallback`` — caller-supplied default (e.g. query param). + + All inputs are coerced to lowercase 2-letter codes. + """ + for candidate in (receiver_locale, chat_override, chat_language_code, fallback): + if candidate: + return candidate[:2].lower() + return "en" + + +async def resolve_telegram_chat_locale( + session: AsyncSession, + *, + bot_id: int | None, + chat_id: str | int | None, + receiver: TargetReceiver | None = None, + fallback: str = "en", +) -> str: + """Look up a Telegram chat and resolve its effective locale. + + Single source of truth for "what language should I send to this chat in?". + Used by every Telegram test/preview path (bot test_chat, target test + receiver, per-receiver fan-out) so they stay in lockstep. + """ + from ..database.models import TelegramChat + + chat_row = None + if bot_id and chat_id: + chat_row = (await session.exec( + select(TelegramChat).where( + TelegramChat.bot_id == bot_id, + TelegramChat.chat_id == str(chat_id), + ) + )).first() + return pick_telegram_locale( + receiver_locale=getattr(receiver, "locale", "") if receiver else "", + chat_override=getattr(chat_row, "language_override", "") if chat_row else "", + chat_language_code=getattr(chat_row, "language_code", "") if chat_row else "", + fallback=fallback, + ) + + async def _load_receivers(target_id: int) -> list[dict]: """Load enabled receivers for a target from DB.""" engine = get_engine() @@ -343,9 +399,12 @@ async def _send_telegram_test_per_receiver( if not recv_rows: return {"success": False, "error": "No receivers configured"} - # Resolve per-receiver locale + # Batch-load TelegramChat rows so per-receiver locale picks don't + # round-trip the DB N times. Priority resolution then runs through the + # shared pick_telegram_locale() helper so single-shot test endpoints + # and this fan-out agree on the same rules. chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")] - chat_locale_map: dict[str, str] = {} + chat_row_map: dict[str, TelegramChat] = {} if bot_id and chat_ids: chat_rows = (await session.exec( select(TelegramChat).where( @@ -353,13 +412,7 @@ async def _send_telegram_test_per_receiver( TelegramChat.chat_id.in_(chat_ids), ) )).all() - for chat in chat_rows: - override = ( - getattr(chat, "language_override", "") or - getattr(chat, "language_code", "") or "" - ) - if override: - chat_locale_map[chat.chat_id] = override[:2].lower() + chat_row_map = {chat.chat_id: chat for chat in chat_rows} http = await get_http_session() client = TelegramClient(http, bot_token) @@ -374,9 +427,14 @@ async def _send_telegram_test_per_receiver( chat_id = str(r.config.get("chat_id", "")) if not chat_id: return None - explicit = getattr(r, "locale", "") or "" - locale = explicit or chat_locale_map.get(chat_id) or default_locale - message = _get_test_message(locale[:2].lower(), "telegram") + chat_row = chat_row_map.get(chat_id) + locale = pick_telegram_locale( + receiver_locale=getattr(r, "locale", "") or "", + chat_override=getattr(chat_row, "language_override", "") if chat_row else "", + chat_language_code=getattr(chat_row, "language_code", "") if chat_row else "", + fallback=default_locale, + ) + message = _get_test_message(locale, "telegram") async with sem: return await client.send_message( chat_id=chat_id,