feat(telegram): per-chat command localization + unified locale resolver

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.
This commit is contained in:
2026-04-25 14:41:28 +03:00
parent 711f218622
commit ef942b77cc
5 changed files with 286 additions and 47 deletions
@@ -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()
@@ -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)
@@ -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,
effective_locale = await resolve_telegram_chat_locale(
session, bot_id=bot_id, chat_id=chat_id, fallback=locale,
)
)).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()
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)
@@ -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
@@ -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,