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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user