Files
notify-bridge/packages/server/src/notify_bridge_server/commands/handler.py
T
alexei.dolgolyov 35a3008896 feat: log bot command invocations to the event stream
Bot commands were the only user-initiated path that didn't surface in
the dashboard. They now produce ``command_handled`` /
``command_rate_limited`` / ``command_failed`` rows in ``EventLog``
alongside tracker and action events.

Backend
- ``EventLog`` gains nullable ``command_tracker_id`` / ``telegram_bot_id``
  FKs plus deletion-snapshot name columns (idempotent migration).
- New ``_log_command_event`` helper emits one row per invocation at the
  three branches in ``handle_command``. Logging failures are swallowed
  so they cannot block the user-visible reply.
- Telegram ``from`` is captured in poller and webhook, whitelisted to
  identity fields by ``_normalize_issuer`` (drops ``language_code`` and
  any future PII), persisted under ``details.issuer``.
- ``/api/status`` resolves live ``CommandTracker`` / ``TelegramBot``
  names (mirroring the action pattern) and exposes ``tracker_id``,
  ``command_tracker_id``, ``telegram_bot_id`` so the frontend can
  deep-link.

Frontend
- Event rows are now clickable and open a detail modal with full
  provenance (bot → chat → issuer → provider), raw ``details`` JSON,
  and per-entity action buttons.
- Buttons use the existing ``requestHighlight`` + ``goto`` crosslink
  pattern, so clicking lands on the entity's list page with that
  specific card scrolled into view and pulsing.
- Auto-refresh dropdown (Off / 10s / 30s / 1m / 5m) persisted in
  ``localStorage``; ticker pauses while the tab is hidden.
- Event-type filter, dashboard verb labels, and gradients extended for
  the three new ``command_*`` types.
- Filled in pre-existing missing i18n keys (``common.hide`` /
  ``common.show`` / ``commandConfig.noCommandsForProvider``).

Tests
- New ``test_command_event_logging.py`` covers subject formatting,
  issuer normalization, the three event branches, and graceful failure
  when the DB is unreachable. ``pytest packages/server/tests/`` → 96/96.
2026-05-07 22:22:41 +03:00

785 lines
31 KiB
Python

