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.
This commit is contained in:
2026-05-07 22:22:41 +03:00
parent 632e4c1aa3
commit 35a3008896
13 changed files with 952 additions and 50 deletions
@@ -262,6 +262,101 @@ def _merge_enabled_commands(
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
# ---------------------------------------------------------------------------
@@ -271,12 +366,18 @@ async def handle_command(
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:
@@ -292,10 +393,20 @@ async def handle_command(
# 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})
return [CommandResponse(text=text_resp)]
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
@@ -307,13 +418,26 @@ async def handle_command(
cmd, bot.id, chat_id, wait,
)
text_resp = _render_cmd_template(merged_templates, "rate_limited", locale, {"wait": wait})
return [CommandResponse(text=text_resp)]
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)
return [CommandResponse(text=text_resp)]
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
@@ -329,48 +453,69 @@ async def handle_command(
from .command_utils import resolve_chat_album_scope
responses: list[CommandResponse] = []
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),
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,
)
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,
)
if result is not None:
responses.append(result)
raise
return responses if responses else None
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(