Compare commits

...

4 Commits

Author SHA1 Message Date
alexei.dolgolyov 757271dadf chore: release v0.7.1
Release / release (push) Successful in 2m11s
2026-05-07 23:33:09 +03:00
alexei.dolgolyov 73b046f7a2 fix(frontend): cyrillic glyphs for nav and section labels
The legacy ``@fontsource/geist-sans`` (v5.2.5) ships latin only and
``@fontsource/geist-mono`` was imported with the default subset only —
so Russian text in the sidebar (nav links, section labels, badges) fell
back to system fonts (Segoe UI / Cascadia / Consolas) and visibly
clashed with the Latin glyphs around it.

- Switched the sans family to ``@fontsource-variable/geist`` (single
  variable woff2 with latin + latin-ext + cyrillic). Updated
  ``--font-sans`` to lead with ``'Geist Variable'`` then keep the old
  ``'Geist Sans'`` and system fallbacks for safety.
- Added ``@fontsource/geist-mono/cyrillic-{400,500,600}.css`` imports
  so Geist Mono renders Russian glyphs in the section labels and
  monospace badges instead of falling back.

Newsreader (display serif) still has no Cyrillic on fontsource, so
italic page-hero emphasis in Russian still falls back to Georgia.
That's a separate, less-prominent concern and a future swap.
2026-05-07 22:45:06 +03:00
alexei.dolgolyov b170c2b792 feat(frontend): smoother event refresh, localized crumbs, template config deep-link
- Auto-refresh ticker is now silent: skips ``eventsLoading`` so the
  loading placeholder no longer flashes, uses ``(event.id)`` key on
  the events ``{#each}`` so unchanged rows reuse their DOM nodes, and
  short-circuits the array reassignment when the visible page is
  identical to what we already rendered. No-op refreshes leave the
  list completely untouched.
- ``PageHeader`` crumbs (Routing · Notification, Operators · Bots, …)
  were hard-coded literals. Moved to a new ``crumbs`` i18n namespace
  with 9 keys; updated all 15 call sites to ``t('crumbs.*')`` so they
  switch with the language.
- Tracker form's Immich feature-discovery banner now exposes both
  ``Open Tracking Config`` and ``Open Template Config``. Added the
  ``?edit=<id>`` auto-open hook to ``/template-configs`` (mirrors the
  existing one on ``/tracking-configs``) so the new link lands users
  directly on the editor.
2026-05-07 22:34:24 +03:00
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
35 changed files with 1118 additions and 111 deletions
+24 -22
View File
@@ -1,40 +1,42 @@
# v0.7.0 (2026-05-07)
# v0.7.1 (2026-05-07)
Hardened notification stack with shared HTTP base, SSRF protections, secret redaction, and a bounded queue across every provider client; Settings logging selectors switched to icon grids; entity names autogenerate from the chosen type or provider across bots, targets, trackers, actions, and configs.
## Features
## User-facing changes
- Bot command invocations now appear in the dashboard event stream with `command_handled`, `command_rate_limited`, and `command_failed` rows — closing the last user-initiated path that was invisible to the dashboard ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
- Click any event row to open a detail modal with full provenance (bot → chat → issuer → provider), raw `details` JSON, and per-entity action buttons that deep-link into the relevant list page with the card scrolled into view and pulsing ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
- Configurable event auto-refresh dropdown (Off / 10s / 30s / 1m / 5m), persisted in `localStorage`; ticker pauses while the tab is hidden ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
- Smoother event refresh — no more loading-placeholder flicker on auto-refresh; unchanged rows reuse their DOM nodes and identical pages skip re-rendering entirely ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
- Page header breadcrumbs are now translated (new `crumbs.*` i18n namespace covering all 15 call sites), so `Routing · Notification`, `Operators · Bots`, etc. switch with the active language ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
- Tracker form's Immich feature-discovery banner now offers an `Open Template Config` shortcut alongside `Open Tracking Config`, and `/template-configs?edit=<id>` auto-opens the editor on landing ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
- Event-type filter, dashboard verb labels, and gradients extended for the three new `command_*` types; filled in previously missing i18n keys (`common.hide`, `common.show`, `commandConfig.noCommandsForProvider`) ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
- Telegram issuer info (`from`) captured in both poller and webhook paths and persisted under `details.issuer`, whitelisted to identity fields only by `_normalize_issuer` so `language_code` and any future PII fields are dropped ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
### Features
## Bug Fixes
- Settings: replace log level and log format dropdowns with icon-grid selectors carrying per-option icons and i18n descriptions ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
- Forms: auto-generate entity names from the selected type/provider across bots, targets, notification trackers, command trackers, actions, and tracking/template/command/command-template configs — names update live until you manually edit, then your edit is preserved ([5bd63a2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5bd63a2))
- Cyrillic glyphs in sidebar nav links, section labels, and monospace badges now render in Geist instead of falling back to Segoe UI / Cascadia / Consolas. Switched to `@fontsource-variable/geist` (latin + latin-ext + cyrillic) and added `@fontsource/geist-mono` cyrillic subsets for weights 400/500/600 ([73b046f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/73b046f))
### Reliability & Security
- Notification stack hardening: shared HTTP base, SSRF protections, secret redaction in error logs, and a bounded delivery queue across the dispatcher, receiver, and all provider clients (telegram, discord, email, matrix, ntfy, slack, webhook) ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
---
## Development / Internal
### Refactoring
### Database
- Notifications: extract shared `http_base`, `redact`, and SSRF helpers; refactor dispatcher, queue, receiver, and every provider client onto the new base ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
- `EventLog` gains nullable `command_tracker_id` / `telegram_bot_id` FKs plus deletion-snapshot name columns; idempotent migration ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
- `/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 ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
### Tests
- New coverage: SSRF hardening, secret redaction, HTTP base, bounded queue, dispatcher aggregation, Telegram media partitioning, email and matrix clients ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
- New `test_command_event_logging.py` covers subject formatting, issuer normalization, the three event branches, and graceful failure when the DB is unreachable; full server suite passing 96/96 ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
### Tooling
- Document code-review-graph MCP usage in CLAUDE.md, register `.mcp.json`, and gitignore `.code-review-graph/` ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
## All Commits
---
<details>
<summary>Click to expand</summary>
<summary>All Commits</summary>
| Hash | Message | Author |
|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|------------------|
| [0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a) | feat: harden notification stack and switch logging selectors to icon grid | alexei.dolgolyov |
| [5bd63a2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5bd63a2) | feat(frontend): autogenerate entity names from type/provider | alexei.dolgolyov |
| Hash | Message | Author |
| ---- | ------- | ------ |
| [73b046f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/73b046f) | fix(frontend): cyrillic glyphs for nav and section labels | alexei.dolgolyov |
| [b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b) | feat(frontend): smoother event refresh, localized crumbs, template config deep-link | alexei.dolgolyov |
| [35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008) | feat: log bot command invocations to the event stream | alexei.dolgolyov |
</details>
+16 -2
View File
@@ -1,12 +1,12 @@
{
"name": "notify-bridge-frontend",
"version": "0.6.1",
"version": "0.7.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "notify-bridge-frontend",
"version": "0.6.1",
"version": "0.7.1",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/lang-html": "^6.4.11",
@@ -14,6 +14,7 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
@@ -607,6 +608,14 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true
},
"node_modules/@fontsource-variable/geist": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dm-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
@@ -2887,6 +2896,11 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true
},
"@fontsource-variable/geist": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="
},
"@fontsource/dm-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.7.0",
"version": "0.7.1",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -34,6 +34,7 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
+12 -6
View File
@@ -1,11 +1,17 @@
@import '@fontsource/geist-sans/300.css';
@import '@fontsource/geist-sans/400.css';
@import '@fontsource/geist-sans/500.css';
@import '@fontsource/geist-sans/600.css';
@import '@fontsource/geist-sans/700.css';
/* Sans: variable Geist ships latin + latin-ext + cyrillic in one woff2,
so RU and EN render in the same font instead of falling back to a
system sans for Cyrillic. Replaces the legacy ``@fontsource/geist-sans``
(latin-only) imports — see --font-sans below for the family rename. */
@import '@fontsource-variable/geist';
@import '@fontsource/geist-mono/400.css';
@import '@fontsource/geist-mono/500.css';
@import '@fontsource/geist-mono/600.css';
/* Geist Mono cyrillic subsets — same family name, additional unicode-range
declarations so Russian text renders in Geist Mono instead of falling
back to Cascadia/Consolas. */
@import '@fontsource/geist-mono/cyrillic-400.css';
@import '@fontsource/geist-mono/cyrillic-500.css';
@import '@fontsource/geist-mono/cyrillic-600.css';
@import '@fontsource/newsreader/300-italic.css';
@import '@fontsource/newsreader/400.css';
@import '@fontsource/newsreader/400-italic.css';
@@ -68,7 +74,7 @@
--shadow-card: 0 1px 0 rgba(255,255,255,0.07) inset, 0 30px 60px -20px rgba(0,0,0,0.6);
/* Typography */
--font-sans: 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-sans: 'Geist Variable', 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
--font-display: 'Newsreader', ui-serif, Georgia, serif;
@@ -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>
+16
View File
@@ -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[] => [
+52
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge",
"tagline": "Service notifications"
},
"crumbs": {
"routingNotification": "Routing · Notification",
"routingCommands": "Routing · Commands",
"routingTargets": "Routing · Targets",
"routingAutomation": "Routing · Automation",
"operatorsBots": "Operators · Bots",
"systemAccess": "System · Access",
"systemConfiguration": "System · Configuration",
"systemMaintenance": "System · Maintenance",
"serviceConnections": "Service · Connections"
},
"nav": {
"sectionOverview": "Overview",
"sectionRouting": "Routing",
@@ -87,6 +98,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 +117,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 +164,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",
@@ -313,6 +353,7 @@
"checkingLinks": "Checking links...",
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
"openTrackingConfig": "Open Tracking Config",
"openTemplateConfig": "Open Template Config",
"linkReplace": "Replace",
"linkReplacing": "Replacing...",
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
@@ -889,6 +930,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 +1067,8 @@
"edit": "Edit",
"description": "Description",
"close": "Close",
"hide": "Hide",
"show": "Show",
"confirm": "Confirm",
"cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:",
@@ -1149,6 +1193,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",
+52
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge",
"tagline": "Уведомления о сервисах"
},
"crumbs": {
"routingNotification": "Маршрутизация · Уведомления",
"routingCommands": "Маршрутизация · Команды",
"routingTargets": "Маршрутизация · Цели",
"routingAutomation": "Маршрутизация · Автоматизация",
"operatorsBots": "Операторы · Боты",
"systemAccess": "Система · Доступ",
"systemConfiguration": "Система · Настройки",
"systemMaintenance": "Система · Обслуживание",
"serviceConnections": "Сервис · Подключения"
},
"nav": {
"sectionOverview": "Обзор",
"sectionRouting": "Маршрутизация",
@@ -87,6 +98,15 @@
"actionSuccess": "действие выполнено",
"actionPartial": "действие частично",
"actionFailed": "действие провалено",
"commandHandled": "команда обработана",
"commandRateLimited": "ограничение частоты",
"commandFailed": "команда упала",
"autoRefreshTitle": "Интервал авто-обновления списка событий",
"refreshOff": "Выкл",
"refresh10s": "10с",
"refresh30s": "30с",
"refresh60s": "1м",
"refresh5m": "5м",
"searchEvents": "Поиск событий...",
"allEvents": "Все события",
"filterAssetsAdded": "Добавление файлов",
@@ -97,6 +117,9 @@
"filterActionSuccess": "Действие выполнено",
"filterActionPartial": "Действие частично",
"filterActionFailed": "Действие провалено",
"filterCommandHandled": "Команда обработана",
"filterCommandRateLimited": "Ограничение частоты",
"filterCommandFailed": "Команда упала",
"allProviders": "Все провайдеры",
"newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые",
@@ -141,6 +164,23 @@
"newTracker": "Новый трекер",
"eventsTotal": "Событий"
},
"events": {
"detailTitle": "Детали события",
"bot": "Бот",
"chat": "Чат",
"issuer": "Отправитель",
"commandTracker": "Командный трекер",
"tracker": "Трекер",
"action": "Действие",
"provider": "Провайдер",
"assetsCount": "Файлов",
"openProvider": "Открыть провайдера",
"openBot": "Открыть бота",
"openCommandTracker": "Открыть командный трекер",
"openAction": "Открыть действие",
"openTracker": "Открыть трекер",
"rawDetails": "Сырые данные"
},
"providers": {
"title": "Сервисные",
"titleEmphasis": "провайдеры",
@@ -313,6 +353,7 @@
"checkingLinks": "Проверка ссылок...",
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
"openTrackingConfig": "Открыть конфигурацию отслеживания",
"openTemplateConfig": "Открыть конфигурацию шаблона",
"linkReplace": "Пересоздать",
"linkReplacing": "Пересоздание...",
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
@@ -889,6 +930,7 @@
"titleEmphasis": "конфигурации",
"countLabel": "конфигураций",
"title": "Конфигурации команд",
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
"description": "Настройки команд для взаимодействия с Telegram-ботами",
"newConfig": "Новая конфигурация",
"name": "Название",
@@ -1025,6 +1067,8 @@
"edit": "Редактировать",
"description": "Описание",
"close": "Закрыть",
"hide": "Скрыть",
"show": "Показать",
"confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:",
@@ -1149,6 +1193,14 @@
"actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено",
"commandHandled": "Команда бота обработана",
"commandRateLimited": "Команда бота ограничена по частоте",
"commandFailed": "Команда бота вызвала ошибку",
"refreshOff": "Автообновление выключено",
"refresh10s": "Обновлять каждые 10 секунд",
"refresh30s": "Обновлять каждые 30 секунд",
"refresh60s": "Обновлять каждую минуту",
"refresh5m": "Обновлять каждые 5 минут",
"newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается",
+7
View File
@@ -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;
+138 -10
View File
@@ -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({ silent: true });
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 {
@@ -119,22 +163,53 @@
return params;
}
async function loadEvents() {
eventsLoading = true;
/** Reload the events panel.
*
* ``silent`` is set by the auto-refresh ticker so the loading
* placeholder doesn't flash and the row list isn't disturbed when
* nothing actually changed. We diff the new payload against the
* current ``status`` and reuse the existing ``recent_events`` array
* reference when the ID list is identical — that lets Svelte's keyed
* ``{#each}`` skip its diff entirely instead of patching every row.
*/
async function loadEvents(opts: { silent?: boolean } = {}) {
if (!opts.silent) eventsLoading = true;
try {
const params = buildFilterParams();
params.set('sort', filterSort);
params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset));
const qs = params.toString();
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
const next = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
if (opts.silent && status && _sameEventIds(status.recent_events, next.recent_events)) {
// Nothing changed in the visible page. Update only the
// out-of-band counts so the header and pager stay accurate;
// keep the existing array reference so no row re-renders.
status = {
...status,
providers: next.providers,
trackers: next.trackers,
targets: next.targets,
total_events: next.total_events,
command_trackers: next.command_trackers,
};
return;
}
status = next;
} catch (err: unknown) {
error = err instanceof Error ? err.message : t('common.error');
} finally {
eventsLoading = false;
if (!opts.silent) eventsLoading = false;
}
}
function _sameEventIds(a: { id: number }[], b: { id: number }[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i].id !== b[i].id) return false;
return true;
}
async function loadChart() {
try {
const params = buildFilterParams();
@@ -360,6 +435,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 +445,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 +459,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 +636,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()}
@@ -597,8 +684,11 @@
</div>
{:else}
<div class="signal-list stagger-children">
{#each status.recent_events as event, i}
<div class="signal-row" style="animation-delay: {i * 60}ms;">
{#each status.recent_events as event, i (event.id)}
<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 +705,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 +741,7 @@
<b>{timeShort(event.created_at)}</b>
<small>{timeAgo(event.created_at)}</small>
</div>
</div>
</button>
{/each}
</div>
@@ -790,6 +902,8 @@
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
<EventDetailModal event={selectedEvent} onclose={() => selectedEvent = null} />
<style>
/* ============================================================
HERO
@@ -1129,6 +1243,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;
+1 -1
View File
@@ -199,7 +199,7 @@
title={t('actions.title')}
emphasis={t('actions.titleEmphasis')}
description={t('actions.description')}
crumb="Routing · Automation"
crumb={t('crumbs.routingAutomation')}
count={actions.length}
countLabel={t('actions.countLabel')}
pills={headerPills}
+1 -1
View File
@@ -99,7 +99,7 @@
title={t('emailBot.title')}
emphasis={t('emailBot.titleEmphasis')}
description={t('emailBot.description')}
crumb="Operators · Bots"
crumb={t('crumbs.operatorsBots')}
count={emailBots.length}
countLabel={t('emailBot.countLabel')}
>
+1 -1
View File
@@ -97,7 +97,7 @@
title={t('matrixBot.title')}
emphasis={t('matrixBot.titleEmphasis')}
description={t('matrixBot.description')}
crumb="Operators · Bots"
crumb={t('crumbs.operatorsBots')}
count={matrixBots.length}
countLabel={t('matrixBot.countLabel')}
>
@@ -303,7 +303,7 @@
title={t('telegramBot.title')}
emphasis={t('telegramBot.titleEmphasis')}
description={t('telegramBot.description')}
crumb="Operators · Bots"
crumb={t('crumbs.operatorsBots')}
count={bots.length}
countLabel={t('telegramBot.countLabel')}
>
@@ -205,7 +205,7 @@
title={t('commandConfig.title')}
emphasis={t('commandConfig.titleEmphasis')}
description={t('commandConfig.description')}
crumb="Routing · Commands"
crumb={t('crumbs.routingCommands')}
count={configs.length}
countLabel={t('commandConfig.countLabel')}
pills={headerPills}
@@ -422,7 +422,7 @@
title={t('cmdTemplateConfig.title')}
emphasis={t('cmdTemplateConfig.titleEmphasis')}
description={t('cmdTemplateConfig.description')}
crumb="Routing · Commands"
crumb={t('crumbs.routingCommands')}
count={configs.length}
countLabel={t('cmdTemplateConfig.countLabel')}
pills={headerPills}
@@ -278,7 +278,7 @@
title={t('commandTracker.title')}
emphasis={t('commandTracker.titleEmphasis')}
description={t('commandTracker.description')}
crumb="Routing · Commands"
crumb={t('crumbs.routingCommands')}
count={trackers.length}
countLabel={t('dashboard.trackersShort')}
pills={headerPills}
@@ -468,7 +468,7 @@
title={t('notificationTracker.title')}
emphasis={t('notificationTracker.titleEmphasis')}
description={t('notificationTracker.description')}
crumb="Routing · Notification"
crumb={t('crumbs.routingNotification')}
count={notificationTrackers.length}
countLabel={t('dashboard.trackersShort')}
pills={headerPills}
@@ -227,13 +227,22 @@
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
<div class="flex-1 text-xs">
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
<a href={form.default_tracking_config_id
? `/tracking-configs?edit=${form.default_tracking_config_id}`
: '/tracking-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
<a href={form.default_tracking_config_id
? `/tracking-configs?edit=${form.default_tracking_config_id}`
: '/tracking-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
<a href={form.default_template_config_id
? `/template-configs?edit=${form.default_template_config_id}`
: '/template-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTemplateConfig')}
</a>
</div>
</div>
</div>
{/if}
+1 -1
View File
@@ -198,7 +198,7 @@
title={t('providers.title')}
emphasis={t('providers.titleEmphasis')}
description={t('providers.description')}
crumb="Service · Connections"
crumb={t('crumbs.serviceConnections')}
count={providers.length}
countLabel={t('dashboard.providersShort')}
pills={headerPills}
+1 -1
View File
@@ -101,7 +101,7 @@
title={t('settings.title')}
emphasis={t('settings.titleEmphasis')}
description={t('settings.description')}
crumb="System · Configuration"
crumb={t('crumbs.systemConfiguration')}
/>
{#if !loaded}
@@ -296,7 +296,7 @@
title={t('backup.title')}
emphasis={t('backup.titleEmphasis')}
description={t('backup.description')}
crumb="System · Maintenance"
crumb={t('crumbs.systemMaintenance')}
/>
{#if !loaded}
+1 -1
View File
@@ -453,7 +453,7 @@
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
crumb="Routing · Targets"
crumb={t('crumbs.routingTargets')}
count={targets.length}
countLabel={t('dashboard.targetsShort')}
pills={headerPills}
@@ -261,7 +261,25 @@
supportedLocalesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
}
// Cross-page deep-link: ``/template-configs?edit=<id>`` auto-opens that
// config in edit mode. Mirrors the same hook on tracking-configs so the
// Notification Tracker form can link directly to the editor instead of
// the generic list. Strips the param afterwards so a browser refresh
// doesn't re-open the modal.
function _openEditFromUrl() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const editId = params.get('edit');
if (!editId) return;
const match = allTemplateConfigs.find(c => String(c.id) === editId);
if (match) edit(match);
params.delete('edit');
const qs = params.toString();
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
window.history.replaceState(null, '', cleanUrl);
}
/**
@@ -429,7 +447,7 @@
title={t('templateConfig.title')}
emphasis={t('templateConfig.titleEmphasis')}
description={t('templateConfig.description')}
crumb="Routing · Notification"
crumb={t('crumbs.routingNotification')}
count={configs.length}
countLabel={t('templateConfig.countLabel')}
pills={headerPills}
@@ -276,7 +276,7 @@
title={t('trackingConfig.title')}
emphasis={t('trackingConfig.titleEmphasis')}
description={t('trackingConfig.description')}
crumb="Routing · Notification"
crumb={t('crumbs.routingNotification')}
count={configs.length}
countLabel={t('trackingConfig.countLabel')}
pills={headerPills}
+1 -1
View File
@@ -93,7 +93,7 @@
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb="System · Access"
crumb={t('crumbs.systemAccess')}
count={users.length}
countLabel={t('users.countLabel')}
>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.7.0"
version = "0.7.1"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.7.0"
version = "0.7.1"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -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())