Compare commits

..

12 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
alexei.dolgolyov 632e4c1aa3 chore: release v0.7.0
Release / release (push) Successful in 1m19s
2026-05-07 14:03:48 +03:00
alexei.dolgolyov 0eb899afb9 feat: harden notification stack and switch logging selectors to icon grid
Notifications:
- Add shared http_base, redact, and SSRF hardening modules
- Refactor dispatcher, queue, receiver and per-provider clients
  (telegram, discord, email, matrix, ntfy, slack, webhook) to use
  the shared base, with bounded queue and redacted error logs
- Tests for ssrf, redact, http_base, queue bounds, dispatcher
  aggregation, telegram media partition, email and matrix clients

Frontend:
- Settings: log level / log format selectors now use IconGridSelect
  with per-option icons and i18n descriptions
- Minor providers page and entity-cache store updates

Tooling:
- Document code-review-graph MCP usage in CLAUDE.md
- Ignore .code-review-graph/, register .mcp.json
2026-05-07 13:53:26 +03:00
alexei.dolgolyov 5bd63a2191 feat(frontend): autogenerate entity names from type/provider
Mirror the providers form pattern (defaultName tied to type) across
bots, targets, trackers, actions, and configs. Each form now derives
form.name from the selected type or provider while the user hasn't
manually edited it; switching to edit-mode flips the manualEdited
flag so existing names are preserved.

Defaults: bots → "<Type> Bot"; targets → type label; notification
trackers → "<provider> Tracker"; command trackers → "<provider>
Commands"; actions → "<provider> <Action Type>"; tracking/template/
command/command-template configs → "<descriptor.defaultName>
<Suffix>". TargetForm and TrackerForm grew an optional onnameinput
prop so parents can flag manual edits in subform inputs.
2026-05-07 13:01:52 +03:00
alexei.dolgolyov 349e9136a4 chore: release v0.6.5
Release / release (push) Successful in 1m36s
2026-04-28 19:10:49 +03:00
alexei.dolgolyov 04c8e3c8b2 feat(frontend): group command template slots into 4 logical fieldsets
Mirrors the notification-template page's group layout. Command slots
now split by name prefix into Command Responses, Error Messages
(rate_limited/no_results), Command Descriptions (desc_*), and Usage
Examples (usage_*). Language picker, reset-all, and slot filter are
hoisted above the groups so they apply across all fieldsets, and
empty groups are hidden so providers without usage_* don't render
empty headers.

Drops the orphan cmdTemplateConfig.commandResponsesHint i18n key —
hints.commandResponses replaces it.
2026-04-28 19:06:39 +03:00
alexei.dolgolyov 9afd38e50e fix(redesign): contain modal scroll chaining and smooth Telegram chat refresh
- Add overscroll-behavior: contain to all in-modal/popup scroll
  containers (Modal body, EntitySelect, MultiEntitySelect, IconPicker,
  IconGridSelect, SearchPalette, TimezoneSelector) so reaching the
  inner scroll boundary no longer scrolls the page underneath.
- Telegram bot Discover Chats no longer collapses the existing chat
  list into a "Loading…" placeholder. Split chatsLoading (initial)
  from chatsRefreshing (Discover); rows are keyed by chat.id with
  flip+fade animations; the list dims with a sweeping shimmer bar
  while the Discover button shows a spinning icon and "Discovering
  chats…" label. Honors prefers-reduced-motion.
2026-04-28 18:52:20 +03:00
alexei.dolgolyov aa9548d884 chore: release v0.6.4
Release / release (push) Successful in 1m41s
2026-04-27 18:27:09 +03:00
alexei.dolgolyov 72dd611f8c fix(telegram): respect chat_action UI choice, drop phantom indicator
chat_action was stored in two places — the model column and config JSON —
and dispatch_helpers unconditionally overrode the config value with the
column. The frontend only ever wrote the JSON path, so the UI choice
silently had no effect on outgoing chat actions.

Make the column the single source of truth: frontend sends chat_action
top-level, dispatch_helpers reads from the column, and a one-time
backfill migrates existing config values to the column and strips the
legacy key.