"""Telegram bot command handler — provider-agnostic dispatcher."""
from __future__ import annotations
import logging
import time
from functools import lru_cache
from typing import Any
import aiohttp
from cachetools import TTLCache
from jinja2.sandbox import SandboxedEnvironment
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.notifications.telegram.client import TelegramClient
from ..database.engine import get_engine
from ..database.models import (
CommandConfig,
CommandTemplateConfig,
CommandTemplateSlot,
CommandTracker,
CommandTrackerListener,
EventLog,
NotificationTracker,
ServiceProvider,
TelegramBot,
TelegramChat,
)
from .base import CommandResponse
from .parser import parse_command
from .registry import get_rate_category
_LOGGER = logging.getLogger(__name__)
# Singleton Jinja2 environment for template rendering (Phase 4d)
_JINJA_ENV = SandboxedEnvironment(autoescape=True)
# Rate limit state with automatic TTL expiry (Phase 4e)
_rate_limits: TTLCache = TTLCache(maxsize=10000, ttl=3600)
# Maximum responses per command to avoid Telegram rate limits
_MAX_RESPONSES_PER_COMMAND = 5
# Commands that fetch assets from the service provider and usually reply
# with media — "uploading photo" is the accurate UX hint while we wait on
# the provider API + Telegram upload.
_UPLOAD_PHOTO_COMMANDS = frozenset({
"latest", "random", "favorites", "memory",
"search", "find", "person", "place",
})
# Commands that fetch from the provider but reply with text only.
# "typing" is accurate; we still want an indicator because the fetch is slow.
_TYPING_COMMANDS = frozenset({"summary"})
def classify_command_chat_action(text: str) -> str | None:
"""Return the Telegram chat-action hint to show for this command, or None.
The classification is by command name alone — good enough for the
cases where a chat action is worthwhile (slow provider fetches). Fast
DB-only commands (``/status``, ``/albums``, ``/events``, ``/people``)
return ``None`` and skip the indicator entirely.
"""
cmd, _, _ = parse_command(text)
if not cmd:
return None
if cmd in _UPLOAD_PHOTO_COMMANDS:
return "upload_photo"
if cmd in _TYPING_COMMANDS:
return "typing"
return None
def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int]) -> int | None:
"""Check rate limit. Returns seconds to wait, or None if OK."""
category = get_rate_category(cmd)
cooldown = limits.get(category, limits.get("default", 10))
if cooldown <= 0:
return None
key = (bot_id, chat_id, category)
now = time.time()
last = _rate_limits.get(key, 0)
if now - last < cooldown:
return int(cooldown - (now - last)) + 1
_rate_limits[key] = now
return None
def _resolve_template(
templates: dict[str, dict[str, str]], slot_name: str, locale: str,
) -> str | None:
"""Pick a template string for slot+locale, falling back to 'en'."""
locale_map = templates.get(slot_name, {})
return locale_map.get(locale) or locale_map.get("en")
@lru_cache(maxsize=256)
def _compile_template(template_str: str):
"""Cache compiled Jinja2 templates to avoid re-parsing identical strings."""
return _JINJA_ENV.from_string(template_str)
def _render_cmd_template(
templates: dict[str, dict[str, str]], slot_name: str, locale: str,
context: dict[str, Any],
) -> str:
"""Render a locale-aware command template. Falls back to 'en'."""
template_str = _resolve_template(templates, slot_name, locale)
if not template_str:
# Missing template = user sees "[No template: X]" — this is an ERROR,
# not a warning. Broken replies must stand out in production logs.
_LOGGER.error("No command template found for slot '%s' locale '%s'", slot_name, locale)
return f"[No template: {slot_name}]"
try:
tmpl = _compile_template(template_str)
return tmpl.render(**context)
except Exception:
_LOGGER.error(
"Failed to render command template '%s' locale=%s — user will see a broken reply",
slot_name, locale, exc_info=True,
)
return f"[Template error: {slot_name}]"
# ---------------------------------------------------------------------------
# Context resolution
# ---------------------------------------------------------------------------
async def _resolve_command_context(
bot: TelegramBot,
) -> tuple[
list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]],
dict[int, dict[str, dict[str, str]]],
]:
"""Resolve all enabled command trackers, configs, and providers for a bot.
Returns:
(context_tuples, templates_by_config_id)
templates_by_config_id is {command_template_config_id: {slot_name: {locale: template}}}.
"""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(CommandTrackerListener).where(
CommandTrackerListener.listener_type == "telegram_bot",
CommandTrackerListener.listener_id == bot.id,
)
)
listeners = result.all()
if not listeners:
return [], {}
# Batch-fetch all referenced entities in 3 queries instead of N*3
tracker_ids = list({l.command_tracker_id for l in listeners})
tracker_result = await session.exec(
select(CommandTracker).where(CommandTracker.id.in_(tracker_ids))
)
trackers_by_id = {t.id: t for t in tracker_result.all()}
config_ids = list({
t.command_config_id for t in trackers_by_id.values()
if t.enabled and t.command_config_id
})
if config_ids:
config_result = await session.exec(
select(CommandConfig).where(CommandConfig.id.in_(config_ids))
)
configs_by_id = {c.id: c for c in config_result.all()}
else:
configs_by_id = {}
provider_ids = list({
t.provider_id for t in trackers_by_id.values()
if t.enabled and t.provider_id
})
if provider_ids:
provider_result = await session.exec(
select(ServiceProvider).where(ServiceProvider.id.in_(provider_ids))
)
providers_by_id = {p.id: p for p in provider_result.all()}
else:
providers_by_id = {}
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]] = []
for listener in listeners:
tracker = trackers_by_id.get(listener.command_tracker_id)
if not tracker or not tracker.enabled:
continue
config = configs_by_id.get(tracker.command_config_id)
if not config:
continue
provider = providers_by_id.get(tracker.provider_id)
if not provider:
continue
tuples.append((tracker, config, provider, listener))
# Load command template slots per config (not merged)
templates_by_config_id: dict[int, dict[str, dict[str, str]]] = {}
seen_config_ids: set[int] = set()
for _, config, _, _ in tuples:
cfg_id = config.command_template_config_id
if cfg_id and cfg_id not in seen_config_ids:
seen_config_ids.add(cfg_id)
slot_result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == cfg_id
)
)
slots: dict[str, dict[str, str]] = {}
for s in slot_result.all():
slots.setdefault(s.slot_name, {})[s.locale] = s.template
templates_by_config_id[cfg_id] = slots
return tuples, templates_by_config_id
def _templates_for_config(
templates_by_config_id: dict[int, dict[str, dict[str, str]]],
config: CommandConfig,
) -> dict[str, dict[str, str]]:
"""Get template slots for a specific command config."""
cfg_id = config.command_template_config_id
if cfg_id and cfg_id in templates_by_config_id:
return templates_by_config_id[cfg_id]
return {}
def _merge_all_templates(
templates_by_config_id: dict[int, dict[str, dict[str, str]]],
) -> dict[str, dict[str, str]]:
"""Merge all template config slots into one dict (for universal commands)."""
merged: dict[str, dict[str, str]] = {}
for slots in templates_by_config_id.values():
for slot_name, locale_map in slots.items():
merged.setdefault(slot_name, {}).update(locale_map)
return merged
def _merge_enabled_commands(
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]],
) -> tuple[list[str], dict[str, Any]]:
"""Merge enabled_commands (union) and rate_limits from all configs.
Rate limits use the most restrictive (minimum) cooldown per category.
"""
if not ctx:
return [], {}
enabled: set[str] = set()
merged_limits: dict[str, int] = {}
for _, config, _, _ in ctx:
enabled.update(config.enabled_commands or [])
for category, cooldown in (config.rate_limits or {}).items():
if category not in merged_limits:
merged_limits[category] = cooldown
else:
merged_limits[category] = min(merged_limits[category], cooldown)
return sorted(enabled), merged_limits
# ---------------------------------------------------------------------------
# Event logging
# ---------------------------------------------------------------------------
def _format_command_subject(cmd: str, args: str) -> str:
"""Render the dashboard ``collection_name`` for a command event."""
args = (args or "").strip()
return f"/{cmd} {args}".rstrip() if args else f"/{cmd}"
def _normalize_issuer(issuer: dict[str, Any] | None) -> dict[str, Any] | None:
"""Strip a Telegram ``from`` payload to the fields the dashboard needs.
Telegram's ``from`` carries plenty we don't want to persist (premium
badge, language code already captured elsewhere, etc.). Keep just
the identity bits and drop anything else so future Telegram changes
can't accidentally start logging extra PII.
"""
if not issuer:
return None
keep = ("id", "username", "first_name", "last_name", "is_bot")
out = {k: issuer[k] for k in keep if k in issuer and issuer[k] not in (None, "")}
return out or None
async def _log_command_event(
*,
bot: TelegramBot,
chat_id: str,
cmd: str,
args: str,
locale: str,
event_type: str,
responses: list[CommandResponse],
ctx_tuples: list[
tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]
],
extra_details: dict[str, Any] | None = None,
issuer: dict[str, Any] | None = None,
) -> None:
"""Persist a single ``EventLog`` row for a bot-command invocation.
One row per user invocation. Per-tracker breakdown lives in ``details``
(``tracker_count`` / ``responses_count``). Best-effort: a logging
failure must never block the user-visible reply, so we swallow.
"""
try:
first_tracker: CommandTracker | None = None
first_provider: ServiceProvider | None = None
if ctx_tuples:
first_tracker, _, first_provider, _ = ctx_tuples[0]
media_total = sum(len(r.media or []) for r in responses)
details: dict[str, Any] = {
"command": cmd,
"args": args or "",
"chat_id": chat_id,
"locale": locale,
"tracker_count": len(ctx_tuples),
"responses_count": len(responses),
}
normalized_issuer = _normalize_issuer(issuer)
if normalized_issuer:
details["issuer"] = normalized_issuer
if extra_details:
details.update(extra_details)
engine = get_engine()
async with AsyncSession(engine) as session:
session.add(EventLog(
user_id=bot.user_id,
tracker_id=None,
tracker_name="",
action_id=None,
action_name="",
command_tracker_id=first_tracker.id if first_tracker else None,
command_tracker_name=first_tracker.name if first_tracker else "",
telegram_bot_id=bot.id,
bot_name=bot.name or "",
provider_id=first_provider.id if first_provider else None,
provider_name=(first_provider.name if first_provider else "") or "",
event_type=event_type,
collection_id=str(chat_id),
collection_name=_format_command_subject(cmd, args),
assets_count=media_total,
details=details,
))
await session.commit()
except Exception: # noqa: BLE001 — diagnostic only, never block reply
_LOGGER.exception(
"Failed to log command event bot=%d chat=%s cmd=/%s",
bot.id, chat_id, cmd,
)
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
async def handle_command(
bot: TelegramBot,
chat_id: str,
text: str,
language_code: str = "",
*,
issuer: dict[str, Any] | None = None,
) -> list[CommandResponse] | None:
"""Handle a bot command. Routes to provider-specific handlers.
Returns a list of CommandResponse objects (one per tracker), or None.
Universal commands (/start, /help) return a single-element list.
Provider-specific commands dispatch per-tracker with per-tracker config.
``issuer`` is the Telegram ``from`` object (``{id, username,
first_name, last_name, language_code}``) when known. Stored on the
EventLog row so the dashboard can show *who* invoked the command.
"""
cmd, args, count_override = parse_command(text)
if not cmd:
return None
ctx_tuples, templates_by_config_id = await _resolve_command_context(bot)
enabled, rate_limits = _merge_enabled_commands(ctx_tuples)
locale = language_code[:2].lower() if language_code else "en"
if locale not in ("en", "ru"):
locale = "en"
# Merged templates for universal commands
merged_templates = _merge_all_templates(templates_by_config_id)
# Universal commands have no tracker/provider context.
if cmd == "start":
text_resp = _render_cmd_template(merged_templates, "start", locale, {"bot_name": bot.name})
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=[], issuer=issuer,
)
return responses
# Unknown / disabled command — caller treats this the same as "no
# match" and we deliberately do NOT log it (avoids dashboard spam
# from random ``/foo`` traffic).
if cmd not in enabled and cmd != "start":
return None
# Rate limit check (once per command, shared across all trackers)
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
if wait is not None:
_LOGGER.info(
"Rate-limited /%s for bot=%d chat=%s%ds cooldown remaining",
cmd, bot.id, chat_id, wait,
)
text_resp = _render_cmd_template(merged_templates, "rate_limited", locale, {"wait": wait})
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_rate_limited", responses=responses,
ctx_tuples=ctx_tuples, extra_details={"wait_seconds": wait},
issuer=issuer,
)
return responses
# Universal commands — single merged response
if cmd == "help":
ctx = _cmd_help(enabled, locale, merged_templates)
text_resp = _render_cmd_template(merged_templates, "help", locale, ctx)
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=ctx_tuples, issuer=issuer,
)
return responses
# Provider-specific dispatch — per-tracker
from .dispatch import get_handler
# For paginated commands (/search, /find) a trailing integer means page,
# not count. Preserve count_override meaning for all other commands.
paginated_cmds = {"search", "find"}
page = 1
if cmd in paginated_cmds and count_override:
page = max(1, count_override)
count_override = None
from .command_utils import resolve_chat_album_scope
responses: list[CommandResponse] = []
dispatched_ctx: list[
tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]
] = []
try:
for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
_LOGGER.warning(
"Truncated command responses at %d for bot=%d chat=%s cmd=/%s (listener context size=%d)",
_MAX_RESPONSES_PER_COMMAND, bot.id, chat_id, cmd, len(ctx_tuples),
)
break
handler = get_handler(provider.type)
if not handler or cmd not in handler.get_provider_commands():
continue
tracker_templates = _templates_for_config(templates_by_config_id, config)
count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media"
# Resolve the album scope for this (provider, bot, chat) triple.
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
# - Otherwise derive from notification routing: only albums that
# already deliver notifications to this chat are queryable from
# it. Prevents commands leaking the full album catalog into
# chats that were never set up to receive from those trackers.
if listener is not None and listener.allowed_album_ids is not None:
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
else:
allowed_album_ids = await resolve_chat_album_scope(
provider_id=provider.id,
bot_id=bot.id,
chat_id=chat_id,
)
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
listener=listener,
allowed_album_ids=allowed_album_ids,
page=page,
)
if result is not None:
responses.append(result)
dispatched_ctx.append((tracker, config, provider, listener))
except Exception as exc: # noqa: BLE001 — log then re-raise
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_failed", responses=responses,
ctx_tuples=ctx_tuples,
extra_details={"error": f"{type(exc).__name__}: {exc}"},
issuer=issuer,
)
raise
if responses:
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=dispatched_ctx, issuer=issuer,
)
return responses
return None
def _cmd_help(
enabled: list[str], locale: str, templates: dict[str, dict[str, str]],
) -> dict[str, Any]:
commands = []
for cmd in enabled:
desc_text = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
entry: dict[str, str] = {"name": cmd, "description": desc_text}
usage_text = _resolve_template(templates, f"usage_{cmd}", locale)
if usage_text:
entry["usage"] = usage_text
commands.append(entry)
return {"commands": commands}
async def _get_notification_trackers_for_providers(
provider_ids: set[int],
) -> list[NotificationTracker]:
"""Get notification trackers for the given provider IDs."""
if not provider_ids:
return []
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(NotificationTracker).where(
NotificationTracker.provider_id.in_(provider_ids)
)
)
return list(result.all())
async def send_reply(
bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Send a text reply to a chat.
Thin wrapper that goes through the single ``services.telegram_send``
entry point so commands and notifications share one routine — same
HTTP session pool, same file_id caches.
Command responses are listings (albums, people, events, ...) that
embed multiple links; Telegram's default behavior of rendering a
preview of the first URL is almost never what the user wants and
clashes with the "Disable link previews" toggle operators set on
their Telegram target. We always pass
``disable_web_page_preview=True`` here.
"""
from ..services.telegram_send import send_telegram_message
result = await send_telegram_message(
bot_token, chat_id, text,
reply_to_message_id=reply_to_message_id,
disable_web_page_preview=True,
)
if not result.get("success"):
# User-visible failure: the bot's reply never reached the chat.
_LOGGER.error(
"Telegram reply failed (chat=%s reply_to=%s len=%d): code=%s error=%r",
chat_id, reply_to_message_id, len(text or ""),
result.get("error_code"), result.get("error"),
)
async def send_media_group(
bot_token: str, chat_id: str, media_items: list[dict[str, Any]],
reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Send media items via the shared Telegram routine.
``media_items`` must already be in TelegramClient asset format — each
entry contains ``type`` (``"photo"``/``"video"``/``"document"``),
``url``, optional ``cache_key``, and optional ``headers``. Provider
command handlers build this format via
``build_telegram_asset_entry`` — the same helper the notification
dispatcher uses — so videos keep their ``"video"`` type and point at
a real video URL instead of a still thumbnail.
Uses ``services.telegram_send.send_telegram_media`` so the URL cache
and asset cache are wired in exactly like the notification path.
Repeated ``/latest`` / ``/random`` commands that match previously-sent
assets hit the cache and skip the re-upload.
"""
if not media_items:
# This is what happened in the /random blind spot: the text reply
# was sent, but the media follow-up was silently skipped because
# the caller passed an empty media list. Surface it so we can see
# it in the log and correlate with the text message.
_LOGGER.warning(
"send_media_group called with 0 items (chat=%s reply_to=%s) — no media will be delivered",
chat_id, reply_to_message_id,
)
return
from ..services.telegram_send import send_telegram_media
result = await send_telegram_media(
bot_token, chat_id, media_items,
reply_to_message_id=reply_to_message_id,
chat_action=None,
)
if not result.get("success"):
# User-visible failure: media promised by the text reply never arrived.
_LOGGER.error(
"Telegram media group failed (chat=%s items=%d reply_to=%s): code=%s error=%r failed_at_chunk=%s",
chat_id, len(media_items), reply_to_message_id,
result.get("error_code"), result.get("error"),
result.get("failed_at_chunk"),
)
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.
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)
from ..services.http_session import get_http_session
http = await get_http_session()
client = TelegramClient(http, bot.token)
success = False
# Register per-locale commands (keyed on user's Telegram client language)
for locale in ("en", "ru"):
commands = _build_command_list(enabled, templates, locale)
result = await client.set_my_commands(commands, language_code=locale)
if result.get("success"):
success = True
else:
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("error"))
# Register default (no language_code) with EN descriptions
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