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:
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { EventLog } from '$lib/types';
|
||||
import { requestHighlight } from '$lib/highlight';
|
||||
import Modal from './Modal.svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
event: EventLog | null;
|
||||
onclose: () => void;
|
||||
}
|
||||
let { event, onclose }: Props = $props();
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function issuerLabel(issuer: { id?: number; username?: string; first_name?: string; last_name?: string } | undefined): string {
|
||||
if (!issuer) return '';
|
||||
if (issuer.username) return '@' + issuer.username;
|
||||
const name = [issuer.first_name, issuer.last_name].filter(Boolean).join(' ');
|
||||
if (name) return name;
|
||||
if (issuer.id) return 'id ' + issuer.id;
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Navigate to a list page and highlight the specific entity card.
|
||||
*
|
||||
* The destination page calls ``highlightFromUrl()`` after data loads,
|
||||
* which scrolls to and pulses the card with ``data-entity-id={id}``.
|
||||
* Same mechanism CrossLink uses elsewhere — keeps the UX consistent. */
|
||||
function openEntity(path: string, entityId: number | string | null | undefined) {
|
||||
if (entityId != null) requestHighlight(entityId);
|
||||
onclose();
|
||||
goto(path);
|
||||
}
|
||||
|
||||
const issuer = $derived(event?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
|
||||
const issuerText = $derived(issuerLabel(issuer));
|
||||
|
||||
const isCommand = $derived(event?.event_type?.startsWith('command_') ?? false);
|
||||
const isAction = $derived(event?.event_type?.startsWith('action_') ?? false);
|
||||
|
||||
const detailsJson = $derived.by(() => {
|
||||
if (!event?.details) return '';
|
||||
try {
|
||||
return JSON.stringify(event.details, null, 2);
|
||||
} catch {
|
||||
return String(event.details);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal open={event !== null} title={event ? t('events.detailTitle') : ''} {onclose}>
|
||||
{#if event}
|
||||
<div class="event-detail">
|
||||
<!-- Subject + verb -->
|
||||
<div class="hero-row">
|
||||
<MdiIcon name="mdiBell" size={18} />
|
||||
<div>
|
||||
<div class="hero-subject">{event.collection_name || event.event_type}</div>
|
||||
<div class="hero-meta">
|
||||
<span class="event-type">{event.event_type}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{fmtDateTime(event.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provenance grid -->
|
||||
<dl class="provenance">
|
||||
{#if event.bot_name}
|
||||
<dt>{t('events.bot')}</dt>
|
||||
<dd>{event.bot_name}</dd>
|
||||
{/if}
|
||||
{#if event.collection_id && isCommand}
|
||||
<dt>{t('events.chat')}</dt>
|
||||
<dd class="font-mono">{event.collection_id}</dd>
|
||||
{/if}
|
||||
{#if issuerText}
|
||||
<dt>{t('events.issuer')}</dt>
|
||||
<dd>
|
||||
{issuerText}
|
||||
{#if issuer?.id}<span class="muted font-mono">(id {issuer.id})</span>{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
{#if event.command_tracker_name}
|
||||
<dt>{t('events.commandTracker')}</dt>
|
||||
<dd>{event.command_tracker_name}</dd>
|
||||
{/if}
|
||||
{#if event.tracker_name}
|
||||
<dt>{t('events.tracker')}</dt>
|
||||
<dd>{event.tracker_name}</dd>
|
||||
{/if}
|
||||
{#if event.action_name}
|
||||
<dt>{t('events.action')}</dt>
|
||||
<dd>{event.action_name}</dd>
|
||||
{/if}
|
||||
{#if event.provider_name}
|
||||
<dt>{t('events.provider')}</dt>
|
||||
<dd>{event.provider_name}</dd>
|
||||
{/if}
|
||||
{#if event.assets_count > 0}
|
||||
<dt>{t('events.assetsCount')}</dt>
|
||||
<dd class="font-mono">{event.assets_count}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<!-- Action buttons — deep-link + highlight the related entity card -->
|
||||
<div class="actions">
|
||||
{#if event.provider_id}
|
||||
<button type="button" onclick={() => openEntity('/providers', event.provider_id)}>
|
||||
<MdiIcon name="mdiServer" size={14} />
|
||||
{t('events.openProvider')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if event.telegram_bot_id && isCommand}
|
||||
<button type="button" onclick={() => openEntity('/bots', event.telegram_bot_id)}>
|
||||
<MdiIcon name="mdiRobotHappy" size={14} />
|
||||
{t('events.openBot')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if event.command_tracker_id && isCommand}
|
||||
<button type="button" onclick={() => openEntity('/command-trackers', event.command_tracker_id)}>
|
||||
<MdiIcon name="mdiChat" size={14} />
|
||||
{t('events.openCommandTracker')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if event.action_id && isAction}
|
||||
<button type="button" onclick={() => openEntity('/actions', event.action_id)}>
|
||||
<MdiIcon name="mdiPlayCircle" size={14} />
|
||||
{t('events.openAction')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isCommand && !isAction && event.tracker_id}
|
||||
<button type="button" onclick={() => openEntity('/notification-trackers', event.tracker_id)}>
|
||||
<MdiIcon name="mdiRadar" size={14} />
|
||||
{t('events.openTracker')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Raw details JSON (always rendered — frequently the most useful piece) -->
|
||||
{#if detailsJson && detailsJson !== '{}'}
|
||||
<details class="raw-details" open={isCommand}>
|
||||
<summary>{t('events.rawDetails')}</summary>
|
||||
<pre>{detailsJson}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.event-detail {
|
||||
display: flex; flex-direction: column; gap: 1.1rem;
|
||||
}
|
||||
.hero-row {
|
||||
display: flex; align-items: flex-start; gap: 0.75rem;
|
||||
}
|
||||
.hero-subject {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
.hero-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-top: 0.25rem;
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.event-type {
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.dot { opacity: 0.5; }
|
||||
|
||||
.provenance {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.45rem 1rem;
|
||||
margin: 0;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 0.7rem;
|
||||
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.provenance dt {
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
align-self: center;
|
||||
}
|
||||
.provenance dd {
|
||||
margin: 0;
|
||||
color: var(--color-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
.muted { color: var(--color-muted-foreground); margin-left: 0.35rem; font-size: 0.75rem; }
|
||||
|
||||
.actions {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem;
|
||||
}
|
||||
.actions button {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-foreground);
|
||||
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 25%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
cursor: pointer;
|
||||
transition: background 150ms, border-color 150ms;
|
||||
}
|
||||
.actions button:hover {
|
||||
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
|
||||
border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
.raw-details summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.raw-details summary:hover { color: var(--color-foreground); }
|
||||
.raw-details pre {
|
||||
margin: 0.55rem 0 0;
|
||||
padding: 0.7rem 0.85rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-foreground);
|
||||
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.font-mono { font-family: var(--font-mono); }
|
||||
</style>
|
||||
@@ -108,6 +108,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
|
||||
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
|
||||
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
|
||||
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
|
||||
{ value: 'command_handled', icon: 'mdiChat', label: t('dashboard.filterCommandHandled'), desc: t('gridDesc.commandHandled') },
|
||||
{ value: 'command_rate_limited', icon: 'mdiTimerSandPaused', label: t('dashboard.filterCommandRateLimited'), desc: t('gridDesc.commandRateLimited') },
|
||||
{ value: 'command_failed', icon: 'mdiAlertCircle', label: t('dashboard.filterCommandFailed'), desc: t('gridDesc.commandFailed') },
|
||||
];
|
||||
|
||||
// --- Sort filter (dashboard) ---
|
||||
@@ -117,6 +120,19 @@ export const sortFilterItems = (): GridItem[] => [
|
||||
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
|
||||
];
|
||||
|
||||
// --- Auto-refresh interval (dashboard events list) ---
|
||||
//
|
||||
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
|
||||
// in routes/+page.svelte if you add or remove cadences.
|
||||
|
||||
export const refreshIntervalItems = (): GridItem[] => [
|
||||
{ value: 0, icon: 'mdiPause', label: t('dashboard.refreshOff'), desc: t('gridDesc.refreshOff') },
|
||||
{ value: 10, icon: 'mdiTimerSand', label: t('dashboard.refresh10s'), desc: t('gridDesc.refresh10s') },
|
||||
{ value: 30, icon: 'mdiTimerOutline', label: t('dashboard.refresh30s'), desc: t('gridDesc.refresh30s') },
|
||||
{ value: 60, icon: 'mdiTimer', label: t('dashboard.refresh60s'), desc: t('gridDesc.refresh60s') },
|
||||
{ value: 300, icon: 'mdiClockOutline', label: t('dashboard.refresh5m'), desc: t('gridDesc.refresh5m') },
|
||||
];
|
||||
|
||||
// --- Chat action (Telegram targets) ---
|
||||
|
||||
export const chatActionItems = (): GridItem[] => [
|
||||
|
||||
@@ -87,6 +87,15 @@
|
||||
"actionSuccess": "action run",
|
||||
"actionPartial": "action partial",
|
||||
"actionFailed": "action failed",
|
||||
"commandHandled": "command handled",
|
||||
"commandRateLimited": "rate limited",
|
||||
"commandFailed": "command failed",
|
||||
"autoRefreshTitle": "Auto-refresh interval for the events list",
|
||||
"refreshOff": "Off",
|
||||
"refresh10s": "10s",
|
||||
"refresh30s": "30s",
|
||||
"refresh60s": "1m",
|
||||
"refresh5m": "5m",
|
||||
"searchEvents": "Search events...",
|
||||
"allEvents": "All Events",
|
||||
"filterAssetsAdded": "Assets Added",
|
||||
@@ -97,6 +106,9 @@
|
||||
"filterActionSuccess": "Action Success",
|
||||
"filterActionPartial": "Action Partial",
|
||||
"filterActionFailed": "Action Failed",
|
||||
"filterCommandHandled": "Command Handled",
|
||||
"filterCommandRateLimited": "Rate Limited",
|
||||
"filterCommandFailed": "Command Failed",
|
||||
"allProviders": "All Providers",
|
||||
"newestFirst": "Newest first",
|
||||
"oldestFirst": "Oldest first",
|
||||
@@ -141,6 +153,23 @@
|
||||
"newTracker": "New tracker",
|
||||
"eventsTotal": "Events"
|
||||
},
|
||||
"events": {
|
||||
"detailTitle": "Event details",
|
||||
"bot": "Bot",
|
||||
"chat": "Chat",
|
||||
"issuer": "Issued by",
|
||||
"commandTracker": "Command tracker",
|
||||
"tracker": "Tracker",
|
||||
"action": "Action",
|
||||
"provider": "Provider",
|
||||
"assetsCount": "Assets",
|
||||
"openProvider": "Open provider",
|
||||
"openBot": "Open bot",
|
||||
"openCommandTracker": "Open command tracker",
|
||||
"openAction": "Open action",
|
||||
"openTracker": "Open tracker",
|
||||
"rawDetails": "Raw details"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Service",
|
||||
"titleEmphasis": "providers",
|
||||
@@ -889,6 +918,7 @@
|
||||
"titleEmphasis": "configs",
|
||||
"countLabel": "configs",
|
||||
"title": "Command Configs",
|
||||
"noCommandsForProvider": "No commands available for this provider type.",
|
||||
"description": "Define command settings for Telegram bot interactions",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
@@ -1025,6 +1055,8 @@
|
||||
"edit": "Edit",
|
||||
"description": "Description",
|
||||
"close": "Close",
|
||||
"hide": "Hide",
|
||||
"show": "Show",
|
||||
"confirm": "Confirm",
|
||||
"cannotDelete": "Cannot delete",
|
||||
"blockedByIntro": "Referenced by:",
|
||||
@@ -1149,6 +1181,14 @@
|
||||
"actionSuccess": "Scheduled action completed",
|
||||
"actionPartial": "Scheduled action partially succeeded",
|
||||
"actionFailed": "Scheduled action failed",
|
||||
"commandHandled": "Bot command served",
|
||||
"commandRateLimited": "Bot command throttled",
|
||||
"commandFailed": "Bot command crashed",
|
||||
"refreshOff": "Auto-refresh disabled",
|
||||
"refresh10s": "Refresh every 10 seconds",
|
||||
"refresh30s": "Refresh every 30 seconds",
|
||||
"refresh60s": "Refresh every minute",
|
||||
"refresh5m": "Refresh every 5 minutes",
|
||||
"newestFirst": "Most recent events on top",
|
||||
"oldestFirst": "Oldest events on top",
|
||||
"chatActionNone": "No indicator shown",
|
||||
|
||||
@@ -87,6 +87,15 @@
|
||||
"actionSuccess": "действие выполнено",
|
||||
"actionPartial": "действие частично",
|
||||
"actionFailed": "действие провалено",
|
||||
"commandHandled": "команда обработана",
|
||||
"commandRateLimited": "ограничение частоты",
|
||||
"commandFailed": "команда упала",
|
||||
"autoRefreshTitle": "Интервал авто-обновления списка событий",
|
||||
"refreshOff": "Выкл",
|
||||
"refresh10s": "10с",
|
||||
"refresh30s": "30с",
|
||||
"refresh60s": "1м",
|
||||
"refresh5m": "5м",
|
||||
"searchEvents": "Поиск событий...",
|
||||
"allEvents": "Все события",
|
||||
"filterAssetsAdded": "Добавление файлов",
|
||||
@@ -97,6 +106,9 @@
|
||||
"filterActionSuccess": "Действие выполнено",
|
||||
"filterActionPartial": "Действие частично",
|
||||
"filterActionFailed": "Действие провалено",
|
||||
"filterCommandHandled": "Команда обработана",
|
||||
"filterCommandRateLimited": "Ограничение частоты",
|
||||
"filterCommandFailed": "Команда упала",
|
||||
"allProviders": "Все провайдеры",
|
||||
"newestFirst": "Сначала новые",
|
||||
"oldestFirst": "Сначала старые",
|
||||
@@ -141,6 +153,23 @@
|
||||
"newTracker": "Новый трекер",
|
||||
"eventsTotal": "Событий"
|
||||
},
|
||||
"events": {
|
||||
"detailTitle": "Детали события",
|
||||
"bot": "Бот",
|
||||
"chat": "Чат",
|
||||
"issuer": "Отправитель",
|
||||
"commandTracker": "Командный трекер",
|
||||
"tracker": "Трекер",
|
||||
"action": "Действие",
|
||||
"provider": "Провайдер",
|
||||
"assetsCount": "Файлов",
|
||||
"openProvider": "Открыть провайдера",
|
||||
"openBot": "Открыть бота",
|
||||
"openCommandTracker": "Открыть командный трекер",
|
||||
"openAction": "Открыть действие",
|
||||
"openTracker": "Открыть трекер",
|
||||
"rawDetails": "Сырые данные"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Сервисные",
|
||||
"titleEmphasis": "провайдеры",
|
||||
@@ -889,6 +918,7 @@
|
||||
"titleEmphasis": "конфигурации",
|
||||
"countLabel": "конфигураций",
|
||||
"title": "Конфигурации команд",
|
||||
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
|
||||
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
@@ -1025,6 +1055,8 @@
|
||||
"edit": "Редактировать",
|
||||
"description": "Описание",
|
||||
"close": "Закрыть",
|
||||
"hide": "Скрыть",
|
||||
"show": "Показать",
|
||||
"confirm": "Подтвердить",
|
||||
"cannotDelete": "Невозможно удалить",
|
||||
"blockedByIntro": "На объект ссылаются:",
|
||||
@@ -1149,6 +1181,14 @@
|
||||
"actionSuccess": "Запланированное действие выполнено",
|
||||
"actionPartial": "Запланированное действие выполнено частично",
|
||||
"actionFailed": "Запланированное действие провалено",
|
||||
"commandHandled": "Команда бота обработана",
|
||||
"commandRateLimited": "Команда бота ограничена по частоте",
|
||||
"commandFailed": "Команда бота вызвала ошибку",
|
||||
"refreshOff": "Автообновление выключено",
|
||||
"refresh10s": "Обновлять каждые 10 секунд",
|
||||
"refresh30s": "Обновлять каждые 30 секунд",
|
||||
"refresh60s": "Обновлять каждую минуту",
|
||||
"refresh5m": "Обновлять каждые 5 минут",
|
||||
"newestFirst": "Сначала новые события",
|
||||
"oldestFirst": "Сначала старые события",
|
||||
"chatActionNone": "Индикатор не показывается",
|
||||
|
||||
@@ -217,9 +217,16 @@ export interface EventLog {
|
||||
event_type: string;
|
||||
collection_id: string;
|
||||
collection_name: string;
|
||||
tracker_id?: number | null;
|
||||
tracker_name: string;
|
||||
provider_name: string;
|
||||
provider_id: number | null;
|
||||
action_id?: number | null;
|
||||
action_name?: string;
|
||||
command_tracker_id?: number | null;
|
||||
command_tracker_name?: string;
|
||||
telegram_bot_id?: number | null;
|
||||
bot_name?: string;
|
||||
assets_count: number;
|
||||
details: Record<string, any>;
|
||||
created_at: string;
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
import EventChart from '$lib/components/EventChart.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
||||
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
|
||||
import type { DashboardStatus } from '$lib/types';
|
||||
import type { DashboardStatus, EventLog } from '$lib/types';
|
||||
|
||||
const SECTIONS_KEY = 'dashboard_section_state';
|
||||
type SectionKey = 'stream' | 'on_watch' | 'pulse' | 'wires';
|
||||
@@ -75,10 +76,53 @@
|
||||
return stored ? parseInt(stored, 10) || 10 : 10;
|
||||
}
|
||||
|
||||
// Auto-refresh: 0 = off, otherwise seconds between refreshes.
|
||||
// Allowed cadences are defined in ``refreshIntervalItems()`` — keep
|
||||
// this whitelist in sync with that helper so a stale localStorage
|
||||
// value can't smuggle in an unsupported interval (e.g. someone
|
||||
// hand-edits to 1).
|
||||
const EVENTS_REFRESH_KEY = 'dashboard_events_refresh_seconds';
|
||||
const ALLOWED_REFRESH_SECONDS = new Set([0, 10, 30, 60, 300]);
|
||||
function loadRefreshSeconds(): number {
|
||||
if (typeof localStorage === 'undefined') return 0;
|
||||
const stored = localStorage.getItem(EVENTS_REFRESH_KEY);
|
||||
const v = stored ? parseInt(stored, 10) : 0;
|
||||
return ALLOWED_REFRESH_SECONDS.has(v) ? v : 0;
|
||||
}
|
||||
|
||||
let eventsLimit = $state(loadEventsPerPage());
|
||||
let eventsOffset = $state(0);
|
||||
let eventsLoading = $state(false);
|
||||
let confirmClearEvents = $state(false);
|
||||
let refreshSeconds = $state(loadRefreshSeconds());
|
||||
let selectedEvent = $state<EventLog | null>(null);
|
||||
|
||||
// Auto-refresh ticker — re-creates the interval whenever the user
|
||||
// changes the cadence. ``$effect`` returns a cleanup that fires on
|
||||
// destroy AND on any tracked dep change, so the prior timer is torn
|
||||
// down before a new one starts.
|
||||
$effect(() => {
|
||||
if (refreshSeconds <= 0) return;
|
||||
// Pause auto-refresh when the tab is hidden so we don't burn API
|
||||
// calls on a tab the user can't see — we'll catch up on the next
|
||||
// visibility flip via ``visibilitychange`` below.
|
||||
const tick = () => {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
loadEvents();
|
||||
loadChart();
|
||||
};
|
||||
const handle = setInterval(tick, refreshSeconds * 1000);
|
||||
return () => clearInterval(handle);
|
||||
});
|
||||
|
||||
// Persist whenever the cadence changes (the IconGridSelect mutates
|
||||
// ``refreshSeconds`` directly via bind:value).
|
||||
let _refreshHydrated = false;
|
||||
$effect(() => {
|
||||
const v = refreshSeconds;
|
||||
if (!_refreshHydrated) { _refreshHydrated = true; return; }
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_REFRESH_KEY, String(v));
|
||||
});
|
||||
|
||||
async function clearEvents() {
|
||||
try {
|
||||
@@ -360,6 +404,9 @@
|
||||
action_success: 'dashboard.actionSuccess',
|
||||
action_partial: 'dashboard.actionPartial',
|
||||
action_failed: 'dashboard.actionFailed',
|
||||
command_handled: 'dashboard.commandHandled',
|
||||
command_rate_limited: 'dashboard.commandRateLimited',
|
||||
command_failed: 'dashboard.commandFailed',
|
||||
};
|
||||
|
||||
const eventIcons: Record<string, string> = {
|
||||
@@ -367,6 +414,7 @@
|
||||
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
||||
scheduled_message: 'mdiCalendarClock',
|
||||
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
||||
command_handled: 'mdiChat', command_rate_limited: 'mdiTimerSandPaused', command_failed: 'mdiAlertCircle',
|
||||
};
|
||||
|
||||
// Aurora gradient palette per event type — used for the avatar tile
|
||||
@@ -380,6 +428,9 @@
|
||||
action_success: ['var(--color-mint)', 'var(--color-primary)'],
|
||||
action_partial: ['var(--color-citrus)', 'var(--color-orchid)'],
|
||||
action_failed: ['var(--color-coral)', 'var(--color-orchid)'],
|
||||
command_handled: ['var(--color-sky)', 'var(--color-primary)'],
|
||||
command_rate_limited:['var(--color-citrus)', 'var(--color-orchid)'],
|
||||
command_failed: ['var(--color-coral)', 'var(--color-orchid)'],
|
||||
};
|
||||
|
||||
const STAT_ACCENTS = [
|
||||
@@ -554,6 +605,11 @@
|
||||
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
|
||||
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
|
||||
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
|
||||
<div class="w-44" title={t('dashboard.autoRefreshTitle')}>
|
||||
<IconGridSelect items={refreshIntervalItems()}
|
||||
bind:value={refreshSeconds}
|
||||
columns={5} compact />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet paginator()}
|
||||
@@ -598,7 +654,10 @@
|
||||
{:else}
|
||||
<div class="signal-list stagger-children">
|
||||
{#each status.recent_events as event, i}
|
||||
<div class="signal-row" style="animation-delay: {i * 60}ms;">
|
||||
<button type="button" class="signal-row signal-row--clickable"
|
||||
style="animation-delay: {i * 60}ms;"
|
||||
onclick={() => selectedEvent = event}
|
||||
aria-label={t('events.detailTitle')}>
|
||||
<div class="signal-avatar"
|
||||
style="--g1: {eventGradients[event.event_type]?.[0] ?? 'var(--color-primary)'}; --g2: {eventGradients[event.event_type]?.[1] ?? 'var(--color-orchid)'};">
|
||||
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={18} />
|
||||
@@ -615,7 +674,29 @@
|
||||
<b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.tracker_name}
|
||||
{#if event.event_type?.startsWith('command_')}
|
||||
{@const issuer = event.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined}
|
||||
{@const issuerLabel = issuer
|
||||
? (issuer.username ? '@' + issuer.username : [issuer.first_name, issuer.last_name].filter(Boolean).join(' ') || ('id ' + issuer.id))
|
||||
: ''}
|
||||
<div class="signal-trail">
|
||||
{#if event.bot_name}
|
||||
<span class="ch"><MdiIcon name="mdiRobotHappy" size={11} />{event.bot_name}</span>
|
||||
{/if}
|
||||
{#if event.collection_id}
|
||||
{#if event.bot_name}<span class="arrow">→</span>{/if}
|
||||
<span class="ch"><MdiIcon name="mdiChatProcessing" size={11} />{event.collection_id}</span>
|
||||
{/if}
|
||||
{#if issuerLabel}
|
||||
<span class="arrow">→</span>
|
||||
<span class="ch"><MdiIcon name="mdiAccount" size={11} />{issuerLabel}</span>
|
||||
{/if}
|
||||
{#if event.provider_name}
|
||||
<span class="arrow">→</span>
|
||||
<span class="ch"><MdiIcon name="mdiServer" size={11} />{event.provider_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if event.tracker_name}
|
||||
<div class="signal-trail">
|
||||
<span class="ch"><MdiIcon name="mdiRadar" size={11} />{event.tracker_name}</span>
|
||||
{#if event.provider_name}
|
||||
@@ -629,7 +710,7 @@
|
||||
<b>{timeShort(event.created_at)}</b>
|
||||
<small>{timeAgo(event.created_at)}</small>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -790,6 +871,8 @@
|
||||
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
|
||||
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
|
||||
|
||||
<EventDetailModal event={selectedEvent} onclose={() => selectedEvent = null} />
|
||||
|
||||
<style>
|
||||
/* ============================================================
|
||||
HERO
|
||||
@@ -1129,6 +1212,20 @@
|
||||
}
|
||||
.signal-row + .signal-row { border-top: 1px solid var(--color-border); }
|
||||
.signal-row:hover { background: var(--color-glass-strong); }
|
||||
/* Row is rendered as <button> for clickability — strip default chrome
|
||||
and align children left like the prior <div> layout. */
|
||||
.signal-row--clickable {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
.signal-row--clickable:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.signal-avatar {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -118,6 +118,31 @@ async def get_status(
|
||||
)).all()
|
||||
action_name_map = {aid: aname for aid, aname in action_rows}
|
||||
|
||||
# Live-resolve command tracker and bot names for command_* events
|
||||
# (mirrors the action/tracker pattern above). Falls back to the
|
||||
# snapshot stored on the EventLog when the entity has been deleted.
|
||||
cmd_tracker_ids = {
|
||||
e.command_tracker_id for e in event_rows if e.command_tracker_id is not None
|
||||
}
|
||||
cmd_tracker_name_map: dict[int, str] = {}
|
||||
if cmd_tracker_ids:
|
||||
cmd_tracker_rows = (await session.exec(
|
||||
select(CommandTracker.id, CommandTracker.name).where(
|
||||
CommandTracker.id.in_(cmd_tracker_ids)
|
||||
)
|
||||
)).all()
|
||||
cmd_tracker_name_map = {tid: tname for tid, tname in cmd_tracker_rows}
|
||||
|
||||
bot_ids = {
|
||||
e.telegram_bot_id for e in event_rows if e.telegram_bot_id is not None
|
||||
}
|
||||
bot_name_map: dict[int, str] = {}
|
||||
if bot_ids:
|
||||
bot_rows = (await session.exec(
|
||||
select(TelegramBot.id, TelegramBot.name).where(TelegramBot.id.in_(bot_ids))
|
||||
)).all()
|
||||
bot_name_map = {bid: bname for bid, bname in bot_rows}
|
||||
|
||||
def _display_tracker_name(e: EventLog) -> str:
|
||||
if e.tracker_id is not None and e.tracker_id in tracker_name_map:
|
||||
return tracker_name_map[e.tracker_id]
|
||||
@@ -135,11 +160,30 @@ async def get_status(
|
||||
return f"(deleted) {e.action_name}"
|
||||
return ""
|
||||
|
||||
def _display_command_tracker_name(e: EventLog) -> str:
|
||||
if (
|
||||
e.command_tracker_id is not None
|
||||
and e.command_tracker_id in cmd_tracker_name_map
|
||||
):
|
||||
return cmd_tracker_name_map[e.command_tracker_id]
|
||||
if e.command_tracker_name:
|
||||
return f"(deleted) {e.command_tracker_name}"
|
||||
return ""
|
||||
|
||||
def _display_bot_name(e: EventLog) -> str:
|
||||
if e.telegram_bot_id is not None and e.telegram_bot_id in bot_name_map:
|
||||
return bot_name_map[e.telegram_bot_id]
|
||||
if e.bot_name:
|
||||
return f"(deleted) {e.bot_name}"
|
||||
return ""
|
||||
|
||||
def _display_subject(e: EventLog) -> str:
|
||||
"""The primary label shown on the event row.
|
||||
|
||||
For action events the ``collection_name`` stores the action name;
|
||||
use the live-resolved action name when available so renames show.
|
||||
For command events the ``collection_name`` already stores the
|
||||
rendered ``/cmd args`` string so we just pass it through.
|
||||
"""
|
||||
if e.action_id is not None or (e.event_type or "").startswith("action_"):
|
||||
return _display_action_name(e) or e.collection_name
|
||||
@@ -155,9 +199,14 @@ async def get_status(
|
||||
"id": e.id,
|
||||
"event_type": e.event_type,
|
||||
"collection_name": _display_subject(e),
|
||||
"tracker_id": e.tracker_id,
|
||||
"tracker_name": _display_tracker_name(e),
|
||||
"action_id": e.action_id,
|
||||
"action_name": _display_action_name(e),
|
||||
"command_tracker_id": e.command_tracker_id,
|
||||
"command_tracker_name": _display_command_tracker_name(e),
|
||||
"telegram_bot_id": e.telegram_bot_id,
|
||||
"bot_name": _display_bot_name(e),
|
||||
"provider_name": _display_provider_name(e),
|
||||
"provider_id": e.provider_id,
|
||||
"assets_count": e.assets_count or 0,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -120,7 +120,11 @@ async def telegram_webhook(
|
||||
async with telegram_chat_action(
|
||||
bot_token, chat_id, classify_command_chat_action(text),
|
||||
):
|
||||
responses = await handle_command(bot, chat_id, text, language_code=effective_lang)
|
||||
responses = await handle_command(
|
||||
bot, chat_id, text,
|
||||
language_code=effective_lang,
|
||||
issuer=from_user or None,
|
||||
)
|
||||
if not responses:
|
||||
_LOGGER.info(
|
||||
"Command produced no response (cmd=%r) after %.0f ms",
|
||||
|
||||
@@ -90,6 +90,10 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
("user_id", "ALTER TABLE event_log ADD COLUMN user_id INTEGER"),
|
||||
("action_id", "ALTER TABLE event_log ADD COLUMN action_id INTEGER"),
|
||||
("action_name", "ALTER TABLE event_log ADD COLUMN action_name TEXT DEFAULT ''"),
|
||||
("command_tracker_id", "ALTER TABLE event_log ADD COLUMN command_tracker_id INTEGER"),
|
||||
("command_tracker_name", "ALTER TABLE event_log ADD COLUMN command_tracker_name TEXT DEFAULT ''"),
|
||||
("telegram_bot_id", "ALTER TABLE event_log ADD COLUMN telegram_bot_id INTEGER"),
|
||||
("bot_name", "ALTER TABLE event_log ADD COLUMN bot_name TEXT DEFAULT ''"),
|
||||
]:
|
||||
if not await _has_column(conn, "event_log", col):
|
||||
await conn.execute(text(sql))
|
||||
@@ -105,6 +109,8 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
("ix_event_log_user_id", "user_id"),
|
||||
("ix_event_log_action_id", "action_id"),
|
||||
("ix_event_log_provider_id", "provider_id"),
|
||||
("ix_event_log_command_tracker_id", "command_tracker_id"),
|
||||
("ix_event_log_telegram_bot_id", "telegram_bot_id"),
|
||||
]:
|
||||
await conn.execute(
|
||||
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON event_log ({col})")
|
||||
|
||||
@@ -519,6 +519,17 @@ class EventLog(SQLModel, table=True):
|
||||
default=None, foreign_key="action.id", index=True,
|
||||
)
|
||||
action_name: str = Field(default="")
|
||||
# Bot command provenance. Populated when ``event_type`` starts with
|
||||
# ``command_`` so the dashboard can render command activity alongside
|
||||
# tracker and action events. NULL for non-command rows.
|
||||
command_tracker_id: int | None = Field(
|
||||
default=None, foreign_key="command_tracker.id", index=True,
|
||||
)
|
||||
command_tracker_name: str = Field(default="")
|
||||
telegram_bot_id: int | None = Field(
|
||||
default=None, foreign_key="telegram_bot.id", index=True,
|
||||
)
|
||||
bot_name: str = Field(default="")
|
||||
provider_id: int | None = Field(default=None, index=True)
|
||||
provider_name: str = Field(default="")
|
||||
event_type: str = Field(index=True)
|
||||
|
||||
@@ -232,7 +232,9 @@ async def _poll_bot(bot_id: int) -> None:
|
||||
# Copy attributes before session closes to avoid detached-instance errors
|
||||
from types import SimpleNamespace
|
||||
bot_token = bot.token
|
||||
bot_obj = SimpleNamespace(id=bot.id, name=bot.name, token=bot.token)
|
||||
bot_obj = SimpleNamespace(
|
||||
id=bot.id, name=bot.name, token=bot.token, user_id=bot.user_id,
|
||||
)
|
||||
|
||||
offset = _last_update_id.get(bot_id, 0)
|
||||
|
||||
@@ -331,7 +333,11 @@ async def _poll_bot(bot_id: int) -> None:
|
||||
async with telegram_chat_action(
|
||||
bot_token, chat_id, classify_command_chat_action(text),
|
||||
):
|
||||
responses = await handle_command(bot_obj, chat_id, text, language_code=effective_lang)
|
||||
responses = await handle_command(
|
||||
bot_obj, chat_id, text,
|
||||
language_code=effective_lang,
|
||||
issuer=from_user or None,
|
||||
)
|
||||
if not responses:
|
||||
_LOGGER.info(
|
||||
"Command produced no response (cmd=%r, poll) after %.0f ms",
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Bot command invocations must be logged to ``EventLog``.
|
||||
|
||||
Covers the three branches in ``handle_command``:
|
||||
|
||||
* ``command_handled`` — a successful invocation (here exercised via the
|
||||
helper directly so the test stays focused on the persistence shape).
|
||||
* ``command_rate_limited`` — caller hit the cooldown.
|
||||
* ``command_failed`` — an exception bubbled out of dispatch.
|
||||
|
||||
The dashboard reads these rows via ``GET /api/status`` so the test also
|
||||
asserts the row is filterable by ``event_type=command_*``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
def _bootstrap_app():
|
||||
"""Bring up the app once so migrations run against the temp DB."""
|
||||
from notify_bridge_server.main import app
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _seed_user_and_bot(name: str = "Test bot"):
|
||||
"""Create a User + TelegramBot, return the bot row."""
|
||||
from notify_bridge_server.database.engine import get_engine
|
||||
from notify_bridge_server.database.models import TelegramBot, User
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
user = User(username=f"u_{name}", hashed_password="x")
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
bot = TelegramBot(user_id=user.id, name=name, token="dummy")
|
||||
session.add(bot)
|
||||
await session.commit()
|
||||
await session.refresh(bot)
|
||||
return bot
|
||||
|
||||
|
||||
async def _read_events(event_type: str, bot_id: int):
|
||||
"""Filter by bot_id so tests don't leak rows into each other.
|
||||
|
||||
The temp DB is shared across tests in this module — without this
|
||||
filter a row left by an earlier test would make the next assertion
|
||||
flaky depending on collection order.
|
||||
"""
|
||||
from notify_bridge_server.database.engine import get_engine
|
||||
from notify_bridge_server.database.models import EventLog
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(EventLog)
|
||||
.where(EventLog.event_type == event_type)
|
||||
.where(EventLog.telegram_bot_id == bot_id)
|
||||
)
|
||||
return list(result.all())
|
||||
|
||||
|
||||
def test_format_command_subject_no_args(tmp_data_dir) -> None: # noqa: ARG001
|
||||
from notify_bridge_server.commands.handler import _format_command_subject
|
||||
|
||||
assert _format_command_subject("latest", "") == "/latest"
|
||||
assert _format_command_subject("help", None) == "/help"
|
||||
|
||||
|
||||
def test_format_command_subject_with_args(tmp_data_dir) -> None: # noqa: ARG001
|
||||
from notify_bridge_server.commands.handler import _format_command_subject
|
||||
|
||||
assert _format_command_subject("search", "sunset") == "/search sunset"
|
||||
# Trailing whitespace must not leak into the dashboard label.
|
||||
assert _format_command_subject("search", "sunset ") == "/search sunset"
|
||||
|
||||
|
||||
def test_normalize_issuer_keeps_identity_drops_extras(tmp_data_dir) -> None: # noqa: ARG001
|
||||
"""Telegram ``from`` is whitelisted to identity fields only."""
|
||||
from notify_bridge_server.commands.handler import _normalize_issuer
|
||||
|
||||
assert _normalize_issuer(None) is None
|
||||
assert _normalize_issuer({}) is None
|
||||
raw = {
|
||||
"id": 1234,
|
||||
"username": "alex",
|
||||
"first_name": "Alex",
|
||||
"last_name": "",
|
||||
"language_code": "ru", # already captured separately — must drop
|
||||
"is_premium": True, # must not leak into our log
|
||||
}
|
||||
assert _normalize_issuer(raw) == {
|
||||
"id": 1234,
|
||||
"username": "alex",
|
||||
"first_name": "Alex",
|
||||
}
|
||||
|
||||
|
||||
def test_log_command_handled_persists_row(tmp_data_dir) -> None: # noqa: ARG001
|
||||
"""``command_handled`` row carries bot + provenance + media count."""
|
||||
import asyncio
|
||||
|
||||
from notify_bridge_server.commands.base import CommandResponse
|
||||
from notify_bridge_server.commands.handler import _log_command_event
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
bot = await _seed_user_and_bot("HandledBot")
|
||||
await _log_command_event(
|
||||
bot=bot,
|
||||
chat_id="123456",
|
||||
cmd="latest",
|
||||
args="",
|
||||
locale="en",
|
||||
event_type="command_handled",
|
||||
responses=[CommandResponse(text="ok", media=[{"type": "photo"}])],
|
||||
ctx_tuples=[], # universal command path: no tracker context
|
||||
)
|
||||
rows = await _read_events("command_handled", bot.id)
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row.user_id == bot.user_id
|
||||
assert row.telegram_bot_id == bot.id
|
||||
assert row.bot_name == "HandledBot"
|
||||
assert row.collection_id == "123456"
|
||||
assert row.collection_name == "/latest"
|
||||
assert row.assets_count == 1
|
||||
assert row.details["command"] == "latest"
|
||||
assert row.details["chat_id"] == "123456"
|
||||
assert row.details["responses_count"] == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_log_command_rate_limited_carries_wait_seconds(tmp_data_dir) -> None: # noqa: ARG001
|
||||
import asyncio
|
||||
|
||||
from notify_bridge_server.commands.base import CommandResponse
|
||||
from notify_bridge_server.commands.handler import _log_command_event
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
bot = await _seed_user_and_bot("ThrottledBot")
|
||||
await _log_command_event(
|
||||
bot=bot,
|
||||
chat_id="42",
|
||||
cmd="random",
|
||||
args="",
|
||||
locale="en",
|
||||
event_type="command_rate_limited",
|
||||
responses=[CommandResponse(text="cooldown")],
|
||||
ctx_tuples=[],
|
||||
extra_details={"wait_seconds": 7},
|
||||
)
|
||||
rows = await _read_events("command_rate_limited", bot.id)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].details["wait_seconds"] == 7
|
||||
assert rows[0].assets_count == 0 # text-only response
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_log_command_failed_records_error(tmp_data_dir) -> None: # noqa: ARG001
|
||||
import asyncio
|
||||
|
||||
from notify_bridge_server.commands.handler import _log_command_event
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
bot = await _seed_user_and_bot("BrokenBot")
|
||||
await _log_command_event(
|
||||
bot=bot,
|
||||
chat_id="9",
|
||||
cmd="albums",
|
||||
args="",
|
||||
locale="ru",
|
||||
event_type="command_failed",
|
||||
responses=[],
|
||||
ctx_tuples=[],
|
||||
extra_details={"error": "RuntimeError: boom"},
|
||||
)
|
||||
rows = await _read_events("command_failed", bot.id)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].details["error"] == "RuntimeError: boom"
|
||||
assert rows[0].details["locale"] == "ru"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_log_command_event_handles_db_error_gracefully(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
|
||||
"""A logging failure must NOT raise — the user still gets their reply."""
|
||||
import asyncio
|
||||
|
||||
from notify_bridge_server.commands import handler as handler_mod
|
||||
from notify_bridge_server.commands.base import CommandResponse
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
bot = await _seed_user_and_bot("StillRepliesBot")
|
||||
|
||||
def boom() -> object:
|
||||
raise RuntimeError("db gone")
|
||||
|
||||
monkeypatch.setattr(handler_mod, "get_engine", boom)
|
||||
|
||||
# Must not raise.
|
||||
await handler_mod._log_command_event(
|
||||
bot=bot,
|
||||
chat_id="1",
|
||||
cmd="help",
|
||||
args="",
|
||||
locale="en",
|
||||
event_type="command_handled",
|
||||
responses=[CommandResponse(text="hi")],
|
||||
ctx_tuples=[],
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
Reference in New Issue
Block a user