Also fix a long-standing race where the keepalive's bare sleep(4) +
finally cancel could fire one last sendChatAction after the response
already arrived, leaving a phantom indicator for ~5s. Replace with a
stop event + wait_for so callers can signal stop cleanly via the new
stop_keepalive helper.
2026-04-27 18:20:50 +03:00
74 changed files with 4273 additions and 1269 deletions
+2
View File
@@ -56,3 +56,5 @@ frontend/.svelte-kit/
# Logs
*.log
# Added by code-review-graph
.code-review-graph/
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": [
"code-review-graph",
"serve"
],
"type": "stdio"
}
}
}
+39
View File
@@ -43,3 +43,42 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the
- Notification preview sample: `packages/server/src/notify_bridge_server/services/sample_context.py` (`_SAMPLE_CONTEXT`)
- Command preview sample: `packages/server/src/notify_bridge_server/api/command_template_configs.py` (`sample_ctx` in `preview_raw`)
- Runtime validator whitelist: `packages/core/src/notify_bridge_core/templates/validator.py`
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
+21 -24
View File
@@ -1,35 +1,32 @@
# v0.6.3 (2026-04-27)
# v0.7.1 (2026-05-07)
Adds user filters for the Gitea tracker, makes the dashboard navigable, removes leftover webhook polling, and fixes the theme/sidebar flash on hard reload.
## 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
- **Gitea — sender filters:** `NotificationTracker` now exposes `sender_allowlist` and `sender_blocklist` via `MultiEntitySelect`. The picker is populated from `Gitea /users/search` merged with past `EventLog` senders, so it is useful even before the first webhook arrives ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
- **Dashboard navigability:** stat cards are now `<a>` links that route to providers, trackers, targets, command-trackers, or scroll to the events panel. Provider deck rows highlight the target provider on click ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
- **Command trackers / configs:** auto-reselect the matching config when the provider type changes, matching notification-tracker behaviour ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
- **Webhook providers (gitea, planka, webhook):** stop scheduling interval polling jobs on tracker create/update/startup, and hide the misleading "every Xs" indicator in the tracker list — webhook trackers do not poll ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
- 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))
### Bug Fixes
- **Theme FOUC on hard reload:** an inline blocking script in `app.html` now resolves the theme from `localStorage` (or `prefers-color-scheme`) and sets `data-theme` on `<html>` before first paint, eliminating the dark→light flash users saw when the light theme was selected ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
- **Sidebar jump on reload:** sidebar collapsed state and expanded nav groups now hydrate synchronously in their `$state` initialisers instead of inside `onMount`, so the sidebar no longer snaps from expanded→collapsed and groups no longer slide open after mount ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
- **Provider-filter row pop-in:** the global provider-filter row now stays rendered while `providersCache.fetchedAt === 0`, so it no longer pops in mid-paint and pushes the nav down once the cache resolves ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
---
## Development / Internal
### Build
- **Build-time app version:** `vite.config.ts` now reads `frontend/package.json` and exposes its version as an `__APP_VERSION__` global via Vite's `define`, with an ambient declaration in `app.d.ts` so the layout's brand version badge type-checks ([4307955](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4307955))
### Database
- **Migration:** drop legacy `batch_duration` column from `notification_tracker` — the field had been removed from the model but its `NOT NULL` constraint still blocked inserts on older DBs ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
- `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))
### Documentation
### Tests
- Refresh `.claude/docs/entity-relationships.md` with current `NotificationTracker` fields (filters, `adaptive_max_skip`, `default_*_config_id`) ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
- 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))
---
@@ -37,9 +34,9 @@ Adds user filters for the Gitea tracker, makes the dashboard navigable, removes
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [4307955](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4307955) | feat(frontend): inject `__APP_VERSION__` from package.json at build time | alexei.dolgolyov |
| [b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01) | fix(redesign): prevent theme FOUC and sidebar jump on hard reload | alexei.dolgolyov |
| [42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6) | feat(trackers): user filters for Gitea, webhook polling cleanup, dashboard navigability | alexei.dolgolyov |
| ---- | ------- | ------ |
| [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.6.3",
"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;
@@ -304,6 +304,7 @@
/* List */
.ep-list {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.35rem;
position: relative;
@@ -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>
@@ -195,6 +195,7 @@
padding: 0.5rem;
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
:global([data-theme="light"]) .icon-grid-popup { --igs-solid-bg: #fafafe; }
@@ -188,6 +188,7 @@
max-height: 14rem;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: thin;
position: relative;
z-index: 1;
+1
View File
@@ -192,6 +192,7 @@
z-index: 1;
padding: 0 1.5rem 1.5rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.modal-close {
@@ -307,6 +307,7 @@
.mes-list {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.25rem 0;
}
@@ -342,6 +342,7 @@
.sp-results {
max-height: 52vh;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.35rem;
position: relative;
@@ -530,6 +530,7 @@
.tz-list {
overflow-y: auto;
overscroll-behavior: contain;
/* No top padding — the sticky group head is at top:0 of the
scroll container, so any padding-top would let scrolling
items leak into the gap above the sticky header. */
+32
View File
@@ -73,6 +73,22 @@ export const localeItems = (): GridItem[] => [
{ value: 'ru', icon: 'mdiAlphabeticalVariant', label: 'Русский', desc: t('gridDesc.localeRu') },
];
// --- Log level ---
export const logLevelItems = (): GridItem[] => [
{ value: 'DEBUG', icon: 'mdiBugOutline', label: 'DEBUG', desc: t('gridDesc.logLevelDebug') },
{ value: 'INFO', icon: 'mdiInformationOutline', label: 'INFO', desc: t('gridDesc.logLevelInfo') },
{ value: 'WARNING', icon: 'mdiAlertOutline', label: 'WARNING', desc: t('gridDesc.logLevelWarning') },
{ value: 'ERROR', icon: 'mdiAlertOctagonOutline', label: 'ERROR', desc: t('gridDesc.logLevelError') },
];
// --- Log format ---
export const logFormatItems = (): GridItem[] => [
{ value: 'text', icon: 'mdiFormatText', label: 'text', desc: t('gridDesc.logFormatText') },
{ value: 'json', icon: 'mdiCodeJson', label: 'json', desc: t('gridDesc.logFormatJson') },
];
// --- Response mode ---
export const responseModeItems = (tFn: typeof t): GridItem[] => [
@@ -92,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) ---
@@ -101,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[] => [
+69 -3
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",
@@ -192,7 +232,8 @@
"apiToken": "API Token",
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
"webhookUrl": "Webhook URL",
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings (relative to your bridge host).",
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings. The full URL is shown when an external base URL is configured in Settings; otherwise it is relative to your bridge host.",
"webhookUrlCopyTitle": "Click to copy",
"nutHost": "NUT Server Host",
"nutHostPlaceholder": "192.168.1.100 or ups.local",
"nutPort": "NUT Server Port",
@@ -312,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}\"",
@@ -475,6 +517,7 @@
"noCommandsForProvider": "This provider type does not support bot commands.",
"syncCommands": "Sync Commands",
"discoverChats": "Discover chats from Telegram",
"discoveringChats": "Discovering chats…",
"clickToCopy": "Click to copy chat ID",
"chatsDiscovered": "Chats discovered",
"chatDeleted": "Chat removed",
@@ -822,7 +865,11 @@
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
"responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.",
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit."
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit.",
"commandResponses": "Reply templates for each /command. Use {variables} to inject dynamic data.",
"commandErrors": "Fallback messages shown when a command can't run (rate-limited) or returns nothing.",
"commandDescriptions": "Short menu blurbs Telegram shows next to each /command in the chat command picker.",
"commandUsage": "Example invocations rendered inside /help to show users how to call each command."
},
"matrixBot": {
"titleEmphasis": "matrix",
@@ -875,12 +922,15 @@
"noConfigs": "No command template configs yet.",
"confirmDelete": "Delete this command template config?",
"commandResponses": "Command Responses",
"commandResponsesHint": "Leave a slot empty to use the default hardcoded response."
"commandErrors": "Error Messages",
"commandDescriptions": "Command Descriptions",
"commandUsage": "Usage Examples"
},
"commandConfig": {
"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",
@@ -1017,6 +1067,8 @@
"edit": "Edit",
"description": "Description",
"close": "Close",
"hide": "Hide",
"show": "Show",
"confirm": "Confirm",
"cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:",
@@ -1124,6 +1176,12 @@
"memorySourceNative": "Use Immich native memories API",
"localeEn": "English interface",
"localeRu": "Russian interface",
"logLevelDebug": "Verbose — show every step",
"logLevelInfo": "Default — high-level events",
"logLevelWarning": "Warnings and errors only",
"logLevelError": "Errors only — quietest",
"logFormatText": "Human-readable plain text",
"logFormatJson": "One JSON object per line",
"modeMedia": "Send actual photo/video files",
"modeText": "Send file names and links only",
"allEvents": "Show all event types",
@@ -1135,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",
+69 -3
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": "провайдеры",
@@ -192,7 +232,8 @@
"apiToken": "API токен",
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
"webhookUrl": "URL вебхука",
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea (относительно хоста bridge).",
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea. Полный URL показывается, если в настройках задан внешний адрес; иначе путь указан относительно хоста bridge.",
"webhookUrlCopyTitle": "Нажмите, чтобы скопировать",
"nutHost": "Хост NUT-сервера",
"nutHostPlaceholder": "192.168.1.100 или ups.local",
"nutPort": "Порт NUT-сервера",
@@ -312,6 +353,7 @@
"checkingLinks": "Проверка ссылок...",
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
"openTrackingConfig": "Открыть конфигурацию отслеживания",
"openTemplateConfig": "Открыть конфигурацию шаблона",
"linkReplace": "Пересоздать",
"linkReplacing": "Пересоздание...",
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
@@ -475,6 +517,7 @@
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
"syncCommands": "Синхр. команды",
"discoverChats": "Обнаружить чаты из Telegram",
"discoveringChats": "Поиск чатов…",
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
"chatsDiscovered": "Чаты обнаружены",
"chatDeleted": "Чат удалён",
@@ -822,7 +865,11 @@
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений.",
"commandResponses": "Шаблоны ответов на каждую /команду. Используйте {переменные} для динамических данных.",
"commandErrors": "Резервные сообщения, когда команда не может выполниться (превышен лимит) или ничего не возвращает.",
"commandDescriptions": "Короткие подписи в меню команд Telegram, которые показываются рядом с каждой /командой.",
"commandUsage": "Примеры вызовов, отображаемые в /help, чтобы показать пользователям как вызывать каждую команду."
},
"matrixBot": {
"titleEmphasis": "matrix",
@@ -875,12 +922,15 @@
"noConfigs": "Шаблонов команд пока нет.",
"confirmDelete": "Удалить этот шаблон команд?",
"commandResponses": "Ответы команд",
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
"commandErrors": "Сообщения об ошибках",
"commandDescriptions": "Описания команд",
"commandUsage": "Примеры использования"
},
"commandConfig": {
"titleEmphasis": "конфигурации",
"countLabel": "конфигураций",
"title": "Конфигурации команд",
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
"description": "Настройки команд для взаимодействия с Telegram-ботами",
"newConfig": "Новая конфигурация",
"name": "Название",
@@ -1017,6 +1067,8 @@
"edit": "Редактировать",
"description": "Описание",
"close": "Закрыть",
"hide": "Скрыть",
"show": "Показать",
"confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:",
@@ -1124,6 +1176,12 @@
"memorySourceNative": "Использовать API воспоминаний Immich",
"localeEn": "Английский интерфейс",
"localeRu": "Русский интерфейс",
"logLevelDebug": "Подробный — каждый шаг",
"logLevelInfo": "По умолчанию — ключевые события",
"logLevelWarning": "Только предупреждения и ошибки",
"logLevelError": "Только ошибки — самый тихий",
"logFormatText": "Читаемый человеком текст",
"logFormatJson": "Один JSON-объект на строку",
"modeMedia": "Отправка файлов фото/видео",
"modeText": "Только имена файлов и ссылки",
"allEvents": "Показать все типы событий",
@@ -1135,6 +1193,14 @@
"actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено",
"commandHandled": "Команда бота обработана",
"commandRateLimited": "Команда бота ограничена по частоте",
"commandFailed": "Команда бота вызвала ошибку",
"refreshOff": "Автообновление выключено",
"refresh10s": "Обновлять каждые 10 секунд",
"refresh30s": "Обновлять каждые 30 секунд",
"refresh60s": "Обновлять каждую минуту",
"refresh5m": "Обновлять каждые 5 минут",
"newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается",
+28
View File
@@ -112,6 +112,34 @@ export const capabilitiesCache = (() => {
};
})();
/** Configured external base URL — used to render absolute webhook URLs.
* Available to all authenticated users. Empty string when unset. */
export const externalUrlCache = (() => {
let data = $state<string>('');
let fetchedAt = $state(0);
let inflight: Promise<string> | null = null;
const TTL = 300_000;
return {
get value() { return data; },
invalidate() { fetchedAt = 0; },
async fetch(force = false): Promise<string> {
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
if (inflight) return inflight;
inflight = (async () => {
try {
const res = await api<{ external_url: string }>('/settings/external-url');
data = (res?.external_url || '').replace(/\/+$/, '');
fetchedAt = Date.now();
return data;
} finally {
inflight = null;
}
})();
return inflight;
},
};
})();
/** Supported template locales — fetched from app settings. */
export const supportedLocalesCache = (() => {
let data = $state<string[]>(['en', 'ru']);
+8
View File
@@ -106,6 +106,7 @@ export interface NotificationTarget {
name: string;
icon: string;
config: Record<string, any>;
chat_action?: string | null;
chat_name?: string;
receiver_count: number;
receivers: TargetReceiver[];
@@ -216,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;
+16 -2
View File
@@ -40,7 +40,19 @@
schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
enabled: false,
});
let nameManuallyEdited = $state(false);
let error = $state('');
function actionTypeLabel(at: string): string {
return at.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const provider = providers.find((p: any) => p.id === form.provider_id);
const at = actionTypeLabel(form.action_type || '');
form.name = provider ? `${provider.name} ${at}`.trim() : at || 'Action';
}
});
let loadError = $state('');
let submitting = $state(false);
let loaded = $state(false);
@@ -98,6 +110,7 @@
config: {}, schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
enabled: false,
};
nameManuallyEdited = false;
editing = null; showForm = true;
}
@@ -109,6 +122,7 @@
schedule_interval: action.schedule_interval,
schedule_cron: action.schedule_cron, enabled: action.enabled,
};
nameManuallyEdited = true;
editing = action.id; showForm = true;
}
@@ -185,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}
@@ -245,7 +259,7 @@
<label for="act-name" class="block text-sm font-medium mb-1">{t('actions.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="act-name" bind:value={form.name} required
<input id="act-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
+13 -4
View File
@@ -30,8 +30,16 @@
smtp_username: '', smtp_password: '', smtp_use_tls: true,
});
let emailForm = $state(defaultEmailForm());
let nameManuallyEdited = $state(false);
function openNewEmail() { emailForm = defaultEmailForm(); editingEmail = null; showEmailForm = true; }
const DEFAULT_BOT_NAME = 'Email Bot';
$effect(() => {
if (showEmailForm && !nameManuallyEdited && !editingEmail) {
emailForm.name = DEFAULT_BOT_NAME;
}
});
function openNewEmail() { emailForm = defaultEmailForm(); nameManuallyEdited = false; editingEmail = null; showEmailForm = true; }
function editEmailBot(bot: EmailBot) {
emailForm = {
name: bot.name, icon: bot.icon || '', email: bot.email,
@@ -39,6 +47,7 @@
smtp_username: bot.smtp_username, smtp_password: '',
smtp_use_tls: bot.smtp_use_tls,
};
nameManuallyEdited = true;
editingEmail = bot.id; showEmailForm = true;
}
@@ -54,7 +63,7 @@
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.emailBotCreated'));
}
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await onreload();
emailForm = defaultEmailForm(); nameManuallyEdited = false; showEmailForm = false; editingEmail = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { emailSubmitting = false; }
}
@@ -90,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')}
>
@@ -107,7 +116,7 @@
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
<input id="ebot-name" bind:value={emailForm.name} required placeholder={t('emailBot.namePlaceholder')}
<input id="ebot-name" bind:value={emailForm.name} oninput={() => nameManuallyEdited = true} required placeholder={t('emailBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
+13 -4
View File
@@ -29,14 +29,23 @@
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
});
let matrixForm = $state(defaultMatrixForm());
let nameManuallyEdited = $state(false);
function openNewMatrix() { matrixForm = defaultMatrixForm(); editingMatrix = null; showMatrixForm = true; }
const DEFAULT_BOT_NAME = 'Matrix Bot';
$effect(() => {
if (showMatrixForm && !nameManuallyEdited && !editingMatrix) {
matrixForm.name = DEFAULT_BOT_NAME;
}
});
function openNewMatrix() { matrixForm = defaultMatrixForm(); nameManuallyEdited = false; editingMatrix = null; showMatrixForm = true; }
function editMatrixBot(bot: MatrixBot) {
matrixForm = {
name: bot.name, icon: bot.icon || '',
homeserver_url: bot.homeserver_url, access_token: '',
display_name: bot.display_name || '',
};
nameManuallyEdited = true;
editingMatrix = bot.id; showMatrixForm = true;
}
@@ -52,7 +61,7 @@
await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.matrixBotCreated'));
}
matrixForm = defaultMatrixForm(); showMatrixForm = false; editingMatrix = null; await onreload();
matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { matrixSubmitting = false; }
}
@@ -88,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')}
>
@@ -105,7 +114,7 @@
<label for="mbot-name" class="block text-sm font-medium mb-1">{t('matrixBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={matrixForm.icon} onselect={(v: string) => matrixForm.icon = v} />
<input id="mbot-name" bind:value={matrixForm.name} required placeholder={t('matrixBot.namePlaceholder')}
<input id="mbot-name" bind:value={matrixForm.name} oninput={() => nameManuallyEdited = true} required placeholder={t('matrixBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
+158 -61
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { slide, fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
@@ -28,13 +29,25 @@
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: '', icon: '', token: '' });
let nameManuallyEdited = $state(false);
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
const DEFAULT_BOT_NAME = 'Telegram Bot';
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
form.name = DEFAULT_BOT_NAME;
}
});
// Per-bot expandable sections
let chats = $state<Record<number, TelegramChat[]>>({});
let chatsLoading = $state<Record<number, boolean>>({});
// Distinct from chatsLoading: refresh keeps the existing list visible
// instead of swapping it for a placeholder, avoiding the disorienting
// "everything disappears" flash during Discover.
let chatsRefreshing = $state<Record<number, boolean>>({});
let expandedSection = $state<Record<number, string>>({});
// Webhook status per bot
@@ -47,8 +60,8 @@
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
let botListenerLoading = $state<Record<number, boolean>>({});
function openNew() { form = { name: '', icon: '', token: '' }; editing = null; showForm = true; }
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
function openNew() { form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; editing = null; showForm = true; }
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; nameManuallyEdited = true; editing = bot.id; showForm = true; }
async function saveBot(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
@@ -60,7 +73,7 @@
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
snackSuccess(t('snack.botRegistered'));
}
form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await onreload();
form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; showForm = false; editing = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; }
}
@@ -98,12 +111,13 @@
}
async function discoverChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
if (chatsRefreshing[botId]) return;
chatsRefreshing = { ...chatsRefreshing, [botId]: true };
try {
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
chatsLoading = { ...chatsLoading, [botId]: false };
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
}
async function deleteChat(botId: number, chatDbId: number) {
@@ -289,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')}
>
@@ -306,7 +320,7 @@
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
<input id="bot-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('telegramBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
@@ -371,66 +385,80 @@
<!-- Chats section -->
{#if expandedSection[bot.id] === 'chats'}
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
{#if chatsLoading[bot.id]}
{#if chatsLoading[bot.id] && !chats[bot.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
{:else if (chats[bot.id] || []).length === 0}
{:else if (chats[bot.id] || []).length === 0 && !chatsRefreshing[bot.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
{:else}
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
<!-- Header -->
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
<span>{t('telegramBot.chatName')}</span>
<span style="text-align:center">{t('telegramBot.chatType')}</span>
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
<span style="text-align:center">{t('telegramBot.cmds')}</span>
<span style="text-align:center">{t('telegramBot.chatId')}</span>
<span></span>
</div>
<!-- Rows -->
{#each chats[bot.id] as chat}
<div style={gridStyle}
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
title={t('telegramBot.clickToCopy')}
aria-label={t('telegramBot.clickToCopy')}
role="button" tabindex="0">
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<EntitySelect
items={LANG_ITEMS}
value={chat.language_override || ''}
size="sm"
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
/>
</div>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<button
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
title={t('telegramBot.commandsToggle')}
onclick={() => toggleChatCommands(bot.id, chat)}>
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
</button>
</div>
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
<div style="justify-self:end" class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('common.test')} size={14}
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
</div>
<div class="chat-list-wrap" class:is-refreshing={chatsRefreshing[bot.id]}>
{#if chatsRefreshing[bot.id]}
<div class="chat-shimmer" aria-hidden="true" transition:fade={{ duration: 180 }}></div>
{/if}
<!-- Header -->
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
<span>{t('telegramBot.chatName')}</span>
<span style="text-align:center">{t('telegramBot.chatType')}</span>
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
<span style="text-align:center">{t('telegramBot.cmds')}</span>
<span style="text-align:center">{t('telegramBot.chatId')}</span>
<span></span>
</div>
{/each}
<!-- Rows -->
{#each (chats[bot.id] || []) as chat (chat.id)}
<div style={gridStyle}
class="chat-row text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
animate:flip={{ duration: 280 }}
in:fade={{ duration: 220, delay: 60 }}
out:fade={{ duration: 140 }}
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
title={t('telegramBot.clickToCopy')}
aria-label={t('telegramBot.clickToCopy')}
role="button" tabindex="0">
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<EntitySelect
items={LANG_ITEMS}
value={chat.language_override || ''}
size="sm"
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
/>
</div>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<button
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
title={t('telegramBot.commandsToggle')}
onclick={() => toggleChatCommands(bot.id, chat)}>
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
</button>
</div>
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
<div style="justify-self:end" class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('common.test')} size={14}
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
</div>
</div>
{/each}
{#if chatsRefreshing[bot.id] && (chats[bot.id] || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] py-2 px-2">{t('telegramBot.discoveringChats')}</p>
{/if}
</div>
{/if}
<button onclick={() => discoverChats(bot.id)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
disabled={chatsRefreshing[bot.id]}
class="discover-btn text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1 disabled:opacity-70 disabled:cursor-default disabled:no-underline">
<span class="discover-icon" class:is-spinning={chatsRefreshing[bot.id]}>
<MdiIcon name="mdiSync" size={14} />
</span>
{chatsRefreshing[bot.id] ? t('telegramBot.discoveringChats') : t('telegramBot.discoverChats')}
</button>
</div>
{/if}
@@ -553,3 +581,72 @@
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
/* Chat list — smooth refresh state.
The list stays mounted during Discover; we only dim it slightly
and run a thin shimmer bar across the top so the user sees
"refreshing" instead of "everything vanished and came back". */
.chat-list-wrap {
position: relative;
transition: opacity 0.25s ease, filter 0.25s ease;
}
.chat-list-wrap.is-refreshing {
opacity: 0.78;
filter: saturate(0.9);
}
.chat-list-wrap.is-refreshing .chat-row {
pointer-events: none;
}
.chat-shimmer {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
overflow: hidden;
border-radius: 2px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
z-index: 2;
}
.chat-shimmer::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--color-primary) 70%, transparent) 50%,
transparent 100%
);
transform: translateX(-100%);
animation: chat-shimmer-sweep 1.15s ease-in-out infinite;
}
@keyframes chat-shimmer-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.discover-icon {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.discover-icon.is-spinning {
animation: discover-spin 1s linear infinite;
}
@keyframes discover-spin {
to { transform: rotate(-360deg); }
}
@media (prefers-reduced-motion: reduce) {
.chat-shimmer::after,
.discover-icon.is-spinning {
animation: none;
}
.chat-list-wrap {
transition: none;
}
}
</style>
@@ -21,6 +21,7 @@
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { getDescriptor } from '$lib/providers';
import type { CommandConfig } from '$lib/types';
function templateName(id: number | null): string {
@@ -69,6 +70,14 @@
command_template_config_id: null as number | null,
});
let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const desc = getDescriptor(form.provider_type);
form.name = desc ? `${desc.defaultName} Commands` : 'Commands';
}
});
let allCapabilities = $derived(capabilitiesCache.items);
let providerCommands = $derived<{key: string, icon: string}[]>(
@@ -107,6 +116,7 @@
// Auto-select first matching template for the chosen provider_type
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
if (match) form.command_template_config_id = match.id;
nameManuallyEdited = false;
editing = null;
showForm = true;
}
@@ -142,6 +152,7 @@
rate_limits: { search: cfg.rate_limits?.search ?? 30, default: cfg.rate_limits?.default ?? 10 },
command_template_config_id: cfg.command_template_config_id ?? null,
};
nameManuallyEdited = true;
editing = cfg.id;
showForm = true;
}
@@ -165,7 +176,7 @@
await api('/command-configs', { method: 'POST', body });
snackSuccess(t('snack.commandConfigSaved'));
}
form = defaultForm(); showForm = false; editing = null; await load();
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; }
}
@@ -194,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}
@@ -214,7 +225,7 @@
<label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="cfg-name" bind:value={form.name} required placeholder={t('commandConfig.namePlaceholder')}
<input id="cfg-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('commandConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
@@ -20,11 +20,13 @@
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
import Hint from '$lib/components/Hint.svelte';
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
import { getLocaleMeta } from '$lib/locales';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
interface CmdTemplateConfig {
id: number;
@@ -119,6 +121,14 @@
slots: {} as Record<string, Record<string, string>>,
});
let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const desc = getDescriptor(form.provider_type);
form.name = desc ? `${desc.defaultName} Command Templates` : 'Command Templates';
}
});
// Provider capabilities
let allCapabilities = $state<Record<string, any>>({});
@@ -126,11 +136,40 @@
let commandSlots = $derived<SlotDef[]>(
allCapabilities[form.provider_type]?.command_slots || []
);
let filteredCmdSlots = $derived(
slotFilter
? commandSlots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase()))
: commandSlots
);
const ERROR_SLOTS = new Set(['rate_limited', 'no_results']);
/**
* Group command slots by purpose so the form mirrors how notification
* templates are split (event vs scheduled vs settings).
*
* commandResponses — primary reply templates (/start, /help, /status, data slots)
* commandErrors — fallback messages (rate_limited, no_results)
* commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker
* commandUsage — usage_* slots: invocation examples shown by /help
*/
let commandSlotGroups = $derived([
{
group: 'commandResponses',
slots: commandSlots.filter(s =>
!s.name.startsWith('desc_') &&
!s.name.startsWith('usage_') &&
!ERROR_SLOTS.has(s.name)
),
},
{
group: 'commandErrors',
slots: commandSlots.filter(s => ERROR_SLOTS.has(s.name)),
},
{
group: 'commandDescriptions',
slots: commandSlots.filter(s => s.name.startsWith('desc_')),
},
{
group: 'commandUsage',
slots: commandSlots.filter(s => s.name.startsWith('usage_')),
},
]);
/** Get slot template for current locale, with fallback. */
function getSlotValue(slotName: string): string {
@@ -227,6 +266,7 @@
form = defaultForm();
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
nameManuallyEdited = false;
editing = null;
showForm = true;
activeLocale = primaryLocale;
@@ -250,6 +290,7 @@
icon: c.icon || '',
slots: slotsCopy,
};
nameManuallyEdited = true;
editing = c.id;
showForm = true;
activeLocale = primaryLocale;
@@ -381,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}
@@ -402,7 +443,7 @@
<label for="ct-name" class="block text-sm font-medium mb-1">{t('cmdTemplateConfig.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="ct-name" bind:value={form.name} required placeholder={t('cmdTemplateConfig.namePlaceholder')}
<input id="ct-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('cmdTemplateConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
@@ -424,93 +465,98 @@
</div>
{/if}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<!-- Language picker -->
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
{t('templateConfig.language')}
</span>
<div class="flex-1 max-w-xs">
<EntitySelect
items={localeItems}
value={activeLocale}
size="sm"
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
/>
</div>
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
<MdiIcon name="mdiRefresh" size={12} />
{t('templateConfig.resetAllToDefaults')}
</button>
{/if}
</div>
<!-- Slot filter -->
{#if commandSlots.length > 4}
<div class="mb-3">
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<!-- Language picker -->
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
{t('templateConfig.language')}
</span>
<div class="flex-1 max-w-xs">
<EntitySelect
items={localeItems}
value={activeLocale}
size="sm"
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
/>
</div>
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
<MdiIcon name="mdiRefresh" size={12} />
{t('templateConfig.resetAllToDefaults')}
</button>
{/if}
</div>
<div class="space-y-2">
{#each filteredCmdSlots as slot}
<CollapsibleSlot
label={slot.name}
description="/{slot.name}{slot.description}"
expanded={expandedSlots.has(slot.name)}
status={getSlotStatus(slot.name)}
ontoggle={() => toggleSlot(slot.name)}
>
<div class="flex items-center justify-end gap-2 mb-2">
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
<button type="button" onclick={() => togglePreview(slot.name)}
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
{t('templateConfig.preview')}
<!-- Slot filter -->
{#if commandSlots.length > 4}
<div>
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{/if}
{#each commandSlotGroups.filter(g => g.slots.length > 0) as group}
{@const filteredSlots = slotFilter ? group.slots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase())) : group.slots}
{#if filteredSlots.length > 0}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">
{t(`cmdTemplateConfig.${group.group}`)}<Hint text={t(`hints.${group.group}`)} />
</legend>
<div class="space-y-2 mt-2">
{#each filteredSlots as slot}
<CollapsibleSlot
label={slot.name}
description="/{slot.name}{slot.description}"
expanded={expandedSlots.has(slot.name)}
status={getSlotStatus(slot.name)}
ontoggle={() => toggleSlot(slot.name)}
>
<div class="flex items-center justify-end gap-2 mb-2">
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
<button type="button" onclick={() => togglePreview(slot.name)}
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
{t('templateConfig.preview')}
</button>
{/if}
{#if getVarsFor(slot.name)}
<button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
{/if}
{#if getVarsFor(slot.name)}
<button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
</div>
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
</div>
{:else}
<JinjaEditor
value={getSlotValue(slot.name)}
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3}
errorLine={slotErrorLines[slot.name] || null}
variables={getVarsFor(slot.name) || undefined}
/>
{/if}
{#if slotErrors[slot.name]}
{#if slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
</div>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
<JinjaEditor
value={getSlotValue(slot.name)}
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3}
errorLine={slotErrorLines[slot.name] || null}
variables={getVarsFor(slot.name) || undefined}
/>
{/if}
{/if}
</CollapsibleSlot>
{/each}
</div>
</fieldset>
{#if slotErrors[slot.name]}
{#if slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
{/if}
{/if}
</CollapsibleSlot>
{/each}
</div>
</fieldset>
{/if}
{/each}
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{editing ? t('common.save') : t('common.create')}
@@ -61,6 +61,14 @@
enabled: true,
});
let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const provider = providers.find(p => p.id === form.provider_id);
form.name = provider ? `${provider.name} Commands` : 'Commands';
}
});
// Filter command configs by selected provider's type
let filteredConfigs = $derived.by(() => {
@@ -110,6 +118,7 @@
const firstCfg = commandConfigs.find(c => c.provider_type === ptype);
if (firstCfg) form.command_config_id = firstCfg.id;
}
nameManuallyEdited = false;
editing = null;
showForm = true;
}
@@ -141,6 +150,7 @@
command_config_id: trk.command_config_id,
enabled: trk.enabled,
};
nameManuallyEdited = true;
editing = trk.id;
showForm = true;
}
@@ -156,7 +166,7 @@
await api('/command-trackers', { method: 'POST', body });
snackSuccess(t('snack.commandTrackerCreated'));
}
form = defaultForm(); showForm = false; editing = null; await load();
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; }
}
@@ -268,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}
@@ -288,7 +298,7 @@
<label for="trk-name" class="block text-sm font-medium mb-1">{t('commandTracker.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="trk-name" bind:value={form.name} required placeholder={t('commandTracker.namePlaceholder')}
<input id="trk-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('commandTracker.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
@@ -70,11 +70,19 @@
filters: {} as Record<string, any>,
});
let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
let selectedProviderType = $derived(
providers.find(p => p.id === form.provider_id)?.type || ''
);
let error = $state('');
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const provider = providers.find(p => p.id === form.provider_id);
form.name = provider ? `${provider.name} Tracker` : 'Tracker';
}
});
// Linked targets management
let expandedTracker = $state<number | null>(null);
let addingTarget = $state<Record<number, boolean>>({});
@@ -210,6 +218,7 @@
form = defaultForm();
// Auto-select first provider if any
if (providers.length > 0) form.provider_id = providers[0].id;
nameManuallyEdited = false;
editing = null; showForm = true; collections = []; users = []; previousCollectionIds = [];
}
@@ -224,6 +233,7 @@
filters: trk.filters || {},
};
previousCollectionIds = [...(trk.collection_ids || [])];
nameManuallyEdited = true;
editing = trk.id; showForm = true;
if (form.provider_id) {
await Promise.all([loadCollections(), loadUsers()]);
@@ -458,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}
@@ -491,6 +501,7 @@
onsave={save}
ontoggleCollection={toggleCollection}
{formatDate}
onnameinput={() => nameManuallyEdited = true}
/>
{/if}
@@ -35,6 +35,7 @@
onsave: (e: SubmitEvent) => void;
ontoggleCollection?: (collectionId: string) => void;
formatDate?: (dateStr: string) => string;
onnameinput?: () => void;
}
let {
@@ -53,6 +54,7 @@
onsave,
ontoggleCollection,
formatDate,
onnameinput,
}: Props = $props();
let descriptor = $derived(getDescriptor(providerType));
@@ -95,7 +97,7 @@
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="trk-name" bind:value={form.name} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<input id="trk-name" bind:value={form.name} oninput={() => onnameinput?.()} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
@@ -225,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}
+43 -5
View File
@@ -3,7 +3,7 @@
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -21,7 +21,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { onDestroy } from 'svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
import Button from '$lib/components/Button.svelte';
@@ -45,6 +45,30 @@
let confirmDelete = $state<ServiceProvider | null>(null);
let descriptor = $derived(getDescriptor(form.type));
let externalUrl = $derived(externalUrlCache.value);
function buildWebhookUrl(pattern: string, token: string): string {
const path = pattern.replace('{token}', token ?? '');
return externalUrl ? `${externalUrl}${path}` : path;
}
function copyWebhookUrl(e: Event, url: string) {
e.preventDefault();
e.stopPropagation();
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url);
} else {
const ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
snackInfo(`${t('snack.copied')}: ${url}`);
}
// Auto-update name when provider type changes (unless user manually edited)
$effect(() => {
@@ -76,6 +100,7 @@
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
externalUrlCache.fetch().catch(() => { /* fall back to relative URLs */ });
});
onDestroy(() => topbarAction.clear());
async function load() {
@@ -173,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}
@@ -246,9 +271,15 @@
</div>
{/each}
{#if descriptor?.webhookUrlPattern && editing}
{@const editingWebhookUrl = buildWebhookUrl(descriptor.webhookUrlPattern, providers.find(p => p.id === editing)?.webhook_token ?? '')}
<div class="bg-[var(--color-muted)] rounded-md p-3">
<div class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</div>
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{token}', providers.find(p => p.id === editing)?.webhook_token ?? '')}</code>
<button type="button"
onclick={(e) => copyWebhookUrl(e, editingWebhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="text-xs break-all text-left hover:text-[var(--color-primary)] cursor-pointer font-mono w-full">
<code class="bg-transparent">{editingWebhookUrl}</code>
</button>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
</div>
{/if}
@@ -295,7 +326,14 @@
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provDesc?.webhookUrlPattern}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">{provDesc.webhookUrlPattern.replace('{token}', provider.webhook_token)}</span></p>
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
{t('providers.webhookUrl')}:
<button type="button"
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
</p>
{/if}
</div>
</div>
+7 -13
View File
@@ -12,7 +12,10 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { logLevelItems, logFormatItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { externalUrlCache } from '$lib/stores/caches.svelte';
interface CacheBucketStats {
count: number;
@@ -76,6 +79,7 @@
saving = true; error = '';
try {
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
externalUrlCache.invalidate();
snackSuccess(t('settings.saved'));
} catch (err: any) { error = err.message; snackError(err.message); }
saving = false;
@@ -97,7 +101,7 @@
title={t('settings.title')}
emphasis={t('settings.titleEmphasis')}
description={t('settings.description')}
crumb="System · Configuration"
crumb={t('crumbs.systemConfiguration')}
/>
{#if !loaded}
@@ -221,21 +225,11 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logLevel')}<Hint text={t('settings.logLevelHint')} /></label>
<select bind:value={settings.log_level}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<IconGridSelect items={logLevelItems()} bind:value={settings.log_level} columns={2} />
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logFormat')}<Hint text={t('settings.logFormatHint')} /></label>
<select bind:value={settings.log_format}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="text">text</option>
<option value="json">json</option>
</select>
<IconGridSelect items={logFormatItems()} bind:value={settings.log_format} columns={2} />
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium mb-1">{t('settings.logLevels')}<Hint text={t('settings.logLevelsHint')} /></label>
@@ -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}
+22 -5
View File
@@ -129,6 +129,7 @@
child_target_ids: [] as number[],
});
let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
let error = $state('');
let loaded = $state(false);
let submitting = $state(false);
@@ -137,6 +138,17 @@
let confirmDelete = $state<NotificationTarget | null>(null);
let formEl = $state<HTMLElement | undefined>();
const TARGET_TYPE_DEFAULT_NAMES: Record<TargetType, string> = {
telegram: 'Telegram', webhook: 'Webhook', email: 'Email',
discord: 'Discord', slack: 'Slack', ntfy: 'ntfy', matrix: 'Matrix',
broadcast: 'Broadcast',
};
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
form.name = TARGET_TYPE_DEFAULT_NAMES[formType] ?? '';
}
});
async function scrollToForm() {
await tick();
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -213,6 +225,7 @@
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
nameManuallyEdited = false;
editing = null;
showTelegramSettings = false;
showForm = true;
@@ -229,7 +242,7 @@
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing',
ai_captions: c.ai_captions ?? false, chat_action: tgt.chat_action ?? c.chat_action ?? 'typing',
// discord/slack
username: c.username || '',
// ntfy
@@ -242,6 +255,7 @@
// broadcast
child_target_ids: c.child_target_ids || [],
};
nameManuallyEdited = true;
editing = tgt.id;
showTelegramSettings = false;
showForm = true;
@@ -268,7 +282,7 @@
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined,
ai_captions: form.ai_captions,
};
} else if (formType === 'webhook') {
config = { ai_captions: form.ai_captions };
@@ -284,10 +298,12 @@
config = { child_target_ids: form.child_target_ids };
}
const body: Record<string, any> = { name: form.name, icon: form.icon, config };
if (formType === 'telegram') body.chat_action = form.chat_action || null;
if (editing) {
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, icon: form.icon, config }) });
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, ...body }) });
}
showForm = false;
editing = null;
@@ -437,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}
@@ -474,6 +490,7 @@
bind:showTelegramSettings
onsave={save}
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
onnameinput={() => nameManuallyEdited = true}
/>
{/if}
@@ -49,6 +49,7 @@
showTelegramSettings: boolean;
onsave: (e: SubmitEvent) => void;
ontoggleTelegramSettings: () => void;
onnameinput?: () => void;
}
let {
@@ -70,6 +71,7 @@
showTelegramSettings = $bindable(),
onsave,
ontoggleTelegramSettings,
onnameinput,
}: Props = $props();
</script>
@@ -87,7 +89,7 @@
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<input id="tgt-name" bind:value={form.name} oninput={() => onnameinput?.()} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if formType === 'telegram'}
@@ -28,6 +28,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import { getDescriptor } from '$lib/providers';
import type { TemplateConfig } from '$lib/types';
let allTemplateConfigs = $derived(templateConfigsCache.items);
@@ -194,8 +195,16 @@
date_only_format: '%d.%m.%Y',
});
let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
let previewTargetType = $state('telegram');
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const desc = getDescriptor(form.provider_type);
form.name = desc ? `${desc.defaultName} Templates` : 'Templates';
}
});
// Provider capabilities: from shared cache
let allCapabilities = $derived(capabilitiesCache.items);
let providerTypes = $derived(Object.keys(allCapabilities));
@@ -252,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);
}
/**
@@ -291,6 +318,7 @@
function openNew() {
form = defaultForm();
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
nameManuallyEdited = false;
editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
refreshDateFormatPreview();
}
@@ -304,6 +332,7 @@
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
nameManuallyEdited = true;
editing = c.id; showForm = true; activeLocale = primaryLocale;
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
@@ -418,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}
@@ -439,7 +468,7 @@
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.namePlaceholder')}
<input id="tpc-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('templateConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
@@ -190,6 +190,14 @@
});
let form: Record<string, any> = $state(defaultForm());
let descriptor = $derived(getDescriptor(form.provider_type));
let nameManuallyEdited = $state(false);
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const desc = getDescriptor(form.provider_type);
form.name = desc ? `${desc.defaultName} Tracking` : 'Tracking';
}
});
onMount(() => {
topbarAction.set({
@@ -230,9 +238,10 @@
window.history.replaceState(null, '', cleanUrl);
}
function openNew() { form = defaultForm(); editing = null; showForm = true; }
function openNew() { form = defaultForm(); nameManuallyEdited = false; editing = null; showForm = true; }
function edit(c: any) {
form = { ...defaultForm(), ...c };
nameManuallyEdited = true;
editing = c.id; showForm = true;
}
@@ -267,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}
@@ -288,7 +297,7 @@
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tc-name" bind:value={form.name} required placeholder={t('trackingConfig.namePlaceholder')}
<input id="tc-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('trackingConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
+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.6.3"
version = "0.7.1"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -4,21 +4,24 @@ from __future__ import annotations
import asyncio
import logging
from typing import Any
from typing import Any, Final
import aiohttp
from ..http_base import HttpProviderClient
_LOGGER = logging.getLogger(__name__)
# Discord webhook content limit
MAX_CONTENT_LENGTH = 2000
# Discord API constraints (per webhook docs).
MAX_CONTENT_LENGTH: Final = 2000
MAX_USERNAME_LENGTH: Final = 80
class DiscordClient:
class DiscordClient(HttpProviderClient):
"""Sends messages via Discord webhook URLs."""
def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session
super().__init__(session, provider_name="discord")
async def send(
self,
@@ -33,6 +36,8 @@ class DiscordClient:
"""
if not webhook_url:
return {"success": False, "error": "Missing webhook_url"}
if username and len(username) > MAX_USERNAME_LENGTH:
return {"success": False, "error": f"username exceeds {MAX_USERNAME_LENGTH} chars"}
chunks = _split_message(message, MAX_CONTENT_LENGTH)
for chunk in chunks:
@@ -42,71 +47,34 @@ class DiscordClient:
if avatar_url:
payload["avatar_url"] = avatar_url
result = await self._post(webhook_url, payload)
if not result["success"]:
result = await self.request("POST", webhook_url, json=payload)
if not result.get("success"):
return result
# Small delay between chunks to respect rate limits
if len(chunks) > 1:
await asyncio.sleep(0.5)
return {"success": True}
_MAX_RETRIES = 3
_MAX_RETRY_AFTER = 60.0
async def _post(self, url: str, payload: dict) -> dict[str, Any]:
"""POST with bounded 429 retry.
We cap retries at _MAX_RETRIES and the ``Retry-After`` header at
_MAX_RETRY_AFTER seconds so a hostile or misbehaving upstream cannot
pin the dispatch task indefinitely.
"""
for attempt in range(self._MAX_RETRIES + 1):
try:
async with self._session.post(
url,
json=payload,
headers={"Content-Type": "application/json"},
allow_redirects=False,
) as resp:
if resp.status == 429 and attempt < self._MAX_RETRIES:
try:
retry_after = float(resp.headers.get("Retry-After", "2"))
except (TypeError, ValueError):
retry_after = 2.0
retry_after = max(0.0, min(retry_after, self._MAX_RETRY_AFTER))
_LOGGER.warning(
"Discord rate limited, retrying after %.1fs (attempt %d/%d)",
retry_after, attempt + 1, self._MAX_RETRIES,
)
await asyncio.sleep(retry_after)
continue
if 200 <= resp.status < 300:
return {"success": True}
body = await resp.text()
return {
"success": False,
"error": f"HTTP {resp.status}: {body[:200]}",
}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
return {"success": False, "error": "Rate limited (retries exhausted)"}
def _split_message(text: str, limit: int) -> list[str]:
"""Split message into chunks respecting the character limit."""
"""Split message into chunks respecting the character limit.
Drops chunks that contain only whitespace Discord rejects those.
"""
if len(text) <= limit:
return [text]
chunks = []
chunks: list[str] = []
while text:
if len(text) <= limit:
chunks.append(text)
break
# Try to split at newline
split_at = text.rfind("\n", 0, limit)
if split_at <= 0:
split_at = limit
chunks.append(text[:split_at])
text = text[split_at:].lstrip("\n")
return chunks
piece = text
text = ""
else:
split_at = text.rfind("\n", 0, limit)
if split_at <= 0:
split_at = limit
piece = text[:split_at]
text = text[split_at:].lstrip("\n")
if piece.strip():
chunks.append(piece)
return chunks or [text]
@@ -7,7 +7,7 @@ import contextlib
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncIterator
from typing import Any, AsyncIterator, Awaitable, Callable, Final
import aiohttp
@@ -15,37 +15,20 @@ from notify_bridge_core.log_context import bind_log_context, dispatch_id_var
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.templates.context import build_template_context
from notify_bridge_core.templates.renderer import render_template
from .ssrf import UnsafeURLError, avalidate_outbound_url
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
# Cap on how many asset downloads run concurrently inside
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
# max_asset_size``, which matters on small-RAM Docker hosts when a batch
# contains many large videos.
_PRELOAD_CONCURRENCY = 6
def _new_session() -> aiohttp.ClientSession:
"""Per-dispatch aiohttp session with a sane default timeout.
We still open a short-lived session per dispatch (connection reuse across
dispatches lives in the server-side shared session), but we always attach
a total timeout so a hung peer cannot wedge the task forever.
"""
return aiohttp.ClientSession(timeout=_HTTP_TIMEOUT)
from .http_base import safe_headers
from .receiver import (
DiscordReceiver,
EmailReceiver,
MatrixReceiver,
NtfyReceiver,
Receiver,
SlackReceiver,
TelegramReceiver,
WebhookReceiver,
EmailReceiver,
DiscordReceiver,
SlackReceiver,
NtfyReceiver,
MatrixReceiver,
)
from .redact import redact_exc
from .ssrf import UnsafeURLError, avalidate_outbound_url
from .telegram.cache import TelegramFileCache
from .telegram.client import TelegramClient
from .telegram.media import (
@@ -58,7 +41,33 @@ from .webhook.client import WebhookClient
_LOGGER = logging.getLogger(__name__)
DEFAULT_TEMPLATE = '{{ event_type }}: "{{ collection_name }}"'
DEFAULT_TEMPLATE: Final = '{{ event_type }}: "{{ collection_name }}"'
_HTTP_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
# Cap on how many asset downloads run concurrently inside
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
# max_asset_size``.
_PRELOAD_CONCURRENCY: Final = 6
# Cap on how many targets the dispatcher fans out to at once. With dozens
# of targets and a single hung peer, unbounded ``gather`` can pin the
# dispatch task. The cap also protects against credential-reuse rate
# limits on shared providers.
_DISPATCH_CONCURRENCY: Final = 16
# Cap on parallel per-receiver sends within a single target.
_RECEIVER_CONCURRENCY: Final = 8
# Per-target soft timeout — at the top of the dispatch tree so a single
# misbehaving target can't hold the whole batch open. Individual provider
# clients carry their own per-request timeouts on top of this.
_TARGET_TIMEOUT_S: Final = 120.0
def _new_session() -> aiohttp.ClientSession:
return aiohttp.ClientSession(timeout=_HTTP_TIMEOUT)
@dataclass
@@ -66,17 +75,23 @@ class TargetConfig:
"""Configuration for a notification target."""
type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix"
config: dict[str, Any] # target-level config (bot_token, settings, etc.)
template_slots: dict[str, dict[str, str]] | None = None # event_type -> {locale -> template}
locale: str = "en" # default locale for template resolution
config: dict[str, Any]
template_slots: dict[str, dict[str, str]] | None = None
locale: str = "en"
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
provider_api_key: str | None = None # API key for downloading assets from provider
provider_internal_url: str | None = None # Internal provider URL for API key scoping
provider_external_url: str | None = None # External domain for API key scoping
provider_api_key: str | None = None
provider_internal_url: str | None = None
provider_external_url: str | None = None
receivers: list[Receiver] = field(default_factory=list)
_SendMethod = Callable[
["NotificationDispatcher", TargetConfig, str, ServiceEvent],
Awaitable[dict[str, Any]],
]
class NotificationDispatcher:
"""Dispatches ServiceEvent notifications to configured targets."""
@@ -90,18 +105,11 @@ class NotificationDispatcher:
self._url_cache = url_cache
self._asset_cache = asset_cache
# Optional shared session owned by the caller; when supplied we reuse
# its connection pool instead of opening a fresh per-dispatch session
# (saves a TLS handshake per outbound call).
# its connection pool instead of opening a fresh per-dispatch session.
self._shared_session = session
@contextlib.asynccontextmanager
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
"""Yield an aiohttp session, reusing the shared one if provided.
When a shared session was passed in ``__init__`` we yield it without
closing (the caller owns its lifetime). Otherwise we open a
short-lived session with our default timeout and close it on exit.
"""
if self._shared_session is not None and not self._shared_session.closed:
yield self._shared_session
return
@@ -115,11 +123,9 @@ class NotificationDispatcher:
) -> list[dict[str, Any]]:
"""Send event notification to all targets.
Returns list of results (one per target).
Returns one result per target. Per-target failures are isolated;
a single bad target cannot poison the batch.
"""
# Bind a dispatch_id so every log line emitted by the target sends
# (including deep in TelegramClient) can be correlated to the same
# upstream event.
new_id = dispatch_id_var.get() or f"disp:{uuid.uuid4().hex[:12]}"
with bind_log_context(dispatch_id=new_id):
@@ -128,20 +134,36 @@ class NotificationDispatcher:
event.event_type.value if hasattr(event.event_type, "value") else event.event_type,
getattr(event, "collection_name", None), len(targets),
)
sem = asyncio.Semaphore(_DISPATCH_CONCURRENCY)
async def run_one(t: TargetConfig) -> dict[str, Any]:
async with sem:
try:
return await asyncio.wait_for(
self._send_to_target(event, t),
timeout=_TARGET_TIMEOUT_S,
)
except asyncio.TimeoutError:
return {
"success": False,
"error": f"Target dispatch timed out after {_TARGET_TIMEOUT_S}s",
}
raw_results = await asyncio.gather(
*[self._send_to_target(event, t) for t in targets],
*[run_one(t) for t in targets],
return_exceptions=True,
)
results = []
results: list[dict[str, Any]] = []
failures = 0
for target, raw in zip(targets, raw_results):
if isinstance(raw, Exception):
failures += 1
_LOGGER.error(
"Dispatch to target type=%s failed: %s",
target.type, raw, exc_info=raw,
target.type, redact_exc(raw),
)
results.append({"success": False, "error": str(raw)})
results.append({"success": False, "error": redact_exc(raw)})
else:
if isinstance(raw, dict) and not raw.get("success"):
failures += 1
@@ -155,7 +177,6 @@ class NotificationDispatcher:
def _resolve_template(
self, event: ServiceEvent, target: TargetConfig, locale: str,
) -> str:
"""Resolve template string for an event, with locale fallback."""
template_str = DEFAULT_TEMPLATE
if target.template_slots:
locale_map = target.template_slots.get(event.event_type.value)
@@ -166,7 +187,6 @@ class NotificationDispatcher:
def _render_message(
self, event: ServiceEvent, target: TargetConfig, locale: str,
) -> str:
"""Resolve template and render message for a given locale."""
template_str = self._resolve_template(event, target, locale)
ctx = build_template_context(
event, target_type=target.type,
@@ -179,7 +199,6 @@ class NotificationDispatcher:
self, receiver: Receiver, default_message: str,
event: ServiceEvent, target: TargetConfig,
) -> str:
"""Return per-receiver message, re-rendering if receiver has a different locale."""
if receiver.locale and receiver.locale != target.locale:
return self._render_message(event, target, receiver.locale)
return default_message
@@ -187,21 +206,16 @@ class NotificationDispatcher:
async def _send_to_target(
self, event: ServiceEvent, target: TargetConfig
) -> dict[str, Any]:
"""Send event to a single target (potentially multiple receivers)."""
"""Dispatch to a single target via the registered handler."""
default_message = self._render_message(event, target, target.locale)
send_method = _PROVIDER_HANDLERS.get(target.type)
if send_method is None:
return {"success": False, "error": f"Unknown target type: {target.type}"}
return await send_method(self, target, default_message, event)
send_method = {
"telegram": self._send_telegram,
"webhook": self._send_webhook,
"email": self._send_email,
"discord": self._send_discord,
"slack": self._send_slack,
"ntfy": self._send_ntfy,
"matrix": self._send_matrix,
}.get(target.type)
if send_method:
return await send_method(target, default_message, event)
return {"success": False, "error": f"Unknown target type: {target.type}"}
# ------------------------------------------------------------------
# Asset preload (Telegram-specific)
# ------------------------------------------------------------------
async def _preload_asset_data(
self,
@@ -210,36 +224,13 @@ class NotificationDispatcher:
session: aiohttp.ClientSession,
max_size: int | None,
) -> None:
"""Download each non-cached asset's bytes once and attach to the entry.
Three benefits:
* ``TelegramClient`` sees ``entry["data"]`` and skips its own download,
so we don't fetch each URL twice.
* We know the exact upload size, which lets the oversize warning in
the rendered text compare against real bytes (for Immich videos,
the transcoded ``/video/playback``), not the original ``file_size``.
* Assets already in the Telegram file_id cache are skipped, and their
stored size (if any) is used to populate ``playback_size`` so
templates see consistent sizes for repeat sends without re-download.
Entries whose download fails or exceeds ``max_size`` are left without
``data``; ``TelegramClient`` will then fall back to its own download
path and apply the same checks no regression, just no preload win.
Concurrency is bounded by ``_PRELOAD_CONCURRENCY`` so peak memory
stays predictable: at most N assets worth of bytes held in RAM at
once, regardless of ``max_media_to_send``. Total wall-clock is
unchanged for small batches and only marginally slower for large
ones (most assets fit in a single RTT and SSL negotiation cost
dominates, so 6-way parallelism is sufficient).
"""
"""Download each non-cached asset's bytes once, with SSRF guard."""
if not assets:
return
sem = asyncio.Semaphore(_PRELOAD_CONCURRENCY)
async def _fetch(entry: dict[str, Any], media: Any) -> None:
# Cache hit → skip download; populate playback_size from stored size.
async def fetch(entry: dict[str, Any], media: Any) -> None:
cache, key = self._cache_for_entry(entry)
if cache and key:
cached = cache.get(key)
@@ -251,28 +242,40 @@ class NotificationDispatcher:
url = entry["url"]
headers = entry.get("headers") or {}
try:
# Defense-in-depth: validate even though TelegramClient
# also validates. The dispatcher is what triggers the
# download, so the guard belongs here too.
await avalidate_outbound_url(url)
except UnsafeURLError as err:
_LOGGER.warning(
"Asset preload skipped: unsafe URL (%s)", redact_exc(err),
)
return
async with sem:
try:
async with session.get(url, headers=headers) as resp:
if resp.status != 200:
return
data = await resp.read()
except aiohttp.ClientError:
except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
return
if max_size is not None and len(data) > max_size:
return
entry["data"] = data
media.extra["playback_size"] = len(data)
await asyncio.gather(*(_fetch(e, m) for e, m in zip(assets, media_assets)))
raw = await asyncio.gather(
*(fetch(e, m) for e, m in zip(assets, media_assets)),
return_exceptions=True,
)
for r in raw:
if isinstance(r, Exception):
_LOGGER.warning("Asset preload raised: %s", redact_exc(r))
def _cache_for_entry(
self, entry: dict[str, Any],
) -> tuple[TelegramFileCache | None, str | None]:
"""Resolve (cache, key) for an asset entry — mirrors TelegramClient logic.
Returns (None, None) if no cache is configured or no key can be derived.
"""
cache_key = entry.get("cache_key")
if cache_key:
cache = self._asset_cache if is_asset_cache_key(cache_key) else self._url_cache
@@ -287,6 +290,10 @@ class NotificationDispatcher:
return self._url_cache, url
return None, None
# ------------------------------------------------------------------
# Per-provider handlers
# ------------------------------------------------------------------
async def _send_telegram(
self, target: TargetConfig, default_message: str, event: ServiceEvent
) -> dict[str, Any]:
@@ -296,27 +303,25 @@ class NotificationDispatcher:
max_media = target.config.get("max_media_to_send", 50)
max_group = target.config.get("max_media_per_group", 10)
chunk_delay = target.config.get("media_delay", 500)
max_size = target.config.get("max_asset_size")
if max_size:
max_size = max_size * 1024 * 1024 # MB to bytes
max_size_mb = target.config.get("max_asset_size")
max_size_bytes = max_size_mb * 1024 * 1024 if max_size_mb else None
send_large_as_docs = target.config.get("send_large_photos_as_documents", False)
if not bot_token:
return {"success": False, "error": "Missing bot_token"}
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
# Prepare assets list once (shared across receivers)
# Prefer internal URL for fetching (LAN speed vs public internet)
internal_url = (target.provider_internal_url or "").rstrip("/")
external_url = (target.provider_external_url or "").rstrip("/")
assets = []
media_assets: list[Any] = [] # aligned with `assets` for preload
assets: list[dict[str, Any]] = []
media_assets: list[Any] = []
for asset in event.added_assets[:max_media]:
url = asset.preview_url or asset.thumbnail_url or asset.full_url
if not url:
continue
asset_entry = build_telegram_asset_entry(
url=url or "",
url=url,
media_type=asset.type.value,
api_key=target.provider_api_key,
internal_url=internal_url,
@@ -327,26 +332,15 @@ class NotificationDispatcher:
assets.append(asset_entry)
media_assets.append(asset)
results: list[dict[str, Any]] = []
async with self._session_ctx() as session:
# Preload all asset bytes once so (a) TelegramClient can skip its
# own download and (b) we know exact upload sizes in time for the
# oversize warning in the rendered text.
await self._preload_asset_data(assets, media_assets, session, max_size)
default_message = self._render_message(event, target, target.locale)
await self._preload_asset_data(assets, media_assets, session, max_size_bytes)
# Asset cache (when in thumbhash mode) invalidates entries when the
# asset's visual content changes. The resolver maps asset id → its
# current thumbhash. Providers that expose thumbhash put it in
# ``asset.extra["thumbhash"]`` (currently Immich).
thumbhash_map = {
asset.id: asset.extra.get("thumbhash")
for asset in event.added_assets
if asset.extra.get("thumbhash")
}
thumbhash_resolver = (
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
)
thumbhash_resolver = thumbhash_map.get if thumbhash_map else None
client = TelegramClient(
session, bot_token,
@@ -355,39 +349,51 @@ class NotificationDispatcher:
thumbhash_resolver=thumbhash_resolver,
)
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id:
results.append({"success": False, "error": "Invalid telegram receiver"})
continue
return {"success": False, "error": "Invalid telegram receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
text_result = await client.send_message(
chat_id=receiver.chat_id,
text=message,
disable_web_page_preview=bool(disable_preview),
)
if not text_result.get("success"):
_LOGGER.warning("Failed to send to chat %s: %s", receiver.chat_id, text_result.get("error"))
results.append(text_result)
continue
_LOGGER.warning(
"Failed to send to chat %s: %s",
receiver.chat_id, text_result.get("error"),
)
return text_result
if assets:
reply_to = text_result.get("message_id")
media_result = await client.send_notification(
chat_id=receiver.chat_id,
assets=assets,
reply_to_message_id=reply_to,
reply_to_message_id=text_result.get("message_id"),
max_group_size=max_group,
chunk_delay=chunk_delay,
max_asset_data_size=max_size,
max_asset_data_size=max_size_bytes,
send_large_photos_as_documents=send_large_as_docs,
chat_action=chat_action or None,
)
if not media_result.get("success"):
_LOGGER.warning("Text sent OK but media failed for chat %s: %s", receiver.chat_id, media_result.get("error"))
_LOGGER.warning(
"Text sent OK but media failed for chat %s: %s",
receiver.chat_id, media_result.get("error"),
)
# Preserve both outcomes — text succeeded, media
# didn't. Operators losing media-failure detail
# in the result dict made root-cause analysis
# impossible.
return {
"success": True,
"message_id": text_result.get("message_id"),
"media_error": media_result.get("error"),
"media_failed_at_chunk": media_result.get("failed_at_chunk"),
}
return text_result
results.append(text_result)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results)
@@ -397,17 +403,10 @@ class NotificationDispatcher:
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
results: list[dict[str, Any]] = []
async with self._session_ctx() as session:
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
results.append({"success": False, "error": "Invalid webhook receiver"})
continue
try:
await avalidate_outbound_url(receiver.url)
except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue
return {"success": False, "error": "Invalid webhook receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
payload = {
"message": message,
@@ -417,8 +416,10 @@ class NotificationDispatcher:
"collection_id": event.collection_id,
"timestamp": event.timestamp.isoformat(),
}
client = WebhookClient(session, receiver.url, receiver.headers)
results.append(await client.send(payload))
client = WebhookClient(session, receiver.url, safe_headers(receiver.headers))
return await client.send(payload)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results)
@@ -431,7 +432,7 @@ class NotificationDispatcher:
if not smtp_cfg.get("host"):
return {"success": False, "error": "SMTP not configured"}
client = EmailClient(SmtpConfig(
email_client = EmailClient(SmtpConfig(
host=smtp_cfg["host"],
port=int(smtp_cfg.get("port", 587)),
username=smtp_cfg.get("username", ""),
@@ -439,27 +440,28 @@ class NotificationDispatcher:
from_address=smtp_cfg.get("from_address", ""),
from_name=smtp_cfg.get("from_name", "Notify Bridge"),
use_tls=smtp_cfg.get("use_tls", True),
tls_mode=smtp_cfg.get("tls_mode", "auto"),
))
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}"
results: list[dict[str, Any]] = []
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, EmailReceiver) or not receiver.email:
results.append({"success": False, "error": "Invalid email receiver"})
continue
return {"success": False, "error": "Invalid email receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
result = await client.send(
# body_html=None lets EmailClient build a safely-escaped HTML
# alternative from body_text instead of trusting user content.
return await email_client.send(
to_email=receiver.email,
subject=subject,
body_text=message,
body_html=message,
body_html=None,
to_name=receiver.name,
)
results.append(result)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results)
async def _send_discord(
@@ -471,20 +473,16 @@ class NotificationDispatcher:
return {"success": False, "error": "No receivers configured"}
username = target.config.get("username")
results: list[dict[str, Any]] = []
async with self._session_ctx() as session:
client = DiscordClient(session)
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
results.append({"success": False, "error": "Invalid discord receiver"})
continue
try:
await avalidate_outbound_url(receiver.webhook_url)
except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue
return {"success": False, "error": "Invalid discord receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send(receiver.webhook_url, message, username=username))
return await client.send(receiver.webhook_url, message, username=username)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results)
@@ -497,20 +495,16 @@ class NotificationDispatcher:
return {"success": False, "error": "No receivers configured"}
username = target.config.get("username")
results: list[dict[str, Any]] = []
async with self._session_ctx() as session:
client = SlackClient(session)
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
results.append({"success": False, "error": "Invalid slack receiver"})
continue
try:
await avalidate_outbound_url(receiver.webhook_url)
except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue
return {"success": False, "error": "Invalid slack receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send(receiver.webhook_url, message, username=username))
return await client.send(receiver.webhook_url, message, username=username)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results)
@@ -526,22 +520,23 @@ class NotificationDispatcher:
try:
await avalidate_outbound_url(server_url)
except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe ntfy server_url: {err}"}
return {"success": False, "error": f"Unsafe ntfy server_url: {redact_exc(err)}"}
title = f"{event.event_type.value}: {event.collection_name}"
results: list[dict[str, Any]] = []
async with self._session_ctx() as session:
client = NtfyClient(session)
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
results.append({"success": False, "error": "Invalid ntfy receiver"})
continue
return {"success": False, "error": "Invalid ntfy receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send(
return await client.send(
server_url, receiver.topic, message,
title=title, priority=receiver.priority, auth_token=auth_token,
))
)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results)
@@ -557,33 +552,108 @@ class NotificationDispatcher:
try:
await avalidate_outbound_url(homeserver)
except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe matrix homeserver_url: {err}"}
return {"success": False, "error": f"Unsafe matrix homeserver_url: {redact_exc(err)}"}
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
results: list[dict[str, Any]] = []
async with self._session_ctx() as session:
client = MatrixClient(session, homeserver, access_token)
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
results.append({"success": False, "error": "Invalid matrix receiver"})
continue
return {"success": False, "error": "Invalid matrix receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send_message(
receiver.room_id, message, html_message=message,
))
# body_html is the same plain text — Matrix accepts the
# raw message as both ``body`` and ``formatted_body``.
# If templates emit HTML in the future, generate a
# separate HTML body upstream rather than aliasing here.
return await client.send_message(
receiver.room_id, message, html_message=None,
)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results)
# ------------------------------------------------------------------
# Aggregation
# ------------------------------------------------------------------
@staticmethod
async def _fan_out(
receivers: list[Receiver],
send_one: Callable[[Receiver], Awaitable[dict[str, Any]]],
) -> list[dict[str, Any]]:
"""Run ``send_one`` per receiver with bounded concurrency.
Per-receiver exceptions are converted to failure dicts so a single
bad receiver can't cancel its peers.
"""
sem = asyncio.Semaphore(_RECEIVER_CONCURRENCY)
async def guarded(receiver: Receiver) -> dict[str, Any]:
async with sem:
try:
return await send_one(receiver)
except Exception as exc: # noqa: BLE001
_LOGGER.error("Receiver send raised: %s", redact_exc(exc))
return {"success": False, "error": redact_exc(exc)}
return await asyncio.gather(*(guarded(r) for r in receivers))
@staticmethod
def _aggregate_results(results: list[dict[str, Any]]) -> dict[str, Any]:
"""Aggregate broadcast results into a single result dict."""
"""Aggregate per-receiver results into a single target-level result.
Preserves the per-receiver detail under ``receivers`` so a caller
can see exactly which receivers failed, instead of getting only
the first error.
"""
if not results:
return {"success": False, "error": "No receivers configured"}
successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and results:
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No receivers configured"}
failures = len(results) - successes
out: dict[str, Any] = {
"success": successes > 0,
"receivers": len(results),
"successes": successes,
"failures": failures,
"results": results,
}
if failures:
out["errors"] = [
r.get("error") for r in results if not r.get("success")
]
if successes == 0:
# Surface the first error at the top level for back-compat
# with callers that only check ``error``.
out["error"] = results[0].get("error", "All receivers failed")
return out
# ----------------------------------------------------------------------
# Provider registry — replaces the if/elif chain so adding a provider
# means just registering it here, not editing dispatch logic.
# ----------------------------------------------------------------------
_PROVIDER_HANDLERS: dict[str, _SendMethod] = {
"telegram": NotificationDispatcher._send_telegram,
"webhook": NotificationDispatcher._send_webhook,
"email": NotificationDispatcher._send_email,
"discord": NotificationDispatcher._send_discord,
"slack": NotificationDispatcher._send_slack,
"ntfy": NotificationDispatcher._send_ntfy,
"matrix": NotificationDispatcher._send_matrix,
}
def register_provider(name: str, handler: _SendMethod) -> None:
"""Register a new dispatcher provider at runtime.
Allows out-of-tree providers to extend the dispatcher without
forking. The handler must follow the
``async (dispatcher, target, default_message, event) -> dict`` shape.
"""
_PROVIDER_HANDLERS[name] = handler
@@ -2,14 +2,32 @@
from __future__ import annotations
import html
import logging
import re
import ssl
from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any
from email.headerregistry import Address
from email.message import EmailMessage
from typing import Any, Final, Literal
try: # Optional dependency — fail at first send rather than at import.
import aiosmtplib
from aiosmtplib import SMTPException
except ImportError: # pragma: no cover
aiosmtplib = None # type: ignore[assignment]
class SMTPException(Exception): # type: ignore[no-redef]
pass
_LOGGER = logging.getLogger(__name__)
_DEFAULT_TIMEOUT_S: Final = 30.0
_TlsMode = Literal["auto", "implicit", "starttls", "none"]
# RFC 5322 lite: catches the obvious-bad addresses ("foo bar", "no-at",
# embedded CRLF) without pretending to fully validate addresses.
_EMAIL_RE: Final = re.compile(r"^[^\s@\r\n,;<>]+@[^\s@\r\n,;<>]+\.[^\s@\r\n,;<>]+$")
@dataclass
class SmtpConfig:
@@ -22,6 +40,55 @@ class SmtpConfig:
from_address: str = ""
from_name: str = "Notify Bridge"
use_tls: bool = True
# Explicit TLS mode. ``auto`` (back-compat) infers from ``use_tls`` and
# ``port``: 465 → implicit; 587 with use_tls=False → starttls; 25 → none.
tls_mode: _TlsMode = "auto"
timeout_s: float = _DEFAULT_TIMEOUT_S
def _strip_header(value: str) -> str:
"""Reject CRLF and bare CR/LF in header-bound strings.
SMTP header injection turns user-controlled subject/name strings into
arbitrary headers (``\\r\\nBcc: attacker@x``). The Python stdlib
accepts CRLF when followed by SP/HT (header folding), so explicit
sanitization is required even though :class:`EmailMessage` does some
validation of its own.
"""
return re.sub(r"[\r\n]+", " ", str(value or "")).strip()
def _validate_email(addr: str) -> str:
addr = _strip_header(addr)
if not addr:
raise ValueError("email address is empty")
if not _EMAIL_RE.match(addr):
raise ValueError("email address is invalid")
return addr
def _resolve_tls(cfg: SmtpConfig) -> tuple[bool, bool]:
"""Resolve ``(use_tls, start_tls)`` flags from the config.
``tls_mode`` overrides ``use_tls``/port heuristics when provided.
"""
mode = cfg.tls_mode
if mode == "implicit":
return True, False
if mode == "starttls":
return False, True
if mode == "none":
return False, False
# auto — preserve the historical "use_tls bool + port heuristic" behavior
# but make the path explicit.
if cfg.use_tls:
return True, False
return False, cfg.port != 25
def _to_html(text: str) -> str:
"""Convert plain text to a minimal HTML body, escaped for safety."""
return "<html><body><pre>" + html.escape(text) + "</pre></body></html>"
class EmailClient:
@@ -30,30 +97,39 @@ class EmailClient:
def __init__(self, smtp_config: SmtpConfig) -> None:
self._config = smtp_config
@staticmethod
def _ssl_context() -> ssl.SSLContext:
# Explicit context so the TLS posture is auditable; aiosmtplib
# defaults look correct today but past regressions (and downstream
# repackaging) make implicit reliance fragile.
return ssl.create_default_context()
async def verify_connection(self) -> dict[str, Any]:
"""Test SMTP connection and authentication without sending an email."""
try:
import aiosmtplib
except ImportError:
if aiosmtplib is None:
return {"success": False, "error": "aiosmtplib not installed"}
cfg = self._config
if not cfg.host:
return {"success": False, "error": "SMTP host not configured"}
use_tls, start_tls = _resolve_tls(cfg)
try:
smtp = aiosmtplib.SMTP(
hostname=cfg.host,
port=cfg.port,
use_tls=cfg.use_tls,
start_tls=not cfg.use_tls and cfg.port != 25,
use_tls=use_tls,
start_tls=start_tls,
tls_context=self._ssl_context(),
timeout=cfg.timeout_s,
validate_certs=True,
)
await smtp.connect()
if cfg.username and cfg.password:
await smtp.login(cfg.username, cfg.password)
await smtp.quit()
return {"success": True}
except Exception as e:
except (SMTPException, OSError) as e:
_LOGGER.warning("SMTP verification failed for %s:%d: %s", cfg.host, cfg.port, e)
return {"success": False, "error": str(e)}
@@ -65,27 +141,52 @@ class EmailClient:
body_html: str | None = None,
to_name: str = "",
) -> dict[str, Any]:
"""Send an email. Returns {"success": True} or {"success": False, "error": "..."}."""
try:
import aiosmtplib
except ImportError:
"""Send an email.
Returns ``{"success": True}`` or ``{"success": False, "error": "..."}``.
``body_html`` is treated as already-safe markup. Pass ``None`` to
derive a safe HTML alternative from ``body_text`` automatically.
"""
if aiosmtplib is None:
return {"success": False, "error": "aiosmtplib not installed. Run: pip install aiosmtplib"}
cfg = self._config
if not cfg.host or not cfg.from_address:
return {"success": False, "error": "SMTP not configured (missing host or from_address)"}
# Build email message
msg = MIMEMultipart("alternative")
msg["From"] = f"{cfg.from_name} <{cfg.from_address}>" if cfg.from_name else cfg.from_address
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
msg["Subject"] = subject
try:
to_addr = _validate_email(to_email)
from_addr = _validate_email(cfg.from_address)
except ValueError as exc:
return {"success": False, "error": f"Invalid email address: {exc}"}
msg.attach(MIMEText(body_text, "plain", "utf-8"))
if body_html:
msg.attach(MIMEText(body_html, "html", "utf-8"))
# EmailMessage with structured Address objects rejects CRLF and
# framework-folds long headers safely. We still strip first because
# EmailMessage's display-name slot is a pure string.
msg = EmailMessage()
from_display = _strip_header(cfg.from_name) or ""
to_display = _strip_header(to_name) or ""
try:
from_user, _, from_domain = from_addr.partition("@")
to_user, _, to_domain = to_addr.partition("@")
msg["From"] = Address(from_display, from_user, from_domain) if from_display else from_addr
msg["To"] = Address(to_display, to_user, to_domain) if to_display else to_addr
except ValueError as exc:
return {"success": False, "error": f"Invalid email address: {exc}"}
msg["Subject"] = _strip_header(subject)
msg.set_content(body_text or "", subtype="plain", charset="utf-8")
# If the caller provided HTML explicitly, honor it; otherwise build a
# safe escaped version so a stray "<" in the rendered template can't
# break the markup.
msg.add_alternative(
body_html if body_html is not None else _to_html(body_text or ""),
subtype="html",
charset="utf-8",
)
use_tls, start_tls = _resolve_tls(cfg)
try:
await aiosmtplib.send(
msg,
@@ -93,11 +194,14 @@ class EmailClient:
port=cfg.port,
username=cfg.username or None,
password=cfg.password or None,
use_tls=cfg.use_tls,
start_tls=not cfg.use_tls and cfg.port != 25,
use_tls=use_tls,
start_tls=start_tls,
tls_context=self._ssl_context(),
timeout=cfg.timeout_s,
validate_certs=True,
)
_LOGGER.info("Email sent to %s", to_email)
_LOGGER.info("Email sent to %s", to_addr)
return {"success": True}
except Exception as e:
_LOGGER.error("Failed to send email to %s: %s", to_email, e)
except (SMTPException, OSError) as e:
_LOGGER.error("Failed to send email to %s: %s", to_addr, e)
return {"success": False, "error": str(e)}
@@ -0,0 +1,196 @@
"""Shared HTTP infrastructure for notification provider clients.
Slack/Discord/ntfy/Matrix/Webhook all follow the same pattern: build a
JSON payload, POST/PUT it, decode 200-range as success, decode 4xx/5xx
into a stable error dict, and retry transient 429/503 responses with a
capped ``Retry-After``. ``HttpProviderClient`` centralizes that pattern
so every provider gets the same SSRF guard, timeouts, secret-redacted
errors, and bounded retry policy by construction adding a new
provider doesn't get to forget any one of them.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Final, Mapping
import aiohttp
from .redact import redact, redact_exc
from .ssrf import UnsafeURLError, avalidate_outbound_url
_LOGGER = logging.getLogger(__name__)
_DEFAULT_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
_MAX_RETRIES: Final = 3
_MAX_RETRY_AFTER_S: Final = 60.0
_RETRY_STATUSES: Final = frozenset({429, 503})
# Hop-by-hop / framing headers a caller must not be able to override via
# user-supplied target config. Letting them through enables request
# smuggling, host-header bypasses of WAFs, and cache poisoning.
_FORBIDDEN_HEADERS: Final = frozenset({
"host",
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
"te",
"upgrade",
"expect",
"proxy-authorization",
"proxy-connection",
})
def safe_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
"""Return a copy of ``headers`` with hop-by-hop/forbidden names dropped
and CRLF-bearing values rejected.
A target config that lets a user inject ``"X-Foo": "bar\\r\\nHost: evil"``
can perform request smuggling depending on aiohttp's framing. We strip
those values rather than letting them reach the wire.
"""
if not headers:
return {}
safe: dict[str, str] = {}
for raw_name, raw_value in headers.items():
name = str(raw_name).strip()
if not name or name.lower() in _FORBIDDEN_HEADERS:
continue
if any(c in name for c in "\r\n:"):
continue
value = str(raw_value)
if "\r" in value or "\n" in value:
continue
safe[name] = value
return safe
def make_error(message: str, *, status: int | None = None, body: str | None = None) -> dict[str, Any]:
"""Build a stable failure dict shape used by every provider client."""
err: dict[str, Any] = {"success": False, "error": redact(message)}
if status is not None:
err["status_code"] = status
if body:
err["body"] = redact(body)[:200]
return err
def make_success(**extra: Any) -> dict[str, Any]:
"""Build a stable success dict shape used by every provider client."""
out: dict[str, Any] = {"success": True}
out.update(extra)
return out
def _retry_after_seconds(headers: Mapping[str, str], cap_s: float) -> float:
raw = headers.get("Retry-After") or headers.get("retry-after") or "2"
try:
seconds = float(raw)
except (TypeError, ValueError):
seconds = 2.0
return max(0.0, min(seconds, cap_s))
class HttpProviderClient:
"""Base for JSON-over-HTTP notification providers.
Subclasses call :meth:`request` instead of using ``self._session``
directly. ``request`` runs the SSRF guard (skippable for known-safe
upstreams via ``ssrf_validate=False``), enforces a per-request
timeout, retries 429/503 with a capped ``Retry-After``, and turns
transport/HTTP errors into the canonical ``{"success": False, ...}``
shape with secrets redacted.
"""
_max_retries: int = _MAX_RETRIES
# Settable per-instance so tests / hostile-upstream tuning can
# tighten the cap. Reads of this attribute fall through to the
# class default when no instance value has been set.
_MAX_RETRY_AFTER: float = _MAX_RETRY_AFTER_S
def __init__(
self,
session: aiohttp.ClientSession,
*,
timeout: aiohttp.ClientTimeout | None = None,
provider_name: str = "http",
) -> None:
self._session = session
self._timeout = timeout or _DEFAULT_TIMEOUT
self._provider = provider_name
async def request(
self,
method: str,
url: str,
*,
json: Any = None,
headers: Mapping[str, str] | None = None,
ssrf_validate: bool = True,
retry_statuses: frozenset[int] = _RETRY_STATUSES,
) -> dict[str, Any]:
"""Send a single request with retry + redaction. Always returns a dict.
On 2xx returns ``{"success": True, "status_code": int, "json": ...
OR "body": str}``. On non-2xx returns the canonical error dict.
"""
if ssrf_validate:
try:
await avalidate_outbound_url(url)
except UnsafeURLError as err:
return make_error(f"Unsafe URL: {redact_exc(err)}")
outbound_headers: dict[str, str] = {"Content-Type": "application/json"}
outbound_headers.update(safe_headers(headers))
for attempt in range(1, self._max_retries + 1):
try:
async with self._session.request(
method,
url,
json=json,
headers=outbound_headers,
timeout=self._timeout,
allow_redirects=False,
) as resp:
if resp.status in retry_statuses and attempt < self._max_retries:
delay = _retry_after_seconds(resp.headers, self._MAX_RETRY_AFTER)
_LOGGER.warning(
"%s %s %s: HTTP %d, retrying after %.2fs (attempt %d/%d)",
self._provider, method, redact(url), resp.status,
delay, attempt, self._max_retries,
)
await resp.read() # drain body so connection can return to pool
await asyncio.sleep(delay)
continue
if 200 <= resp.status < 300:
try:
payload: Any = await resp.json(content_type=None)
except (aiohttp.ContentTypeError, ValueError):
payload = await resp.text()
return make_success(status_code=resp.status, json=payload)
body = await resp.text()
return make_error(
f"HTTP {resp.status}",
status=resp.status,
body=body,
)
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
# asyncio.CancelledError inherits from BaseException on
# 3.8+, so it is not caught here — good: cancellation
# must propagate.
if attempt < self._max_retries and isinstance(err, asyncio.TimeoutError):
_LOGGER.warning(
"%s %s %s: timeout, retrying (attempt %d/%d)",
self._provider, method, redact(url),
attempt, self._max_retries,
)
await asyncio.sleep(min(2 ** (attempt - 1), 5))
continue
return make_error(redact_exc(err))
# Retry budget exhausted on a retriable status.
return make_error("Rate limited (retries exhausted)")
@@ -2,22 +2,36 @@
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
import re
import uuid
from typing import Any, Final
from urllib.parse import quote
import aiohttp
from ..http_base import _MAX_RETRY_AFTER_S, safe_headers
from ..redact import redact, redact_exc
_LOGGER = logging.getLogger(__name__)
# Monotonically increasing transaction counter for idempotent sends
_txn_counter = int(time.time() * 1000)
# Matrix room IDs are ``!opaque:server.name`` per the spec. We also allow
# the ``#alias:server`` form because some callers may pass aliases. The
# pattern's purpose is to reject obvious path-injection (``/``, ``..``,
# control chars, query/fragment chars) before the value reaches a URL.
_ROOM_ID_RE: Final = re.compile(r"^[!#][^\x00-\x1f\s/?#]{1,255}:[A-Za-z0-9.\-:]{1,255}$")
_DEFAULT_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
_MAX_RETRIES: Final = 3
def _next_txn_id() -> str:
global _txn_counter
_txn_counter += 1
return str(_txn_counter)
def _validate_room_id(room_id: str) -> str:
if not room_id:
raise ValueError("room_id is empty")
if not _ROOM_ID_RE.match(room_id):
raise ValueError("room_id format is invalid")
return room_id
class MatrixClient:
@@ -33,49 +47,67 @@ class MatrixClient:
self._homeserver = homeserver_url.rstrip("/")
self._token = access_token
@staticmethod
def _txn_id() -> str:
# uuid4 hex is collision-resistant across processes/restarts;
# eliminates the previous module-level counter race.
return uuid.uuid4().hex
async def send_message(
self,
room_id: str,
message: str,
html_message: str | None = None,
) -> dict[str, Any]:
"""Send a text message to a Matrix room.
"""Send a text message to a Matrix room."""
try:
room_id = _validate_room_id(room_id)
except ValueError as exc:
return {"success": False, "error": f"Invalid room_id: {exc}"}
Args:
room_id: Internal room ID (e.g. !abc:matrix.org)
message: Plain text body
html_message: Optional HTML-formatted body
"""
if not room_id:
return {"success": False, "error": "Missing room_id"}
encoded_room = quote(room_id, safe="")
url = (
f"{self._homeserver}/_matrix/client/v3/rooms/{encoded_room}"
f"/send/m.room.message/{self._txn_id()}"
)
txn_id = _next_txn_id()
# URL-encode the room_id (! and : need encoding)
encoded_room = room_id.replace("!", "%21").replace(":", "%3A")
url = f"{self._homeserver}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}"
body: dict[str, Any] = {
"msgtype": "m.text",
"body": message,
}
body: dict[str, Any] = {"msgtype": "m.text", "body": message}
if html_message:
body["format"] = "org.matrix.custom.html"
body["formatted_body"] = html_message
headers = {
headers = safe_headers({
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
})
try:
async with self._session.put(
url, json=body, headers=headers, allow_redirects=False,
) as resp:
if 200 <= resp.status < 300:
return {"success": True}
resp_body = await resp.text()
if resp.status == 429:
_LOGGER.warning("Matrix rate limited: %s", resp_body[:200])
return {"success": False, "error": f"HTTP {resp.status}: {resp_body[:200]}"}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
for attempt in range(1, _MAX_RETRIES + 1):
try:
async with self._session.put(
url, json=body, headers=headers,
timeout=_DEFAULT_TIMEOUT, allow_redirects=False,
) as resp:
if 200 <= resp.status < 300:
return {"success": True}
resp_body = await resp.text()
if resp.status == 429 and attempt < _MAX_RETRIES:
try:
wait_s = float(resp.headers.get("Retry-After", "2"))
except (TypeError, ValueError):
wait_s = 2.0
wait_s = max(0.0, min(wait_s, _MAX_RETRY_AFTER_S))
_LOGGER.warning(
"Matrix rate limited, retrying after %.2fs (attempt %d/%d)",
wait_s, attempt, _MAX_RETRIES,
)
await asyncio.sleep(wait_s)
continue
return {
"success": False,
"error": f"HTTP {resp.status}: {redact(resp_body)[:200]}",
"status_code": resp.status,
}
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
return {"success": False, "error": redact_exc(e)}
return {"success": False, "error": "Rate limited (retries exhausted)"}
@@ -3,18 +3,33 @@
from __future__ import annotations
import logging
from typing import Any
from typing import Any, Final
import aiohttp
from ..http_base import HttpProviderClient
_LOGGER = logging.getLogger(__name__)
_PRIORITY_MIN: Final = 1
_PRIORITY_MAX: Final = 5
_DEFAULT_PRIORITY: Final = 3
_MAX_TAGS: Final = 10
_MAX_TAG_LEN: Final = 64
class NtfyClient:
def _strip_crlf(value: str) -> str:
"""Remove CR/LF — ntfy's JSON path is safe today, but the same fields
are used by the header API; defensive sanitization here means a future
refactor can't accidentally re-introduce header injection."""
return value.replace("\r", " ").replace("\n", " ")
class NtfyClient(HttpProviderClient):
"""Sends push notifications via ntfy server."""
def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session
super().__init__(session, provider_name="ntfy")
async def send(
self,
@@ -22,41 +37,48 @@ class NtfyClient:
topic: str,
message: str,
title: str | None = None,
priority: int = 3,
priority: int = _DEFAULT_PRIORITY,
tags: list[str] | None = None,
click_url: str | None = None,
auth_token: str | None = None,
markdown: bool = True,
) -> dict[str, Any]:
"""Send a push notification to an ntfy topic."""
if not server_url or not topic:
return {"success": False, "error": "Missing server_url or topic"}
url = f"{server_url.rstrip('/')}"
topic = _strip_crlf(topic).strip()
if not topic:
return {"success": False, "error": "Topic is empty after sanitization"}
try:
priority_int = int(priority) if priority is not None else _DEFAULT_PRIORITY
except (TypeError, ValueError):
priority_int = _DEFAULT_PRIORITY
priority_int = max(_PRIORITY_MIN, min(priority_int, _PRIORITY_MAX))
payload: dict[str, Any] = {
"topic": topic,
"message": message,
"markdown": True,
"markdown": bool(markdown),
}
if title:
payload["title"] = title
if priority != 3:
payload["priority"] = priority
payload["title"] = _strip_crlf(title)
if priority_int != _DEFAULT_PRIORITY:
payload["priority"] = priority_int
if tags:
payload["tags"] = tags
cleaned = [
_strip_crlf(str(t))[:_MAX_TAG_LEN]
for t in tags[:_MAX_TAGS]
if t
]
if cleaned:
payload["tags"] = cleaned
if click_url:
payload["click"] = click_url
payload["click"] = _strip_crlf(click_url)
headers: dict[str, str] = {"Content-Type": "application/json"}
headers: dict[str, str] = {}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
try:
async with self._session.post(
url, json=payload, headers=headers, allow_redirects=False,
) as resp:
if 200 <= resp.status < 300:
return {"success": True}
body = await resp.text()
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
return await self.request("POST", server_url.rstrip("/"), json=payload, headers=headers)
@@ -2,47 +2,88 @@
from __future__ import annotations
import asyncio
import copy
import logging
from datetime import datetime, timezone
from typing import Any
from typing import Any, Final
from notify_bridge_core.storage import StorageBackend
_LOGGER = logging.getLogger(__name__)
# Bound on queue length. Without a cap, a misconfigured quiet-hour
# window plus high event throughput grows the persisted file unboundedly
# and every enqueue rewrites the whole file (O(n²) total writes). When
# the cap is hit we drop the oldest entry (FIFO) so the most recent
# events still reach the recipient when the window opens.
DEFAULT_MAX_QUEUE_SIZE: Final = 1000
class NotificationQueue:
"""Persistent queue for notifications deferred during quiet hours."""
def __init__(self, backend: StorageBackend) -> None:
def __init__(
self,
backend: StorageBackend,
*,
max_size: int = DEFAULT_MAX_QUEUE_SIZE,
) -> None:
self._backend = backend
self._data: dict[str, Any] | None = None
self._max_size = max_size
# Coordinates load / enqueue / clear / remove so a write-while-load
# race can't leave the in-memory copy out of sync with disk and so
# bulk operations don't interleave their reads-then-writes.
self._lock = asyncio.Lock()
@staticmethod
def _ensure_schema(data: Any) -> dict[str, Any]:
if not isinstance(data, dict) or not isinstance(data.get("queue"), list):
return {"queue": []}
return data
async def async_load(self) -> None:
self._data = await self._backend.load() or {"queue": []}
async with self._lock:
raw = await self._backend.load()
self._data = self._ensure_schema(raw)
async def async_enqueue(self, notification_params: dict[str, Any]) -> None:
if self._data is None:
self._data = {"queue": []}
self._data["queue"].append({
"params": notification_params,
"queued_at": datetime.now(timezone.utc).isoformat(),
})
await self._backend.save(self._data)
async with self._lock:
if self._data is None:
self._data = {"queue": []}
queue: list[dict[str, Any]] = self._data["queue"]
queue.append({
"params": notification_params,
"queued_at": datetime.now(timezone.utc).isoformat(),
})
if self._max_size > 0 and len(queue) > self._max_size:
# Drop oldest (FIFO) so a new event can still land.
drop = len(queue) - self._max_size
_LOGGER.warning(
"NotificationQueue: dropping %d oldest entries (cap=%d)",
drop, self._max_size,
)
del queue[:drop]
await self._backend.save(self._data)
def get_all(self) -> list[dict[str, Any]]:
if not self._data:
return []
return list(self._data.get("queue", []))
# Deep copy so callers can iterate / mutate without corrupting the
# in-memory queue. The cost is bounded by ``max_size``.
return copy.deepcopy(list(self._data.get("queue", [])))
def has_pending(self) -> bool:
return bool(self._data and self._data.get("queue"))
async def async_clear(self) -> None:
if self._data:
self._data["queue"] = []
await self._backend.save(self._data)
async with self._lock:
if self._data:
self._data["queue"] = []
await self._backend.save(self._data)
async def async_remove(self) -> None:
await self._backend.remove()
self._data = None
async with self._lock:
await self._backend.remove()
self._data = None
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from typing import Any, Callable
@dataclass
@@ -70,51 +70,64 @@ class MatrixReceiver(Receiver):
room_id: str = ""
_ReceiverFactory = Callable[[str, dict[str, Any]], Receiver]
def _coerce_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
_RECEIVER_FACTORIES: dict[str, _ReceiverFactory] = {
"telegram": lambda locale, config: TelegramReceiver(
locale=locale, config=config, chat_id=str(config.get("chat_id", "")),
),
"webhook": lambda locale, config: WebhookReceiver(
locale=locale, config=config,
url=str(config.get("url", "")),
headers=dict(config.get("headers", {}) or {}),
),
"email": lambda locale, config: EmailReceiver(
locale=locale, config=config,
email=str(config.get("email", "")),
name=str(config.get("name", "")),
),
"discord": lambda locale, config: DiscordReceiver(
locale=locale, config=config,
webhook_url=str(config.get("webhook_url", "")),
),
"slack": lambda locale, config: SlackReceiver(
locale=locale, config=config,
webhook_url=str(config.get("webhook_url", "")),
),
"ntfy": lambda locale, config: NtfyReceiver(
locale=locale, config=config,
topic=str(config.get("topic", "")),
priority=_coerce_int(config.get("priority"), 3),
),
"matrix": lambda locale, config: MatrixReceiver(
locale=locale, config=config,
room_id=str(config.get("room_id", "")),
),
}
def register_receiver_factory(target_type: str, factory: _ReceiverFactory) -> None:
"""Register a receiver factory for an out-of-tree target type."""
_RECEIVER_FACTORIES[target_type] = factory
def build_receiver(target_type: str, config: dict[str, Any], locale: str = "") -> Receiver:
"""Factory: build typed Receiver from target type and config dict."""
if target_type == "telegram":
return TelegramReceiver(
locale=locale,
config=config,
chat_id=str(config.get("chat_id", "")),
)
if target_type == "webhook":
return WebhookReceiver(
locale=locale,
config=config,
url=config.get("url", ""),
headers=config.get("headers", {}),
)
if target_type == "email":
return EmailReceiver(
locale=locale,
config=config,
email=config.get("email", ""),
name=config.get("name", ""),
)
if target_type == "discord":
return DiscordReceiver(
locale=locale,
config=config,
webhook_url=config.get("webhook_url", ""),
)
if target_type == "slack":
return SlackReceiver(
locale=locale,
config=config,
webhook_url=config.get("webhook_url", ""),
)
if target_type == "ntfy":
return NtfyReceiver(
locale=locale,
config=config,
topic=config.get("topic", ""),
priority=config.get("priority", 3),
)
if target_type == "matrix":
return MatrixReceiver(
locale=locale,
config=config,
room_id=config.get("room_id", ""),
)
return Receiver(locale=locale, config=config)
"""Factory: build typed Receiver from target type and config dict.
Falls back to a base ``Receiver`` for unknown target types so callers
that handle types defensively still receive a usable object but the
dispatcher rejects them with ``"Unknown target type"`` so a typo can't
silently route to nowhere.
"""
factory = _RECEIVER_FACTORIES.get(target_type)
if factory is None:
return Receiver(locale=locale, config=config)
return factory(locale, config)
@@ -0,0 +1,64 @@
"""Secret-redaction helpers for log lines and error strings.
Notification clients embed secrets in URLs (Telegram bot tokens) and
Authorization headers (Matrix access tokens, ntfy bearer tokens). When
those secrets surface in ``aiohttp.ClientError.__str__``, response
bodies, or operator-visible error fields, they leak into logs and into
the per-target result dict that callers may forward upstream. ``redact``
returns a defanged copy safe for both contexts.
"""
from __future__ import annotations
import re
from typing import Final
# api.telegram.org/bot<digits>:<token>/<method>
_TELEGRAM_BOT_TOKEN_RE: Final = re.compile(
r"(api\.telegram\.org/bot)\d+:[A-Za-z0-9_-]+", re.IGNORECASE,
)
# Authorization: Bearer <token> (header form, case-insensitive)
_BEARER_RE: Final = re.compile(r"(Bearer\s+)[A-Za-z0-9._\-+/=]+", re.IGNORECASE)
# Discord webhook: /api/webhooks/<id>/<token>
_DISCORD_WEBHOOK_RE: Final = re.compile(
r"(discord(?:app)?\.com/api/webhooks/\d+/)[A-Za-z0-9_-]+",
re.IGNORECASE,
)
# Slack webhook path: /services/T.../B.../<token>
_SLACK_WEBHOOK_RE: Final = re.compile(
r"(hooks\.slack\.com/services/[A-Z0-9]+/[A-Z0-9]+/)[A-Za-z0-9]+",
re.IGNORECASE,
)
# URL userinfo: scheme://user:password@host
_URL_USERINFO_RE: Final = re.compile(
r"([a-z][a-z0-9+\-.]*://)[^/@\s]+:[^/@\s]+@",
re.IGNORECASE,
)
# Common token query parameters
_QUERY_TOKEN_RE: Final = re.compile(
r"([?&](?:token|access_token|api_key|key|secret|password)=)[^&\s]+",
re.IGNORECASE,
)
def redact(text: str) -> str:
"""Return ``text`` with known secret patterns replaced by ``***``.
Idempotent and safe to call on already-redacted strings. Always
returns a ``str``; non-strings are coerced via ``str()`` so callers
can pass exception instances directly.
"""
if not isinstance(text, str):
text = str(text)
text = _TELEGRAM_BOT_TOKEN_RE.sub(r"\1***", text)
text = _DISCORD_WEBHOOK_RE.sub(r"\1***", text)
text = _SLACK_WEBHOOK_RE.sub(r"\1***", text)
text = _BEARER_RE.sub(r"\1***", text)
text = _URL_USERINFO_RE.sub(r"\1***@", text)
text = _QUERY_TOKEN_RE.sub(r"\1***", text)
return text
def redact_exc(err: BaseException) -> str:
"""Redact-and-stringify an exception. Convenience for error fields."""
return redact(str(err))
@@ -7,14 +7,16 @@ from typing import Any
import aiohttp
from ..http_base import HttpProviderClient
_LOGGER = logging.getLogger(__name__)
class SlackClient:
class SlackClient(HttpProviderClient):
"""Sends messages via Slack incoming webhook URLs."""
def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session
super().__init__(session, provider_name="slack")
async def send(
self,
@@ -33,19 +35,4 @@ class SlackClient:
if icon_emoji:
payload["icon_emoji"] = icon_emoji
try:
async with self._session.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
allow_redirects=False,
) as resp:
if resp.status == 429:
_LOGGER.warning("Slack rate limited")
return {"success": False, "error": "Rate limited by Slack"}
if 200 <= resp.status < 300:
return {"success": True}
body = await resp.text()
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
return await self.request("POST", webhook_url, json=payload)
@@ -1,10 +1,22 @@
"""Outbound URL validation to mitigate SSRF attacks.
User-controlled URLs (provider `url`, webhook target `url`, shared-link
base URLs, image downloads) must be validated before any HTTP request is
issued. This module rejects schemes other than http/https and blocks
destinations that resolve to private, loopback, link-local, or unspecified
address ranges.
User-controlled URLs (provider ``url``, webhook target ``url``,
shared-link base URLs, image downloads) must be validated before any
HTTP request is issued. This module rejects schemes other than
http/https and blocks destinations that resolve to private, loopback,
link-local, unspecified, CGNAT (100.64.0.0/10), or IPv4-mapped IPv6
ranges.
DNS rebinding mitigation
~~~~~~~~~~~~~~~~~~~~~~~~
``avalidate_outbound_url`` returns the original URL on success, but
also returns the resolved IP it actually validated. Callers that pass
the validated URL straight into ``aiohttp`` are vulnerable to a
DNS-rebinding attack: the validator's ``getaddrinfo`` returns a public
IP; aiohttp's connect-time resolution returns ``127.0.0.1``. To close
that gap, use :func:`build_ssrf_safe_session` (or
:class:`PinnedResolver`) so the resolved IP from the validation step is
the one aiohttp connects to.
Set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the environment for
development against localhost services.
@@ -17,12 +29,20 @@ import ipaddress
import logging
import os
import socket
from dataclasses import dataclass
from urllib.parse import urlparse
import aiohttp
_LOGGER = logging.getLogger(__name__)
_ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1"
_ALLOWED_SCHEMES = {"http", "https"}
_ALLOWED_SCHEMES = frozenset({"http", "https"})
# Carrier-grade NAT range. Not in stdlib's ``is_private``; an attacker
# pointing a domain at a CGNAT IP could reach the operator's ISP-side
# routing infrastructure. RFC 6598.
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
if _ALLOW_PRIVATE: # pragma: no cover — operator-visible banner
_LOGGER.warning(
@@ -36,7 +56,29 @@ class UnsafeURLError(ValueError):
"""Raised when a URL targets a disallowed network destination."""
@dataclass(frozen=True)
class ValidatedURL:
"""Result of validating an outbound URL.
Attributes:
url: The original URL string (unchanged).
host: Hostname extracted from the URL (lower-cased, IDN-encoded).
ip: Resolved IP address that passed the block-range check, as a
string. Pass to :class:`PinnedResolver` to defeat DNS
rebinding by reusing this exact IP at connect time.
"""
url: str
host: str
ip: str
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
# An IPv4-mapped IPv6 like ``::ffff:127.0.0.1`` is NOT considered
# ``is_private`` etc. by stdlib — the v4 view holds those flags. So
# we unwrap before checking.
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
return (
ip.is_private
or ip.is_loopback
@@ -44,22 +86,54 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
or (isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK)
)
def _safe_host_repr(host: str) -> str:
"""Return ``host`` shortened/escaped for safe inclusion in error text."""
h = host[:64].replace("\r", "").replace("\n", "")
return h
def _normalize_host(parsed_host: str) -> str:
"""Normalize a hostname: lowercase, strip trailing dot, IDN-encode."""
host = parsed_host.lower()
if host.endswith("."):
host = host[:-1]
# Strip IPv6 zone id ("fe80::1%eth0") — must not reach the resolver.
if "%" in host:
host = host.split("%", 1)[0]
# IDN-encode unicode hostnames so we don't downgrade to confusables
# in any later log/output and so getaddrinfo gets the ascii form.
try:
if any(ord(c) > 127 for c in host):
host = host.encode("idna").decode("ascii")
except UnicodeError:
# Caller will fail on resolution; leave as-is so the error path
# surfaces a "DNS resolution failed" rather than a stack trace.
pass
return host
def _check_scheme_host(url: str) -> tuple[str, str]:
if not isinstance(url, str) or not url:
raise UnsafeURLError("URL is empty")
parsed = urlparse(url)
if parsed.scheme not in _ALLOWED_SCHEMES:
raise UnsafeURLError(f"Scheme '{parsed.scheme}' not allowed")
scheme = parsed.scheme.lower()
if scheme not in _ALLOWED_SCHEMES:
raise UnsafeURLError(f"Scheme '{scheme[:16]}' not allowed")
host = parsed.hostname
if not host:
raise UnsafeURLError("URL has no host")
return parsed.scheme, host
return scheme, _normalize_host(host)
def _check_resolved_addresses(host: str, infos: list[tuple]) -> None:
def _select_addresses(
host: str, infos: list[tuple],
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
"""Return parsed, non-blocked IPs from ``getaddrinfo`` results."""
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
for info in infos:
sockaddr = info[4]
try:
@@ -67,64 +141,143 @@ def _check_resolved_addresses(host: str, infos: list[tuple]) -> None:
except ValueError:
continue
if _is_blocked_ip(ip):
raise UnsafeURLError(f"Host {host} resolves to blocked address {ip}")
raise UnsafeURLError(
f"Host {_safe_host_repr(host)} resolves to blocked address {ip}"
)
addrs.append(ip)
if not addrs:
raise UnsafeURLError(f"Host {_safe_host_repr(host)} has no usable address")
return addrs
def validate_outbound_url(url: str) -> str:
"""Validate ``url`` is safe to fetch; returns the URL on success.
Raises :class:`UnsafeURLError` when the scheme, host, or resolved IP
is not allowed. In development (``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1``)
private addresses are permitted but the scheme check still applies.
Synchronous; uses blocking ``socket.getaddrinfo``. Prefer
:func:`avalidate_outbound_url` from async code paths.
.. deprecated::
Synchronous; uses blocking ``socket.getaddrinfo``. Prefer
:func:`avalidate_outbound_url` from async code paths so the
event loop isn't blocked, and use :func:`build_ssrf_safe_session`
to defeat DNS rebinding.
"""
_, host = _check_scheme_host(url)
if _ALLOW_PRIVATE:
return url
# Literal IP host
try:
ip = ipaddress.ip_address(host)
if _is_blocked_ip(ip):
raise UnsafeURLError(f"Host {host} is in a blocked range")
raise UnsafeURLError(f"Host {_safe_host_repr(host)} is in a blocked range")
return url
except ValueError:
pass
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror as exc:
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
_check_resolved_addresses(host, infos)
except (socket.gaierror, UnicodeError, OSError) as exc:
# ``UnicodeError`` covers IDNA failures (labels >63 chars, malformed
# unicode) which getaddrinfo surfaces as encoding errors rather than
# gaierror. ``OSError`` covers transient resolver failures on some
# platforms.
raise UnsafeURLError(f"DNS resolution failed for {_safe_host_repr(host)}") from exc
_select_addresses(host, infos)
return url
async def avalidate_outbound_url(url: str) -> str:
"""Async variant that resolves DNS via the running loop's resolver.
"""Async variant — returns the URL on success.
Use this from ``async def`` code paths to avoid blocking the event
loop on DNS lookups.
For DNS-rebinding-safe usage, prefer :func:`avalidate_outbound_url_full`
which also returns the resolved IP for connect-time pinning.
"""
result = await avalidate_outbound_url_full(url)
return result.url
async def avalidate_outbound_url_full(url: str) -> ValidatedURL:
"""Validate ``url`` and return a :class:`ValidatedURL` on success.
The returned ``ip`` field is the IP that passed the block-range
check. Pair with :class:`PinnedResolver` so aiohttp connects to that
exact IP and a malicious DNS server can't swap in a private address
after validation.
"""
_, host = _check_scheme_host(url)
if _ALLOW_PRIVATE:
return url
# In dev mode we still resolve to give a usable IP, but we don't
# gate on the result.
try:
ip = str(ipaddress.ip_address(host))
except ValueError:
try:
loop = asyncio.get_running_loop()
infos = await loop.getaddrinfo(host, None)
ip = infos[0][4][0] if infos else host
except (socket.gaierror, OSError):
ip = host
return ValidatedURL(url=url, host=host, ip=ip)
# Literal IP host
try:
ip = ipaddress.ip_address(host)
if _is_blocked_ip(ip):
raise UnsafeURLError(f"Host {host} is in a blocked range")
return url
ip_obj = ipaddress.ip_address(host)
if _is_blocked_ip(ip_obj):
raise UnsafeURLError(f"Host {_safe_host_repr(host)} is in a blocked range")
return ValidatedURL(url=url, host=host, ip=str(ip_obj))
except ValueError:
pass
loop = asyncio.get_running_loop()
try:
infos = await loop.getaddrinfo(host, None)
except socket.gaierror as exc:
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
_check_resolved_addresses(host, infos)
return url
except (socket.gaierror, UnicodeError, OSError) as exc:
raise UnsafeURLError(f"DNS resolution failed for {_safe_host_repr(host)}") from exc
addrs = _select_addresses(host, infos)
return ValidatedURL(url=url, host=host, ip=str(addrs[0]))
class PinnedResolver(aiohttp.abc.AbstractResolver):
"""aiohttp resolver that returns a fixed (host, ip) mapping.
Used to pin the resolved IP from :func:`avalidate_outbound_url_full`
so aiohttp's connect-time resolution can't be tricked by DNS
rebinding into using a different IP than the one we validated.
Falls back to :class:`aiohttp.AsyncResolver` (or default) for any
host not explicitly pinned, so a single resolver instance can be
reused across multiple validated URLs.
"""
def __init__(self, mapping: dict[str, str] | None = None) -> None:
self._map: dict[str, str] = dict(mapping or {})
self._fallback: aiohttp.abc.AbstractResolver | None = None
def pin(self, host: str, ip: str) -> None:
self._map[host.lower()] = ip
async def resolve(
self, host: str, port: int = 0, family: int = socket.AF_INET,
) -> list[dict]:
ip = self._map.get(host.lower())
if ip is not None:
try:
ip_obj = ipaddress.ip_address(ip)
except ValueError:
ip_obj = None
if ip_obj is not None:
fam = socket.AF_INET6 if ip_obj.version == 6 else socket.AF_INET
return [{
"hostname": host,
"host": ip,
"port": port,
"family": fam,
"proto": 0,
"flags": socket.AI_NUMERICHOST,
}]
if self._fallback is None:
self._fallback = aiohttp.ThreadedResolver()
return await self._fallback.resolve(host, port, family)
async def close(self) -> None:
if self._fallback is not None:
await self._fallback.close()
@@ -2,16 +2,29 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Any
from typing import Any, Final
from notify_bridge_core.storage import StorageBackend
_LOGGER = logging.getLogger(__name__)
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
DEFAULT_MAX_ENTRIES = 5000
DEFAULT_TELEGRAM_CACHE_TTL: Final = 48 * 60 * 60
DEFAULT_MAX_ENTRIES: Final = 5000
def _parse_iso(value: str | None) -> datetime | None:
"""Parse an ISO-8601 timestamp tolerantly. Returns ``None`` on failure."""
if not value or not isinstance(value, str):
return None
try:
# Python <3.11 doesn't accept "Z"; normalize to +00:00.
v = value.replace("Z", "+00:00") if value.endswith("Z") else value
return datetime.fromisoformat(v)
except ValueError:
return None
class TelegramFileCache:
@@ -25,7 +38,17 @@ class TelegramFileCache:
Intended for content-addressable assets (e.g. Immich) where re-uploads
should be triggered by visual change, not elapsed time.
``max_entries`` always applies as an LRU size cap (by ``cached_at``).
``max_entries`` always applies as a FIFO size cap (oldest-cached first).
Concurrency
~~~~~~~~~~~
All mutators take an internal ``asyncio.Lock`` so concurrent
media-group sends can't interleave a read-time invalidation with a
bulk write and corrupt the underlying dict (``RuntimeError:
dictionary changed size during iteration``) or lose just-written
entries. Reads do not take the lock they are O(1) dict lookups
but ``get`` uses a snapshot reference so it cannot mutate the data
structure under another task.
"""
def __init__(
@@ -40,35 +63,40 @@ class TelegramFileCache:
self._ttl_seconds = ttl_seconds
self._use_thumbhash = use_thumbhash
self._max_entries = max_entries
self._lock = asyncio.Lock()
async def async_load(self) -> None:
self._data = await self._backend.load() or {"files": {}}
await self._cleanup_expired()
async with self._lock:
self._data = await self._backend.load() or {"files": {}}
await self._cleanup_expired_locked()
async def _cleanup_expired(self) -> None:
async def _cleanup_expired_locked(self) -> None:
"""Caller must hold ``self._lock``."""
if not self._data or "files" not in self._data:
return
files = self._data["files"]
files: dict[str, dict[str, Any]] = self._data["files"]
changed = False
# TTL sweep — only when TTL validation is active (i.e. no thumbhash
# mode and a positive TTL). In thumbhash mode we rely entirely on
# content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
# cache forever, subject only to the size cap.
if not self._use_thumbhash and self._ttl_seconds > 0:
now = datetime.now(timezone.utc)
expired = [
url for url, entry in files.items()
if entry.get("cached_at") and
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
]
expired: list[str] = []
for url, entry in list(files.items()):
cached_at = _parse_iso(entry.get("cached_at"))
if cached_at is None:
continue
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
if (now - cached_at).total_seconds() > self._ttl_seconds:
expired.append(url)
for key in expired:
del files[key]
changed = True
# LRU cap — always enforced. Evicts oldest-cached entries first.
if self._max_entries > 0 and len(files) > self._max_entries:
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
sorted_keys = sorted(
files,
key=lambda k: _parse_iso(files[k].get("cached_at")) or datetime.min.replace(tzinfo=timezone.utc),
)
for key in sorted_keys[: len(files) - self._max_entries]:
del files[key]
changed = True
@@ -80,7 +108,10 @@ class TelegramFileCache:
if not self._data or "files" not in self._data:
return None
entry = self._data["files"].get(key)
# Take a local reference so a concurrent ``async_set`` rebuilding
# the dict cannot pull the rug out mid-read.
files = self._data["files"]
entry = files.get(key)
if not entry:
return None
@@ -88,19 +119,23 @@ class TelegramFileCache:
if thumbhash is not None:
stored = entry.get("thumbhash")
if stored and stored != thumbhash:
del self._data["files"][key]
# Mark stale — actual deletion happens lock-protected
# in the next mutation. Returning None is sufficient
# for the caller to skip the cache hit.
return None
elif self._ttl_seconds > 0:
cached_at_str = entry.get("cached_at")
if cached_at_str:
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
cached_at = _parse_iso(entry.get("cached_at"))
if cached_at is not None:
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
if age > self._ttl_seconds:
return None
return {
"file_id": entry.get("file_id"),
"type": entry.get("type"),
"size": entry.get("size"), # bytes of what was uploaded; None for legacy entries
"size": entry.get("size"),
}
async def async_set(
@@ -111,21 +146,22 @@ class TelegramFileCache:
thumbhash: str | None = None,
size: int | None = None,
) -> None:
if self._data is None:
self._data = {"files": {}}
async with self._lock:
if self._data is None:
self._data = {"files": {}}
entry: dict[str, Any] = {
"file_id": file_id,
"type": media_type,
"cached_at": datetime.now(timezone.utc).isoformat(),
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
entry: dict[str, Any] = {
"file_id": file_id,
"type": media_type,
"cached_at": datetime.now(timezone.utc).isoformat(),
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry
await self._backend.save(self._data)
self._data["files"][key] = entry
await self._backend.save(self._data)
async def async_set_many(
self,
@@ -139,32 +175,34 @@ class TelegramFileCache:
"""
if not entries:
return
if self._data is None:
self._data = {"files": {}}
async with self._lock:
if self._data is None:
self._data = {"files": {}}
now_iso = datetime.now(timezone.utc).isoformat()
for item in entries:
if len(item) == 5:
key, file_id, media_type, thumbhash, size = item
else:
key, file_id, media_type, thumbhash = item
size = None
entry: dict[str, Any] = {
"file_id": file_id,
"type": media_type,
"cached_at": now_iso,
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry
now_iso = datetime.now(timezone.utc).isoformat()
for item in entries:
if len(item) == 5:
key, file_id, media_type, thumbhash, size = item
else:
key, file_id, media_type, thumbhash = item
size = None
entry: dict[str, Any] = {
"file_id": file_id,
"type": media_type,
"cached_at": now_iso,
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry
await self._backend.save(self._data)
await self._backend.save(self._data)
async def async_remove(self) -> None:
await self._backend.remove()
self._data = None
async with self._lock:
await self._backend.remove()
self._data = None
def stats(self) -> dict[str, Any]:
"""Return summary stats about the current cache contents.
@@ -172,25 +210,33 @@ class TelegramFileCache:
Includes the number of cached entries, total tracked size in bytes
(only counts entries with a recorded ``size``), and the oldest /
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
Timestamps are compared as parsed ``datetime`` objects so mixed
timezone formats (``Z`` vs ``+00:00``) order correctly.
"""
files = self._data.get("files", {}) if self._data else {}
count = len(files)
total_size = 0
oldest: str | None = None
newest: str | None = None
oldest_dt: datetime | None = None
newest_dt: datetime | None = None
oldest_str: str | None = None
newest_str: str | None = None
for entry in files.values():
size = entry.get("size")
if isinstance(size, int):
total_size += size
cached_at = entry.get("cached_at")
if cached_at:
if oldest is None or cached_at < oldest:
oldest = cached_at
if newest is None or cached_at > newest:
newest = cached_at
dt = _parse_iso(cached_at)
if dt is None or not cached_at:
continue
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
if oldest_dt is None or dt < oldest_dt:
oldest_dt, oldest_str = dt, cached_at
if newest_dt is None or dt > newest_dt:
newest_dt, newest_str = dt, cached_at
return {
"count": count,
"total_size_bytes": total_size,
"oldest": oldest,
"newest": newest,
"oldest": oldest_str,
"newest": newest_str,
}
File diff suppressed because it is too large Load Diff
@@ -2,20 +2,35 @@
from __future__ import annotations
import logging
import re
from typing import Any, Final
from urllib.parse import urlparse
_LOGGER = logging.getLogger(__name__)
# Telegram constants
TELEGRAM_API_BASE_URL: Final = "https://api.telegram.org/bot"
TELEGRAM_MAX_PHOTO_SIZE: Final = 10 * 1024 * 1024 # 10 MB
TELEGRAM_MAX_VIDEO_SIZE: Final = 50 * 1024 * 1024 # 50 MB
TELEGRAM_MAX_DIMENSION_SUM: Final = 10000
# Telegram message-text limit (sendMessage) and caption limit
# (sendPhoto/sendVideo/sendDocument/first item of sendMediaGroup).
TELEGRAM_MAX_TEXT_LENGTH: Final = 4096
TELEGRAM_MAX_CAPTION_LENGTH: Final = 1024
# Generic UUID pattern for asset IDs
_ASSET_ID_PATTERN = re.compile(r"^[a-f0-9-]{36}$")
# Strict canonical-UUID pattern (8-4-4-4-12) for asset IDs. The previous
# loose ``[a-f0-9-]{36}`` matched 36 hyphens / arbitrary digit groupings,
# which could collide across providers when used as a cache key.
_ASSET_ID_PATTERN = re.compile(
r"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$",
re.IGNORECASE,
)
# Cache key: "host:uuid" or bare "uuid"
_ASSET_CACHE_KEY_PATTERN = re.compile(r"^(?:[^:]+:)?[a-f0-9-]{36}$")
_ASSET_CACHE_KEY_PATTERN = re.compile(
r"^(?:[^:]+:)?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$",
re.IGNORECASE,
)
# URL patterns to extract asset IDs (generic enough for Immich-style URLs)
_ASSET_ID_URL_PATTERNS = [
@@ -162,5 +177,10 @@ def check_photo_limits(
return False, None, width, height
except ImportError:
return False, None, None, None
except Exception:
except (OSError, ValueError, MemoryError) as exc:
# PIL surfaces ``UnidentifiedImageError`` (subclass of OSError),
# truncated-image / decompression-bomb errors here. Log so a
# corrupt asset isn't silently passed to Telegram and rejected
# downstream with a less actionable error.
_LOGGER.warning("check_photo_limits: failed to inspect image (%d bytes): %s", len(data), exc)
return False, None, None, None
@@ -7,37 +7,29 @@ from typing import Any
import aiohttp
from ..ssrf import UnsafeURLError, avalidate_outbound_url
from ..http_base import HttpProviderClient
_LOGGER = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=30)
class WebhookClient(HttpProviderClient):
"""Send JSON payloads to a webhook URL.
class WebhookClient:
"""Send JSON payloads to a webhook URL."""
The URL is SSRF-validated on every send (defense-in-depth: re-validating
catches DNS rebinding between calls and a misconfigured target). Headers
pass through :func:`safe_headers` so a target config can't inject
framing/hop-by-hop headers like ``Host`` or ``Transfer-Encoding``.
"""
def __init__(self, session: aiohttp.ClientSession, url: str, headers: dict[str, str] | None = None) -> None:
self._session = session
def __init__(
self,
session: aiohttp.ClientSession,
url: str,
headers: dict[str, str] | None = None,
) -> None:
super().__init__(session, provider_name="webhook")
self._url = url
self._headers = headers or {}
self._extra_headers = headers or {}
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
try:
await avalidate_outbound_url(self._url)
except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe URL: {err}"}
try:
async with self._session.post(
self._url,
json=payload,
headers={"Content-Type": "application/json", **self._headers},
timeout=_DEFAULT_TIMEOUT,
allow_redirects=False,
) as response:
if 200 <= response.status < 300:
return {"success": True, "status_code": response.status}
body = await response.text()
return {"success": False, "error": f"HTTP {response.status}", "body": body[:200]}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
return await self.request("POST", self._url, json=payload, headers=self._extra_headers)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.6.3"
version = "0.7.1"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -218,6 +218,19 @@ async def get_supported_locales(
return locales or ["en"]
@router.get("/external-url")
async def get_external_url(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Return the configured external base URL (available to all users).
Used by the UI to render absolute provider webhook URLs. Returns empty
string when unset so the UI falls back to the relative path.
"""
return {"external_url": (await get_setting(session, "external_url")).rstrip("/")}
async def _reregister_webhooks(
session: AsyncSession, base_url: str, secret: str
) -> None:
@@ -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})")
@@ -1391,6 +1397,40 @@ async def migrate_performance_indexes(engine: AsyncEngine) -> None:
)
async def migrate_chat_action_to_column(engine: AsyncEngine) -> None:
"""Move ``chat_action`` from ``config`` JSON to the dedicated column.
Earlier versions of the frontend stored ``chat_action`` inside
``notification_target.config``; the dedicated ``chat_action`` column
was rarely set or held a stale default. The dispatcher's resolver
overrode the config value with the (stale) column, so a user's UI
choice silently had no effect on outgoing chat actions.
This backfill takes the config value as authoritative (it's what the
UI was writing) and copies it to the column, then strips it from
config so the column becomes the single source of truth. Idempotent:
a second run finds nothing to migrate.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "notification_target"):
return
if not await _has_column(conn, "notification_target", "chat_action"):
return
# Copy config["chat_action"] → column where present.
await conn.execute(text(
"UPDATE notification_target "
"SET chat_action = json_extract(config, '$.chat_action') "
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
))
# Strip the legacy key so the column is unambiguous going forward.
await conn.execute(text(
"UPDATE notification_target "
"SET config = json_remove(config, '$.chat_action') "
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
))
logger.info("Migrated chat_action from config JSON to column where present")
# ---------------------------------------------------------------------------
# Schema version tracking — lightweight alternative to Alembic while the
# hand-rolled idempotent migrations remain the source of truth. Gives
@@ -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)
@@ -75,6 +75,7 @@ async def lifespan(app: FastAPI):
migrate_notification_slot_locale,
migrate_user_token_version,
migrate_performance_indexes,
migrate_chat_action_to_column,
migrate_schema_version,
)
from .database.snapshot import snapshot_and_prune
@@ -98,6 +99,7 @@ async def lifespan(app: FastAPI):
await migrate_notification_slot_locale(engine)
await migrate_user_token_version(engine)
await migrate_performance_indexes(engine)
await migrate_chat_action_to_column(engine)
await migrate_schema_version(engine)
from .database.seeds import seed_all
await seed_all()
@@ -326,7 +326,11 @@ async def _resolve_target(
receivers.append(build_receiver(target.type, dict(r.config), locale))
target_config = dict(target.config)
# Inject chat_action for Telegram targets
# chat_action lives on the model column — single source of truth.
# Strip any legacy/stale value from config so an old config-stored value
# can't shadow the user's UI choice. When the column is unset, leave the
# key absent so the dispatcher's "typing" fallback applies.
target_config.pop("chat_action", None)
if hasattr(target, 'chat_action') and target.chat_action:
target_config["chat_action"] = target.chat_action
# Inject bot credentials for bot-backed target types
@@ -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",
@@ -19,7 +19,6 @@ this module just guarantees every caller gets a properly-wired client.
from __future__ import annotations
import asyncio
import contextlib
from typing import Any, AsyncIterator, Callable
@@ -144,6 +143,4 @@ async def telegram_chat_action(
try:
yield
finally:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await client.stop_keepalive(task)
@@ -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())
@@ -0,0 +1,46 @@
"""Dispatcher result aggregation: per-receiver detail must survive."""
from __future__ import annotations
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher
def test_aggregate_all_success() -> None:
out = NotificationDispatcher._aggregate_results([
{"success": True, "message_id": 1},
{"success": True, "message_id": 2},
])
assert out["success"] is True
assert out["receivers"] == 2
assert out["successes"] == 2
assert out["failures"] == 0
def test_aggregate_partial() -> None:
out = NotificationDispatcher._aggregate_results([
{"success": True},
{"success": False, "error": "boom"},
])
assert out["success"] is True # at least one succeeded
assert out["successes"] == 1
assert out["failures"] == 1
assert "boom" in out["errors"]
assert "results" in out
def test_aggregate_all_fail_preserves_all_errors() -> None:
out = NotificationDispatcher._aggregate_results([
{"success": False, "error": "first"},
{"success": False, "error": "second"},
])
assert out["success"] is False
assert out["error"] == "first" # back-compat top-level field
assert out["errors"] == ["first", "second"]
# Per-receiver details survive — operator can see exactly what failed.
assert len(out["results"]) == 2
def test_aggregate_empty() -> None:
out = NotificationDispatcher._aggregate_results([])
assert out["success"] is False
assert "error" in out
@@ -0,0 +1,77 @@
"""Email client header-injection / address-validation regression tests."""
from __future__ import annotations
import pytest
from notify_bridge_core.notifications.email.client import (
EmailClient,
SmtpConfig,
_strip_header,
_validate_email,
_to_html,
)
def test_strip_header_removes_crlf() -> None:
out = _strip_header("Subject\r\nBcc: attacker@example.com")
assert "\r" not in out
assert "\n" not in out
# The injected "Bcc:" line is folded to a single header line; the SMTP
# server will treat it as part of the subject text, not a header.
assert "Bcc:" in out # value preserved as plain text
def test_strip_header_removes_bare_lf() -> None:
out = _strip_header("Hello\nWorld")
assert "\n" not in out
def test_strip_header_handles_non_string() -> None:
assert _strip_header(None) == ""
def test_validate_email_rejects_crlf() -> None:
with pytest.raises(ValueError):
_validate_email("user@example.com\r\nBcc: x@y")
def test_validate_email_rejects_no_at() -> None:
with pytest.raises(ValueError):
_validate_email("not-an-email")
def test_validate_email_rejects_empty() -> None:
with pytest.raises(ValueError):
_validate_email("")
def test_validate_email_accepts_normal() -> None:
assert _validate_email("user@example.com") == "user@example.com"
def test_to_html_escapes_brackets() -> None:
out = _to_html("<script>alert(1)</script>")
assert "<script>" not in out
assert "&lt;script&gt;" in out
@pytest.mark.asyncio
async def test_send_returns_error_on_invalid_to() -> None:
cfg = SmtpConfig(host="smtp.example.com", from_address="from@example.com")
client = EmailClient(cfg)
result = await client.send(
to_email="user@example.com\r\nBcc: attacker@example.com",
subject="hi",
body_text="body",
)
assert result["success"] is False
assert "Invalid email" in result["error"]
@pytest.mark.asyncio
async def test_send_returns_error_on_no_host() -> None:
cfg = SmtpConfig(host="", from_address="from@example.com")
client = EmailClient(cfg)
result = await client.send("u@x.com", "s", "b")
assert result["success"] is False
+53
View File
@@ -0,0 +1,53 @@
"""HttpProviderClient + safe_headers tests."""
from __future__ import annotations
import pytest
from notify_bridge_core.notifications.http_base import safe_headers
class TestSafeHeaders:
def test_drops_hop_by_hop(self) -> None:
out = safe_headers({
"X-Custom": "ok",
"Host": "evil.example.com",
"Content-Length": "999",
"Transfer-Encoding": "chunked",
"Connection": "close",
})
assert out == {"X-Custom": "ok"}
def test_rejects_crlf_in_value(self) -> None:
out = safe_headers({
"X-Custom": "ok",
"X-Bad": "value\r\nInjected: yes",
})
assert "X-Custom" in out
assert "X-Bad" not in out
def test_rejects_crlf_in_name(self) -> None:
out = safe_headers({
"X-Custom": "ok",
"X-Bad\r\nInject": "value",
})
assert out == {"X-Custom": "ok"}
def test_empty_input(self) -> None:
assert safe_headers(None) == {}
assert safe_headers({}) == {}
@pytest.mark.asyncio
async def test_http_base_returns_safe_error_on_invalid_url() -> None:
"""An obviously-bad URL must not panic or leak the URL verbatim."""
import aiohttp
from notify_bridge_core.notifications.http_base import HttpProviderClient
async with aiohttp.ClientSession() as sess:
client = HttpProviderClient(sess, provider_name="test")
# file:// is rejected by the SSRF guard before any HTTP call.
result = await client.request("POST", "file:///etc/passwd", json={})
assert result["success"] is False
assert "Unsafe URL" in result["error"]
@@ -0,0 +1,84 @@
"""Matrix client validation: room_id format and quoting."""
from __future__ import annotations
import aiohttp
import pytest
from aioresponses import aioresponses
from notify_bridge_core.notifications.matrix.client import MatrixClient
HOMESERVER = "https://matrix.example.com"
TOKEN = "secret-bearer-token-1234567890"
@pytest.mark.asyncio
async def test_rejects_path_injection_room_id() -> None:
async with aiohttp.ClientSession() as sess:
client = MatrixClient(sess, HOMESERVER, TOKEN)
result = await client.send_message("!abc:host/../../etc/passwd", "hi")
assert result["success"] is False
assert "room_id" in result["error"]
@pytest.mark.asyncio
async def test_rejects_empty_room_id() -> None:
async with aiohttp.ClientSession() as sess:
client = MatrixClient(sess, HOMESERVER, TOKEN)
result = await client.send_message("", "hi")
assert result["success"] is False
assert "room_id" in result["error"]
@pytest.mark.asyncio
async def test_rejects_unicode_control_chars_in_room_id() -> None:
async with aiohttp.ClientSession() as sess:
client = MatrixClient(sess, HOMESERVER, TOKEN)
result = await client.send_message("!abc\x00:host", "hi")
assert result["success"] is False
@pytest.mark.asyncio
async def test_url_encodes_room_id_special_chars() -> None:
"""``!`` and ``:`` must reach the server URL-encoded."""
captured: list[str] = []
with aioresponses() as mocked:
# Match any PUT under the rooms path; capture the URL we got.
mocked.put(
"https://matrix.example.com/_matrix/client/v3/rooms/%21abc%3Ahost.example/send/m.room.message",
status=200, body='{}', repeat=True,
)
# aioresponses doesn't expose URL templates well, so use a regex mock.
import re
mocked.put(
re.compile(r"https://matrix\.example\.com/_matrix/client/v3/rooms/[^/]+/send/m\.room\.message/.*"),
status=200, body='{}', repeat=True,
)
async with aiohttp.ClientSession() as sess:
client = MatrixClient(sess, HOMESERVER, TOKEN)
result = await client.send_message("!abc:host.example", "hi")
assert result["success"] is True
@pytest.mark.asyncio
async def test_redacts_bearer_in_error() -> None:
"""A 4xx response body must not echo the Authorization Bearer back to caller."""
import re
with aioresponses() as mocked:
mocked.put(
re.compile(r".*"),
status=403,
body='{"errcode": "M_FORBIDDEN", "Authorization": "Bearer ' + TOKEN + '"}',
repeat=True,
)
async with aiohttp.ClientSession() as sess:
client = MatrixClient(sess, HOMESERVER, TOKEN)
result = await client.send_message("!abc:host.example", "hi")
assert result["success"] is False
assert TOKEN not in result["error"]
+84
View File
@@ -0,0 +1,84 @@
"""NotificationQueue bound + concurrency regression tests."""
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from notify_bridge_core.notifications.queue import (
DEFAULT_MAX_QUEUE_SIZE,
NotificationQueue,
)
class _MemBackend:
"""In-memory storage backend stub for tests."""
def __init__(self) -> None:
self._data: dict[str, Any] | None = None
async def load(self) -> dict[str, Any] | None:
return self._data
async def save(self, data: dict[str, Any]) -> None:
self._data = data
async def remove(self) -> None:
self._data = None
@pytest.mark.asyncio
async def test_load_with_garbage_falls_back_to_empty() -> None:
backend = _MemBackend()
backend._data = {"queue": "not-a-list"} # type: ignore[assignment]
q = NotificationQueue(backend)
await q.async_load()
assert q.get_all() == []
@pytest.mark.asyncio
async def test_enqueue_caps_at_max_size() -> None:
backend = _MemBackend()
q = NotificationQueue(backend, max_size=3)
await q.async_load()
for i in range(10):
await q.async_enqueue({"i": i})
items = q.get_all()
assert len(items) == 3
# FIFO drop: most recent three are kept (i=7..9).
assert [it["params"]["i"] for it in items] == [7, 8, 9]
@pytest.mark.asyncio
async def test_get_all_returns_deep_copy() -> None:
backend = _MemBackend()
q = NotificationQueue(backend, max_size=10)
await q.async_load()
await q.async_enqueue({"key": "value"})
snap = q.get_all()
snap[0]["params"]["key"] = "MUTATED"
snap2 = q.get_all()
assert snap2[0]["params"]["key"] == "value"
@pytest.mark.asyncio
async def test_concurrent_enqueue_and_clear_no_corruption() -> None:
backend = _MemBackend()
q = NotificationQueue(backend, max_size=DEFAULT_MAX_QUEUE_SIZE)
await q.async_load()
async def producer() -> None:
for i in range(50):
await q.async_enqueue({"i": i})
async def clearer() -> None:
for _ in range(10):
await asyncio.sleep(0)
await q.async_clear()
await asyncio.gather(producer(), clearer())
# No exceptions = no race-induced "dictionary changed size during iteration".
items = q.get_all()
assert isinstance(items, list)
+74
View File
@@ -0,0 +1,74 @@
"""Secret-redaction helper regression tests.
Locks in the patterns that surface from real provider error paths:
Telegram bot URLs in aiohttp.ClientError messages, Authorization Bearer
tokens in Matrix/ntfy responses, Discord/Slack webhook tokens, URL
userinfo, and common ?token= query params.
"""
from __future__ import annotations
import pytest
from notify_bridge_core.notifications.redact import redact, redact_exc
@pytest.mark.parametrize(
"raw,expected_substr,not_in",
[
(
"Cannot connect to host api.telegram.org/bot1234567:AABBCC-secret-token/sendMessage",
"api.telegram.org/bot***",
"AABBCC-secret-token",
),
(
"Authorization: Bearer ey.JhbGciOiJIUzI1NiJ9.payload.sig",
"Bearer ***",
"ey.JhbGciOiJIUzI1NiJ9",
),
(
"POST https://discord.com/api/webhooks/12345/abcdefg-token failed",
"discord.com/api/webhooks/12345/***",
"abcdefg-token",
),
(
"POST https://hooks.slack.com/services/T01/B02/zzzzz failed",
"hooks.slack.com/services/T01/B02/***",
"zzzzz",
),
(
"fetch http://user:supersecret@example.com/foo",
"http://***@example.com/foo",
"supersecret",
),
(
"https://api.example.com/x?token=mytoken123&extra=ok",
"token=***",
"mytoken123",
),
],
)
def test_redact_known_secrets(raw: str, expected_substr: str, not_in: str) -> None:
out = redact(raw)
assert expected_substr in out
assert not_in not in out
def test_redact_idempotent() -> None:
once = redact("Bearer abcdefghij1234567890")
twice = redact(once)
assert once == twice
def test_redact_exc_returns_str() -> None:
err = RuntimeError("Bearer abcdefghij1234567890")
out = redact_exc(err)
assert isinstance(out, str)
assert "Bearer ***" in out
assert "abcdefghij1234567890" not in out
def test_redact_handles_non_string() -> None:
# Coercion path should not raise.
out = redact(12345) # type: ignore[arg-type]
assert out == "12345"
@@ -0,0 +1,73 @@
"""SSRF hardening regression tests.
Covers cases the original guard missed: IPv4-mapped IPv6, CGNAT,
trailing-dot hostnames, IPv6 zone identifiers, and the safe-host repr
used in error messages.
"""
from __future__ import annotations
import pytest
from notify_bridge_core.notifications.ssrf import (
UnsafeURLError,
PinnedResolver,
avalidate_outbound_url_full,
validate_outbound_url,
)
class TestBlockedRanges:
@pytest.mark.parametrize(
"url",
[
"http://[::ffff:127.0.0.1]/", # IPv4-mapped IPv6 → loopback
"http://[::ffff:10.0.0.1]/", # IPv4-mapped IPv6 → RFC1918
"http://100.64.0.1/", # CGNAT (RFC 6598)
"http://0.0.0.0/", # unspecified
],
)
def test_rejects_extra_ranges(self, url: str) -> None:
with pytest.raises(UnsafeURLError):
validate_outbound_url(url)
class TestHostnameNormalization:
def test_strips_trailing_dot(self) -> None:
# ``localhost.`` should normalize to ``localhost`` and still resolve
# to the loopback address — and be blocked.
with pytest.raises(UnsafeURLError):
validate_outbound_url("http://localhost./")
def test_rejects_bad_scheme_uppercase(self) -> None:
with pytest.raises(UnsafeURLError):
validate_outbound_url("FILE:///etc/passwd")
class TestErrorMessages:
def test_error_does_not_leak_long_hosts(self) -> None:
with pytest.raises(UnsafeURLError) as ei:
validate_outbound_url("http://" + "a" * 1024 + ".invalid/")
# Truncated to 64 chars in the error string.
assert len(str(ei.value)) < 256
class TestPinnedResolverSync:
def test_pin_returns_pinned_ip(self) -> None:
resolver = PinnedResolver({"example.com": "93.184.216.34"})
# Just exercise the dict path — full resolve runs in async tests.
assert resolver._map["example.com"] == "93.184.216.34" # type: ignore[attr-defined]
class TestAsyncFullValidator:
@pytest.mark.asyncio
async def test_returns_resolved_ip(self) -> None:
# Literal IP — no DNS lookup; we still get back a ValidatedURL.
result = await avalidate_outbound_url_full("http://8.8.8.8/")
assert result.ip == "8.8.8.8"
assert result.host == "8.8.8.8"
@pytest.mark.asyncio
async def test_rejects_blocked_literal(self) -> None:
with pytest.raises(UnsafeURLError):
await avalidate_outbound_url_full("http://[::ffff:127.0.0.1]/")
@@ -0,0 +1,56 @@
"""Telegram media-group mixed-type partitioning regression test.
Telegram rejects sendMediaGroup payloads that mix ``document`` with
``photo``/``video``. The client must partition before chunking so a
mixed input list still delivers all assets.
"""
from __future__ import annotations
from notify_bridge_core.notifications.telegram.client import TelegramClient
def test_partition_keeps_photo_video_together() -> None:
parts = TelegramClient._partition_media_by_kind([
{"type": "photo", "url": "p1"},
{"type": "video", "url": "v1"},
{"type": "photo", "url": "p2"},
])
assert len(parts) == 1
assert [a["url"] for a in parts[0]] == ["p1", "v1", "p2"]
def test_partition_separates_documents_from_media() -> None:
parts = TelegramClient._partition_media_by_kind([
{"type": "photo", "url": "p1"},
{"type": "document", "url": "d1"},
{"type": "video", "url": "v1"},
])
assert len(parts) == 3
assert parts[0][0]["url"] == "p1"
assert parts[1][0]["url"] == "d1"
assert parts[2][0]["url"] == "v1"
def test_partition_groups_consecutive_documents() -> None:
parts = TelegramClient._partition_media_by_kind([
{"type": "document", "url": "d1"},
{"type": "document", "url": "d2"},
{"type": "photo", "url": "p1"},
])
assert len(parts) == 2
assert [a["url"] for a in parts[0]] == ["d1", "d2"]
assert parts[1][0]["url"] == "p1"
def test_partition_empty() -> None:
assert TelegramClient._partition_media_by_kind([]) == []
def test_partition_defaults_missing_type_to_photo() -> None:
"""Items without an explicit type are treated as photos for grouping."""
parts = TelegramClient._partition_media_by_kind([
{"url": "x"}, # no type
{"type": "video", "url": "v"},
])
assert len(parts) == 1