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