Compare commits

..

8 Commits

Author SHA1 Message Date
alexei.dolgolyov d7c48b06ee ci: isolate test backend install in venv
Release / test-backend (push) Successful in 2m1s
Release / release (push) Successful in 2m3s
The persistent Gitea runner caches the setup-python toolcache between
runs. A previous run that produced wheels with broken metadata (no
Version field in METADATA) left a notify-bridge-server install with
no RECORD file in site-packages. The next run hits:

  Found existing installation: notify-bridge-server None
  error: uninstall-no-record-file

pip refuses to uninstall (no RECORD) and refuses to overlay (it tries
to uninstall first). Switching from a system-pip install into the
toolcache to an isolated /tmp/venv per run sidesteps the leak — each
CI run starts with empty site-packages.

Same change to build.yml and release.yml so the pre-merge gate and
the release-gate both run the same setup.
2026-05-16 18:33:41 +03:00
alexei.dolgolyov 66f152ef2c fix(tests): green pytest gate for v0.8.1
Release / test-backend (push) Failing after 8s
Release / release (push) Has been skipped
Four root causes blocked the CI test gate; all fixed minimally:

1. test_release_provider._allow_private_urls used setenv +
   importlib.reload(ssrf_mod). The reload permanently rebound
   _ALLOW_PRIVATE=True in the module; monkeypatch.setenv undid the
   env var on teardown but the module attribute stayed True for the
   rest of the session, masking every test_ssrf*/test_ssrf_hardening
   case (16 failures). Switched to monkeypatch.setattr on the module
   attribute directly — restored cleanly on teardown.

2. _FakeResponse in test_release_provider lacked the content_length
   attribute and a top-level read() method that the new size-cap
   guards in gitea.py consult before parsing (5 failures).

3. test_gate_quiet_hours_wins_over_event_type_flag was asserting the
   pre-refactor gate order. evaluate_event_gate now intentionally
   reports EVENT_TYPE_DISABLED before QUIET_HOURS so deferrable
   events with the event-type flag off get dropped immediately
   instead of being deferred and then silently discarded at drain
   time. Renamed the test and inverted the expectation.

4. resolve_version() returned 0.0.0+unknown in CI because
   pip-wheel-built hatchling distributions ended up with METADATA
   missing the Version field — importlib.metadata returned None.
   Added __version__ = "0.8.1" to notify_bridge_server/__init__.py
   as a third (always-available) candidate; resolve_version() now
   picks the max of (installed, package, source).
2026-05-16 18:25:51 +03:00
alexei.dolgolyov faaaa39f8a chore: release v0.8.1
Release / test-backend (push) Failing after 1m36s
Release / release (push) Has been skipped
2026-05-16 12:28:07 +03:00
alexei.dolgolyov 8651767112 feat: bridge_self bot commands — status, thresholds, reset, health
Adds bot commands for the bridge_self provider so operators can inspect
and manage bridge health from chat: /status, /thresholds, /reset, /health.
Includes Jinja2 templates for both locales, seed data, capability slots,
and a handler that exposes pending deferred backlog plus per-counter
reset. Also adds .claude/skills/ for project-scoped graph-aware skills.
2026-05-16 03:43:48 +03:00
alexei.dolgolyov 10d30fc956 feat: production readiness — security, perf, bug fixes, bridge self-monitoring
Comprehensive multi-area pass driven by a parallel 8-agent production
review. Frontend, backend, database, security, performance, operational,
plus a new self-monitoring feature.

## Critical fixes
- Planka webhook: reads bounded raw body (was NameError on every call)
- HA quiet hours: ha_state_changed/automation_triggered/service_called/
  event_fired added to deferrable set (were silently dropped)
- DNS-rebinding SSRF: PinnedResolver wired into shared aiohttp session
- Telegram inbound webhook: secret now mandatory (401 without)
- Generic webhook: auth_mode="none" requires explicit
  acknowledge_unauthenticated=true; per-IP rate limit 60/min
- svelte-check: 5 null-narrowing errors in EventDetailModal fixed
- Provider hardcoding: Immich-only block extracted to descriptor
  featureDiscoveryHint
- command_sync: snapshot+expunge bot before exiting AsyncSession

## Bug fixes
- notifier asyncio.gather(return_exceptions=True) — one bad chat no longer
  cancels peer sends
- NotificationDispatcher hoisted out of per-tracker loop
- Provider credential resolution unified across all 5 dispatch sites
- HA asyncio.shield now drains inner task on cancellation
- Provider construction switched from if/elif ladder to factory registry
- NUT first poll seeds silently (no spurious ups_on_battery)
- Quiet-hours gate: event-type-disabled now wins over deferral
- APScheduler drain job ID resolution upgraded to seconds
- HA on_status_change wired through to EventLog
- Webhook payload rollback failures now logged (not swallowed)
- Batched receivers/chats/bots in load_link_data (was per-target N+1)
- flag_modified on JSON column reassignments in deferred_dispatch

## Database
- UNIQUE indexes on service_provider.webhook_token,
  telegram_bot.webhook_path_id, partial UNIQUE on telegram_bot.bot_id,
  telegram_chat(bot_id, chat_id), notification_tracker_target unique link,
  partial UNIQUE on bridge_self provider per user
- Composite ix_event_log_user_event_type_created index
- save_chat_from_webhook switched to ON CONFLICT DO UPDATE
- ondelete=CASCADE on user-id FKs (model annotation; app-side cascade
  delete added for existing data)
- delete_notification_tracker converted from N+1 to bulk DELETE/UPDATE
- Module-level asyncio.Lock replaced with lazy _get_lock() pattern
- VACUUM INTO snapshot now PRAGMA integrity_check verified

## Performance
- Jinja2 template compilation LRU cached (lru_cache maxsize=512)
- Per-locale render cache in NotificationDispatcher (skips re-rendering
  identical content for receivers sharing a locale)
- Tracker list cached per provider_id with 5s TTL + explicit invalidation
  on tracker CRUD (relieves HA chat-bus rate query pressure)
- Nav-counts collapsed from 16 round-trips to single UNION ALL
- HA event_log: skip persisting empty assets_added/removed events

## Security hardening
- Mass-assignment guard on Action create/update; cron sub-minute reject
- Backup JSON depth/node-count cap (depth ≤ 10, nodes ≤ 100k)
- _sanitize_config extended to all JSON-typed fields on backup import
- Telegram _safe_get walks redirects manually with SSRF revalidation
- Bcrypt 72-byte password length cap with clear 422
- Webhook payload body redaction; sensitive substring set extended with
  oauth/client_secret/webhook_secret/csrf in both header filter and
  template extras filter

## Frontend
- 76 catch (err: any) sites converted to errMsg(err) helper
- globalProviderFilter: pure getter; reconciliation moved to one-time
  $effect in +layout
- Provider-filter binding: removed paired $effects + _syncingFilter flag,
  now one-way derived
- entity-cache: separate _refreshing flag for background re-fetches
- api.ts 401 handling: AuthRedirectError class + dedup _redirecting flag,
  goto() instead of window.location.href
- a11y: aria-expanded on mobile More, role=switch + aria-checked on
  Telegram bot toggles

## Tests & operations
- CI pytest gate added to .gitea/workflows/build.yml + release.yml
  (wheel-built install to dodge editable-install slowness)
- /api/ready upgraded to deep healthcheck (db SELECT 1, scheduler.running,
  HA supervisor presence) returning {ready, checks, errors, version}
- /api/metrics endpoint with prometheus_client (deferred_pending,
  event_log_total, dispatch_duration, poll_failures, send_failures)
- New OPERATIONS.md covering deploy, healthchecks, metrics, backup/restore
  procedures, log handling, common scenarios, upgrade flow
- New tests: test_bridge_self (11), test_gitea_parser (9),
  test_planka_parser (6), test_immich_change_detector (6),
  test_backup_roundtrip (1)

## New feature: bridge self-monitoring
- New bridge_self provider type — internal sink for bridge health events
- Three event types: bridge_self_poll_failures (consecutive tracker poll
  failures), bridge_self_deferred_backlog (pending count crosses
  threshold), bridge_self_target_failures (consecutive 5xx/network
  failures per target)
- Per-user thresholds (defaults: 3 / 100 / 5) configurable via the
  provider config form
- Auto-seeded on user create + /setup + boot backfill for existing users
- Anti-spam: counters reset after emission; backlog uses transition latch
- Self-loop guard: bridge_self failures don't count toward target-failure
  thresholds (logged only) — wire to your own Telegram/Email/Matrix to
  get notified when polls/dispatches/sends fail
- 6 default templates (3 events × 2 locales), tracking config columns
  with backfill migration, frontend descriptor (excluded from "create
  provider" wizard since auto-managed)

Operator-visible behavior changes (call out in release notes):
- NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET now REQUIRED for webhook mode
- Existing webhook providers with auth_mode="none" need explicit opt-in
- Generic webhook endpoint rate-limited 60/min per source IP
- HA disconnect/reconnect writes ha_status_* EventLog rows
- Every user gets a bridge_self provider — wire it to a target to
  receive failure alerts

Pre-existing test failures (test_ssrf, test_release_provider) on
Python 3.13 are unrelated; CI runs on 3.12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:16:49 +03:00
alexei.dolgolyov 22127e2a59 feat: Home Assistant provider — WebSocket subscription + bot commands
Adds Home Assistant as a service provider with two coordinated surfaces:

Notifications (subscription):
- Long-lived WebSocket client (aiohttp ws_connect) with auth handshake,
  exponential-backoff reconnect, bounded event queue, and area-registry
  enrichment cached per (re)connect
- ServiceProvider ABC gains an optional `subscribe()` method for push-style
  providers; HomeAssistantServiceProvider uses it via a per-provider
  supervisor task started in the FastAPI lifespan
- 4 event types (state_changed, automation_triggered, call_service,
  event_fired), 4 default Jinja templates (en + ru), HA-specific
  tracker filters (entity_glob, domain_allowlist, exact entity ids)
- Extracted shared dispatch pipeline (api/webhooks.py → services/
  event_dispatch.py) so subscription and webhook ingest share the same
  event_log + deferred-dispatch + quiet-hours code path

Bot commands:
- /status, /entities [glob], /state <entity_id>, /areas
- Multi-command WS session so /status and /areas cost one handshake
- Sensitive-attribute blocklist (camera access_token, entity_picture, etc.)
  and 30-attribute cap to keep /state output safe and within Telegram's
  message size
- Error-message redaction strips URL userinfo before surfacing to chat

Frontend:
- HA descriptor with toggle ConfigField type (new) and tag-input filter
  mode for free-text glob/domain lists (new TagInput component)
- 15 command slots + 4 notification slots wired into the existing
  template-config UI
2026-05-13 14:31:56 +03:00
alexei.dolgolyov 90f958bdc6 fix(server): honor periodic_interval_days for Immich periodic summary
The APScheduler cron job fires daily at every entry in `periodic_times`,
and nothing in the dispatch path consulted `periodic_interval_days` or
`periodic_start_date` — so a configured 3-day interval still produced a
daily summary.

Gate the dispatch in `dispatch_scheduled_for_tracker` for kind=periodic
via `(today_in_app_tz - start_date).days % interval == 0`. Log a skip
with reason `interval_not_due` on non-firing days so operators can tell
suppressed-by-interval apart from other skip causes.
2026-05-13 12:14:18 +03:00
alexei.dolgolyov dec0839853 feat: on-watch stats scope selector (page vs all)
Adds an icon selector to the "On watch" provider deck letting users
choose between page-scoped stats (legacy) and full-corpus stats that
aggregate across every event matching the current filters. Backend
returns a new provider_event_counts map alongside the paginated events.
2026-05-12 14:12:59 +03:00
195 changed files with 10962 additions and 994 deletions
+177
View File
@@ -0,0 +1,177 @@
# Feature Backlog
Curated feature ideas, narrowed from a brainstorming pass on 2026-05-13.
Order is **rough sequencing preference**, not strict priority — adjust as we go.
---
## 1. Quiet Hours — close the gaps in the existing system
**Reality check (verified 2026-05-13).** Quiet hours are already shipped under
the "deferred dispatch" name in v0.8.0. The pipeline lives at
`packages/server/src/notify_bridge_server/services/deferred_dispatch.py` with
helpers in `dispatch_helpers.py` and tests in
`tests/test_deferred_dispatch.py`. What exists:
- Per-tracking-config window: `tracking_config.quiet_hours_enabled`,
`quiet_hours_start`, `quiet_hours_end`.
- Per-link override: `notification_tracker_target.quiet_hours_start`,
`quiet_hours_end`.
- Smart coalescing (asset add + asset remove during a window cancels each
other out, set-union merge for repeated adds).
- Post-window drain via APScheduler one-shot date jobs.
- Wall-clock event types (`scheduled_message`) drop instead of deferring.
- Frontend status surface: `deferred`, `deferred_then_dropped`,
`deferred_then_failed`, with `deferred_until` and `deferred_for_seconds`
fields exposed in the event log.
**What's NOT there (the actual gaps):**
| Gap | Sketch |
| --- | --- |
| **Target-level windows** | Today, hours bind to the *watcher* (tracking config / link). Users naturally think of DND at the *destination* ("don't ping my phone at night, regardless of source"). New column on `notification_target` + dispatcher gate. |
| **Multiple windows per row** | Today is a single HH:MM range. Real schedules want weekday-evening + weekend-all-day. JSON list of windows. |
| **Days-of-week** | Same window every day. Need `days: ["mon", "tue", ...]` filter per window. |
| **Per-window timezone** | Uses the global app TZ. Multi-traveller / multi-target setups want per-window TZ. |
| **Silent mode** | Modes today are defer-or-drop. Telegram `disable_notification=true` ("send but don't ring") is a third useful mode. |
| **Per-receiver windows** | One bot → multiple chats, each potentially with its own DND. Today it's all-or-nothing per target. |
**Recommended cut for v1 of "extend quiet hours":**
- Add target-level quiet hours (new column `notification_target.quiet_hours_json`
= list of `{days, start, end, mode, tz}`).
- Modes: `drop`, `defer`, `silent`. `defer` reuses the existing
deferred-dispatch pipeline (just changes who decides). `silent` maps to
`disable_notification=true` for Telegram; other targets fall through to
normal send (or we treat `silent` as `defer` for non-Telegram targets — TBD).
- Dispatcher precedence: target window wins over link/tracking-config window
when both are configured. Document this explicitly.
- Frontend: new "Quiet hours" fieldset in the target editor (Aurora cassette
style). Reuses Timezone picker; new day-picker chip row.
- Skip days-of-week + multi-window in v1 if scope grows — ship the target-level
cut first, then iterate.
**Open questions.**
- How exactly do target / link / tracking-config windows combine? Proposal:
any window covering "now" wins (drop > defer > silent precedence).
- Should `silent` for non-Telegram targets degrade to normal send or to
defer? Defer is the safer default.
- Does the event log need a new status (`silenced` / `dropped_by_target_qh`)
to make precedence visible?
---
## 2. Immich Smart Actions (expand beyond Auto-Organize)
**What.** Extend the existing Smart Actions pattern (currently:
**Immich Auto-Organize**) with more rule-driven actions against the Immich API.
**Why.** Auto-Organize already proves the descriptor → rule editor → executor
pipeline. Adding actions is mostly authoring new executors + small UI rule
shapes, not new infra.
**Candidates (pick in this order).**
1. **Auto-favorite by person** — when an asset is detected containing person
X (or any of a set), mark it favorite.
2. **Auto-archive by age / album** — assets older than N days in a given
album get archived. Pair with a "dry-run shows count" UX like
Auto-Organize already has.
3. **Duplicate cluster nudge** — periodically run Immich's duplicate API and
send a digest notification with inline buttons ("review", "ignore for 30d").
Depends on inline-button work (see backlog item 4 dependencies).
4. **Share-link rotation** — for an album, regenerate the share link every N
days; notify with the new URL.
5. **Pending-delete review** — push a weekly digest of trash contents before
Immich's auto-purge fires.
**Shape.**
- Reuse the existing **action descriptor** layer
(`packages/core/src/notify_bridge_core/providers/actions.py`,
`action_executor.py`) and the frontend rule editor used by Auto-Organize.
- Each new action = (a) executor in core, (b) rule schema in the descriptor,
(c) frontend descriptor extension for the rule editor fields.
- Persist as `provider_actions` rows (already exists for Auto-Organize) with
a discriminator + JSON config.
**Open questions.**
- Does "auto-favorite by person" need a confirmation queue or run silently?
Default to silent + event_log entry.
- How do we surface "this action moved/changed X assets" in the dashboard?
Probably a per-action stat tile on the provider detail page.
---
## 3. Home Assistant Provider
**Full plan:** [feature-home-assistant.md](feature-home-assistant.md).
**One-line summary.** New WebSocket-based service provider with a 3-phase
ship: subscribe + dispatch (Phase 1), bot commands (Phase 2), HA service
calls as Smart Actions (Phase 3). Chosen over webhook ingest because
Phases 2 + 3 force a long-lived API connection anyway; consolidating on WS
avoids a refactor.
**Status:** planned, not started.
---
## 4. Block-Based Template Builder
**What.** A visual, drag-and-drop builder for notification and command
templates that compiles down to Jinja2. Lives alongside (not instead of) the
current `JinjaEditor`. Author can flip between views.
**Why.** The current Jinja editor is powerful but unforgiving. A block UI
lowers the floor for new users and provides a discovery surface for the
variables documented in `template_configs.py`.
**Shape.**
- Frontend-only feature for v1 — compiles to the same Jinja strings the
backend already accepts.
- Blocks: `Text`, `Variable`, `If`, `For`, `Link`, `Image`, `Icon`, `Caption`,
`Group` (HTML span/group). Each block knows its serialized Jinja
representation.
- Round-trip: variables, simple `{% if %}` / `{% for %}` blocks, and string
literals parse back to blocks; arbitrary Jinja stays in a "Raw" block that
the user can edit as text.
- Variable picker reads `get_template_variables(provider_type, slot)`. This is
the same data already shown in the template-help panel.
- Preview pane unchanged — reuses `services/sample_context.py` server
rendering.
- Toggle in the template editor: **Visual / Code**.
**Open questions.**
- Round-tripping arbitrary Jinja is hard. v1: parseable subset → blocks,
anything else → single Raw block. Show a banner explaining.
- Locale handling: same compiled Jinja, just authored per locale tab.
- Do we want a marketplace of pre-built block compositions? Out of scope for
v1 — bundle import/export is a separate backlog item.
---
## Recommended Sequencing
1. **Quiet Hours per Target** — small, isolated, immediate user value.
2. **Immich Smart Actions** — incremental on existing pattern; ship one
action at a time (start with auto-favorite by person).
3. **Home Assistant Provider** — multi-file, follows new-provider checklist;
biggest user-base expansion.
4. **Block-Based Template Builder** — largest frontend lift; benefits from
the variable-doc work that the other features will exercise.
Dependencies are loose — 1 and 2 are independent of 3 and 4. The block
builder pairs nicely with Home Assistant because HA's rich context surfaces
the value of an easier authoring UX.
---
## Decision log
- **2026-05-13** — Backlog seeded with these four items selected from a
broader brainstorm. Not started.
+284
View File
@@ -0,0 +1,284 @@
# Home Assistant Provider — Implementation Plan
> Status: **planned, not started**. Sequencing: third item on the backlog
> (see [feature-backlog.md](feature-backlog.md)).
> Last updated: 2026-05-13.
## Decision: WebSocket subscription, not webhook
We considered three ingest modes (webhook automation, WebSocket subscription,
hybrid). The WebSocket route is chosen as the architectural foundation because
the medium-term roadmap forces it anyway:
| Phase | Capability | Needs API access? |
| --- | --- | --- |
| 1 | Subscribe to events, emit notifications | Read (event stream) |
| 2 | Bot commands (`/state`, `/entities`, `/areas`) | Read (REST or WS get_states) |
| 3 | Smart Actions (`light.turn_on`, scene activation) | Write (call_service) |
A webhook-only Phase 1 would still need a REST client by Phase 2 and a write
path by Phase 3 — net result is two client implementations + one event
pipeline refactor. WebSocket consolidates all three phases on one connection.
**Tradeoff (be honest):** WebSocket introduces a long-lived-connection pattern
this codebase does not have yet. Reconnect logic, missed-events-on-restart
gap, and a new shape on the `ServiceProvider` ABC are real costs. Phase 1 is
**not** shippable in one short session — plan for a multi-session build.
## Provider abstraction extension
The current `ServiceProvider` ABC
([packages/core/src/notify_bridge_core/providers/base.py](../../packages/core/src/notify_bridge_core/providers/base.py))
is poll-oriented: every provider implements `poll(collection_ids, state) →
(events, new_state)`. Webhook providers (Gitea, Planka, Webhook) satisfy this
by no-op'ing `poll` and shoving events in via `api/webhooks.py` instead.
Home Assistant fits neither cleanly. The plan:
1. Add an **optional** `async subscribe(emit) → None` method on the base ABC.
Default implementation raises `NotImplementedError`. Polling providers do
not override it. The scheduler / lifecycle layer (currently `services/watcher.py`)
gains a "subscription manager" branch that, for any provider whose class
overrides `subscribe`, starts a long-lived task instead of registering
a polling job.
2. `emit` is a callback `(event: ServiceEvent) → None` provided by the
subscription manager — it routes events to the dispatcher exactly like the
webhook handler does today. Keeping the dispatch path unchanged is the
point of this design.
3. Reconnect lives **inside** `subscribe`: the method is expected to be a
`while not cancelled: try connect; on drop, sleep with backoff, retry`
loop. The manager cancels the task on shutdown via the cooperative cancel
token used elsewhere.
This is a small, additive change to one ABC. No existing provider is
modified.
## Phase 1 — Subscribe + Dispatch (MVP)
### Scope
- Long-lived WebSocket connection to HA, authenticated with a long-lived
access token.
- Subscribe to the event bus with optional `event_type` filter (defaults to
`state_changed`).
- Translate HA events into `ServiceEvent` and dispatch via the existing
pipeline. Notifications go out exactly as they do today for any other
provider.
- Filter UI: entity-id glob list, domain allowlist (e.g. `light.*`,
`binary_sensor.*`), event-type allowlist. **Hard-required** to avoid the HA
firehose drowning the bridge.
- Connection test + entity listing via WS `get_states` (no REST client yet —
WS gives us both subscribe and read).
### Out of scope for Phase 1
- Bot commands → Phase 2.
- Service calls → Phase 3.
- Replay of events missed during disconnect (HA does not support this; we
document the gap and surface "reconnected after N seconds" in the event
log).
- Webhook-style ingestion (path-embedded token webhook receiver). If a user
prefers webhooks, we add it later as a second ingestion mode on the same
provider — out of scope for v1.
### Event types (v1)
| HA event | ServiceEvent type | Notification slot |
| --- | --- | --- |
| `state_changed` | `ha_state_changed` | `message_state_changed` |
| `automation_triggered` | `ha_automation_triggered` | `message_automation_triggered` |
| `call_service` | `ha_service_called` | `message_service_called` |
| (custom event types) | `ha_event_fired` | `message_event_fired` |
Default tracking config enables `state_changed` only — the others are loud
and opt-in.
### Context variables exposed to templates
Pulled directly from HA's `state_changed` payload, normalized:
- `entity_id``light.kitchen`
- `friendly_name``attributes.friendly_name` or fallback to `entity_id`
- `domain` — derived from `entity_id` before the dot
- `old_state``from_state.state`
- `new_state``to_state.state`
- `attributes` — dict of new-state attributes (raw)
- `device_class``attributes.device_class` if present
- `area``attributes.area_id` if present (best effort; only set if HA
exposes it via the area registry, which costs a `get_registry` WS call —
see "Open questions")
- `last_changed`, `last_updated` — ISO timestamps
- For non-`state_changed` events: `event_type`, `event_data` (full dict)
### File touch map (Phase 1)
**Core** (`packages/core/src/notify_bridge_core/`)
| Path | Action | Notes |
| --- | --- | --- |
| `providers/base.py` | Modify | Add optional `subscribe(emit)` ABC method (default `NotImplementedError`); add `HOME_ASSISTANT = "home_assistant"` to `ServiceProviderType` |
| `providers/capabilities.py` | Modify | Add `HOME_ASSISTANT_CAPABILITIES` + register |
| `providers/home_assistant/__init__.py` | Create | Export + register template variables |
| `providers/home_assistant/client.py` | Create | WebSocket client (auth, subscribe, get_states, call_service stub) |
| `providers/home_assistant/event_parser.py` | Create | HA event dict → `ServiceEvent` |
| `providers/home_assistant/provider.py` | Create | Class with `connect`, `disconnect`, `subscribe`, `list_collections` (entity list), `get_available_variables`, `get_provider_config_schema`, `test_connection`. `poll` raises NotImplementedError. |
| `templates/defaults/en/home_assistant_*.jinja2` | Create | 4 slot templates |
| `templates/defaults/ru/home_assistant_*.jinja2` | Create | 4 slot templates |
| `templates/defaults/loader.py` | Modify | Add to `PROVIDER_SLOT_FILE_MAP` |
| `templates/command_defaults/loader.py` | Modify | Stub entry — empty commands list for now |
| `templates/context.py` | Modify | HA context builder |
| `templates/validator.py` | Modify | Whitelist HA variable names |
**Server** (`packages/server/src/notify_bridge_server/`)
| Path | Action | Notes |
| --- | --- | --- |
| `services/watcher.py` *(or scheduler / lifecycle module that hosts polling)* | Modify | Add subscription-manager branch — for providers whose class overrides `subscribe`, start/stop long-running task instead of polling |
| `services/scheduler.py` | Verify | Confirm we cancel HA subscription on shutdown (graceful_shutdown_seconds path) |
| `api/template_configs.py` | Modify | `get_template_variables()` entry |
| `api/command_template_configs.py` | Modify | Sample ctx (minimal for Phase 1 — no commands) |
| `services/sample_context.py` | Modify | `_SAMPLE_CONTEXT["home_assistant"]` |
| `database/seeds.py` | Modify | Seed notification templates + default tracking config |
**Frontend** (`frontend/src/`)
| Path | Action | Notes |
| --- | --- | --- |
| `lib/providers/home-assistant.ts` | Create | Descriptor per CLAUDE.md rule 11 |
| `lib/providers/index.ts` | Modify | Register descriptor |
| `lib/locales/en.json` | Modify | `providers.typeHomeAssistant`, `gridDesc.providerHomeAssistant` |
| `lib/locales/ru.json` | Modify | Same |
**Tests**
| Path | Action |
| --- | --- |
| `packages/core/tests/providers/test_home_assistant_parser.py` | Create — HA payload → `ServiceEvent` |
| `packages/core/tests/providers/test_home_assistant_client.py` | Create — WS auth, subscribe, reconnect (use a fake server) |
| `packages/server/tests/test_home_assistant_subscription.py` | Create — subscription manager lifecycle, event flows through dispatcher |
### Frontend descriptor essentials
```text
type: "home_assistant"
defaultName: "Home Assistant"
icon: "home" (consider Lucide icon; HA logo if a custom asset exists)
hasUrl: true // base URL of HA (used to derive WS URL)
configFields:
- url: http(s)://homeassistant.local:8123
- access_token: long-lived access token (required)
- allowed_event_types: comma-separated, defaults to "state_changed"
eventFields: 4 checkboxes (state_changed, automation_triggered,
call_service, event_fired)
extraTrackingFields:
- entity_glob: tag input ("light.*", "binary_sensor.*_motion")
- domain_allowlist: tag input
collectionMeta: { label: "Entities", icon: "..." }
webhookBased: false // we are NOT webhook based
```
WS URL is derived: `wss://{host}/api/websocket` (or `ws://` for plain http
HA). Document this in the UI hint.
### Auth model
- **Long-lived access token** from HA (Profile → Long-Lived Access Tokens).
- Stored encrypted at rest via the same path the other providers use for
secrets (the bridge already has a secret-encryption helper — verify the
exact module name during implementation).
- WS auth handshake: connect → server sends `auth_required` → client sends
`{type: "auth", access_token: "..."}` → server replies `auth_ok` or
`auth_invalid`.
### Risks / open questions (Phase 1)
1. **Reconnect strategy.** Exponential backoff capped at 60s, jittered.
On reconnect, log a `connection_restored_after` event so the UI can
surface the gap. Document that HA does not support event replay.
2. **Area registry.** Pulling `area_id` for entities requires a separate
`config/area_registry/list` WS call. Decision needed: fetch once on
connect and cache, refetch on `area_registry_updated` event, or skip
`area` from the context entirely in v1. Recommendation: fetch on
connect, refetch on `area_registry_updated`, skip if it fails (best-effort).
3. **TLS verification for self-signed HA.** Homelab users often have
self-signed certs. Need a `verify_tls: bool` config field (default true)
and a clear warning when disabled. Same pattern as
`NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS` for the SSRF case.
4. **Backpressure.** HA's `state_changed` can fire hundreds of events per
minute in a busy install. The subscription manager must drop or coalesce
if the dispatcher backlog grows beyond a threshold. Cheapest cut: a
bounded `asyncio.Queue` between WS receiver and dispatch — `put_nowait`
with overflow counter visible in the event log.
5. **Entity filter precedence.** Tracking-config has `collection_ids`
(entity_id list) and we want `entity_glob` + `domain_allowlist`. Decision:
if both `collection_ids` and globs are set, union them (any match passes).
Documented prominently in the tracker UI.
6. **Library choice.** `hass-client` is a Python WS client maintained by the
HA community; alternative is rolling our own with `websockets`. The
latter is ~150 LOC and has no external dependency surface. Recommendation:
roll our own. Re-evaluate if Phase 3 needs registry-aware service calls.
## Phase 2 — Bot Commands
Adds Telegram bot commands for HA tracking configs.
- `/status` — connection status, subscribed event count
- `/entities <glob>` — list matching entities + current state
- `/state <entity_id>` — full state + attributes for one entity
- `/areas` — area registry summary
- `/help`
These use the existing WS connection (no new client) via WS commands
`get_states`, `config/area_registry/list`. Template slots and command
template configs follow the same pattern as Gitea/Planka — see
[CLAUDE.md](../../CLAUDE.md) rule 7 / rule 11 for the full set of locations
that must be updated.
Out-of-scope for Phase 2: any command that mutates HA state.
## Phase 3 — Smart Actions (Service Calls)
A new action descriptor in the existing Smart Actions framework
([packages/core/src/notify_bridge_core/providers/actions.py](../../packages/core/src/notify_bridge_core/providers/actions.py)).
- Action type: `ha_call_service`
- Rule: trigger event → service call (e.g. "on motion event in
`binary_sensor.front_door` → call `light.turn_on` on `light.porch`")
- Executor uses the existing WS connection to send `call_service`.
This phase is gated behind explicit per-target authorization in the UI — HA
service calls can do anything the access token allows, including unlocking
doors. Default state: **disabled**, with a clear consent flow when enabling.
## Rough effort estimates
These are rough — sub-task discovery during Phase 1 will refine them.
| Phase | Estimate (focused work) |
| --- | --- |
| Phase 1 (subscribe + dispatch) | 23 sessions |
| Phase 2 (bot commands) | 1 session |
| Phase 3 (smart actions) | 12 sessions |
## When to start
Phase 1 work order, once you green-light it:
1. ABC extension (`base.py`) + tests for the new `subscribe` shape on a fake
provider.
2. WS client + parser + unit tests against recorded HA fixtures (no live HA
needed for these).
3. Subscription manager in `services/watcher.py` — integration test with the
fake provider from step 1.
4. Templates (en + ru), capabilities entry, validator whitelist.
5. Server: seeds, sample context, template_configs entry.
6. Frontend: descriptor, locale keys, i18n.
7. End-to-end smoke test against a real HA instance (homelab).
Backend restart cadence per the project rule: after **every** change in
`packages/server/` or `packages/core/`.
## Decision log
- **2026-05-13** — Plan drafted. Ingest mode = WebSocket (chosen over
webhook for future-proofing toward Phases 2 + 3). Not started.
+27
View File
@@ -0,0 +1,27 @@
---
name: Debug Issue
description: Systematically debug issues using graph-powered code navigation
---
## Debug Issue
Use the knowledge graph to systematically trace and debug issues.
### Steps
1. Use `semantic_search_nodes` to find code related to the issue.
2. Use `query_graph` with `callers_of` and `callees_of` to trace call chains.
3. Use `get_flow` to see full execution paths through suspected areas.
4. Run `detect_changes` to check if recent changes caused the issue.
5. Use `get_impact_radius` on suspected files to see what else is affected.
### Tips
- Check both callers and callees to understand the full context.
- Look at affected flows to find the entry point that triggers the bug.
- Recent changes are the most common source of new issues.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+28
View File
@@ -0,0 +1,28 @@
---
name: Explore Codebase
description: Navigate and understand codebase structure using the knowledge graph
---
## Explore Codebase
Use the code-review-graph MCP tools to explore and understand the codebase.
### Steps
1. Run `list_graph_stats` to see overall codebase metrics.
2. Run `get_architecture_overview` for high-level community structure.
3. Use `list_communities` to find major modules, then `get_community` for details.
4. Use `semantic_search_nodes` to find specific functions or classes.
5. Use `query_graph` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships.
6. Use `list_flows` and `get_flow` to understand execution paths.
### Tips
- Start broad (stats, architecture) then narrow down to specific areas.
- Use `children_of` on a file to see all its functions and classes.
- Use `find_large_functions` to identify complex code.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+28
View File
@@ -0,0 +1,28 @@
---
name: Refactor Safely
description: Plan and execute safe refactoring using dependency analysis
---
## Refactor Safely
Use the knowledge graph to plan and execute refactoring with confidence.
### Steps
1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions.
2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.
3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations.
4. Use `apply_refactor_tool` with the refactor_id to apply renames.
5. After changes, run `detect_changes` to verify the refactoring impact.
### Safety Checks
- Always preview before applying (rename mode gives you an edit list).
- Check `get_impact_radius` before major refactors.
- Use `get_affected_flows` to ensure no critical paths are broken.
- Run `find_large_functions` to identify decomposition targets.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+29
View File
@@ -0,0 +1,29 @@
---
name: Review Changes
description: Perform a structured code review using change detection and impact
---
## Review Changes
Perform a thorough, risk-aware code review using the knowledge graph.
### Steps
1. Run `detect_changes` to get risk-scored change analysis.
2. Run `get_affected_flows` to find impacted execution paths.
3. For each high-risk function, run `query_graph` with pattern="tests_for" to check test coverage.
4. Run `get_impact_radius` to understand the blast radius.
5. For any untested changes, suggest specific test cases.
### Output Format
Provide findings grouped by risk level (high/medium/low) with:
- What changed and why it matters
- Test coverage status
- Suggested improvements
- Overall merge recommendation
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+44 -2
View File
@@ -29,16 +29,58 @@ jobs:
- name: Svelte check
run: |
cd frontend
npm run check || echo "::warning::svelte-check reported warnings"
npm run check
- name: Build
run: |
cd frontend
npm run build
test-backend:
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
# Editable installs of packages/core + packages/server are extremely slow
# on the hosted runner — measured 4-6x slower than building wheels first
# because hatchling's editable hook re-resolves on every collection. We
# build wheels once into an isolated venv, then install them (and only
# the test deps). The venv isolation also prevents broken-wheel installs
# from leaking dist-info across runs on the persistent Gitea runner
# (pip can't uninstall a wheel that landed without a RECORD file). The
# wheels themselves are NOT cached because their hashes depend on every
# file under packages/ — invalidates on basically every PR. Pip's HTTP
# cache for the test deps is enough.
- name: Build wheels in isolated venv
run: |
python -m venv /tmp/venv
/tmp/venv/bin/pip install --upgrade pip build
mkdir -p /tmp/wheels
/tmp/venv/bin/pip wheel --no-deps -w /tmp/wheels packages/core packages/server
- name: Install backend + test deps
run: |
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses prometheus_client
- name: Run pytest
env:
NOTIFY_BRIDGE_DATA_DIR: /tmp/nb-test-data
NOTIFY_BRIDGE_SECRET_KEY: ci-secret-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTIFY_BRIDGE_DEBUG: "false"
NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS: "http://localhost:8420"
run: |
cd packages/server
/tmp/venv/bin/pytest tests --tb=short
build-image:
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
needs: [test-frontend]
needs: [test-frontend, test-backend]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+36
View File
@@ -10,7 +10,43 @@ env:
IMAGE_NAME: alexei.dolgolyov/notify-bridge
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
# Wheel-first strategy in an isolated venv — editable install is too slow,
# and a plain pip install into the toolcache Python leaks state across
# runs on the persistent Gitea runner (previous broken wheel installs
# leave dist-info dirs that pip can't uninstall: "uninstall-no-record-file").
- name: Build wheels in isolated venv
run: |
python -m venv /tmp/venv
/tmp/venv/bin/pip install --upgrade pip build
mkdir -p /tmp/wheels
/tmp/venv/bin/pip wheel --no-deps -w /tmp/wheels packages/core packages/server
- name: Install backend + test deps
run: |
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses prometheus_client
- name: Run pytest
env:
NOTIFY_BRIDGE_DATA_DIR: /tmp/nb-test-data
NOTIFY_BRIDGE_SECRET_KEY: ci-secret-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTIFY_BRIDGE_DEBUG: "false"
NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS: "http://localhost:8420"
run: |
cd packages/server
/tmp/venv/bin/pytest tests --tb=short
release:
needs: [test-backend]
runs-on: ubuntu-latest
steps:
- name: Checkout repo
+394
View File
@@ -0,0 +1,394 @@
# Operations Guide
This document covers running, monitoring, and recovering Notify Bridge in
production. The intended audience is the operator on call when the
notifications stop firing or when a release upgrade goes sideways.
For developer-focused docs (architecture, conventions, project layout) see
`CLAUDE.md` and the `.claude/docs/` directory.
## Deployment overview
Notify Bridge ships as a single Docker image. All state lives in a single
data directory mounted at `/data`.
### Required environment variables
| Variable | Default | Notes |
| --- | --- | --- |
| `NOTIFY_BRIDGE_SECRET_KEY` | _(none)_ | **Required.** 32+ random bytes. The server refuses to boot with the default placeholder or any of the known dev literals. |
| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | `http://localhost:5175` | Comma-separated list. `*` is rejected because credentials are enabled. |
| `NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS` | `127.0.0.1` | Trusted proxy IPs whose `X-Forwarded-For` / `X-Forwarded-Proto` headers are honored. Set to your reverse-proxy IP. |
### Useful environment variables
| Variable | Default | Notes |
| --- | --- | --- |
| `NOTIFY_BRIDGE_DATA_DIR` | `/data` | Where the SQLite DB, snapshots, and backups live. |
| `NOTIFY_BRIDGE_DATABASE_URL` | _(derived from data_dir)_ | Override only if you want a non-default DB path. |
| `NOTIFY_BRIDGE_DEBUG` | `false` | Verbose logging + SQL echo. Do not enable in production. |
| `NOTIFY_BRIDGE_LOG_FORMAT` | `text` | Set to `json` for one JSON object per line — pipe to a log aggregator. |
| `NOTIFY_BRIDGE_LOG_LEVEL` | `INFO` | Root logger level. |
| `NOTIFY_BRIDGE_LOG_LEVELS` | _(empty)_ | Per-module overrides, e.g. `sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG`. |
| `NOTIFY_BRIDGE_EVENT_LOG_RETENTION_DAYS` | `30` | Days of `event_log` history kept by the daily cleanup job. `0` disables retention. |
| `NOTIFY_BRIDGE_PRE_MIGRATE_SNAPSHOT_KEEP` | `5` | Number of pre-migration DB snapshots retained. `0` disables snapshotting. |
| `NOTIFY_BRIDGE_METRICS_ENABLED` | `true` | Expose `/api/metrics` for Prometheus. Set to `false` if the API port crosses a trust boundary. |
| `NOTIFY_BRIDGE_GRACEFUL_SHUTDOWN_SECONDS` | `60` | SIGTERM grace period before in-flight requests are killed. |
| `NOTIFY_BRIDGE_SUPERVISED` | _(auto)_ | Force the supervised flag for `apply-restart`. Use `true` when running under systemd/PM2 outside Docker. |
### Data directory layout
```
/data/
notify_bridge.db # main SQLite DB (WAL mode)
notify_bridge.db-wal # SQLite write-ahead log
notify_bridge.db-shm # SQLite shared memory file
backups/
pre-migrate-*.db # automatic pre-upgrade snapshots
backup-*.json # scheduled / manual config backups
snapshots/ # legacy alias retained for older deployments
pending_restore.json # staged restore (consumed at next boot)
applied_restores/ # archive of applied restore payloads
```
Always mount `/data` on a persistent volume. The WAL files MUST live on the
same filesystem as the main DB — never split them across mounts.
### Docker example
See `docker-compose.yml` at the repo root for the canonical reference. The
container runs read-only with `tmpfs` for `/tmp`, drops all capabilities,
and limits memory/CPU. The healthcheck targets `/api/ready` (deep) — see
the next section.
## Healthchecks
Two endpoints, used for different probe types.
### `GET /api/health` — liveness, shallow
Returns `200 OK` once the ASGI app has started. Does not touch the DB or
the scheduler. Use this for liveness probes that should only restart the
process if it stops responding entirely.
```json
{"status": "ok", "version": "0.8.0"}
```
### `GET /api/ready` — readiness, deep
Verifies that each critical dependency is reachable:
* **db** — `SELECT 1` against the SQLAlchemy engine, 2-second timeout.
* **scheduler** — APScheduler `running` flag.
* **ha** — Home Assistant subscription supervisor task. Reported as
`na` when no HA providers are configured, `ok` when at least one
supervisor is alive, `degraded` otherwise. **Informational only**
HA degradation does not flip readiness off.
Returns `503` when any required check (db, scheduler) fails.
```json
{
"ready": true,
"checks": {"db": "ok", "scheduler": "ok", "ha": "na"},
"errors": [],
"version": "0.8.0"
}
```
### Kubernetes probe example
```yaml
livenessProbe:
httpGet:
path: /api/health
port: 8420
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/ready
port: 8420
initialDelaySeconds: 15
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 2
```
The Docker compose file uses `/api/ready` as its healthcheck so the
container is only reported healthy after migrations finish.
## Metrics
Notify Bridge exposes Prometheus metrics at `GET /api/metrics` in the
standard text exposition format. **No authentication** — Prometheus
scrapers do not authenticate. Disable via `NOTIFY_BRIDGE_METRICS_ENABLED=false`
when the API port is reachable beyond the trust boundary.
### Prometheus scrape example
```yaml
scrape_configs:
- job_name: notify-bridge
metrics_path: /api/metrics
static_configs:
- targets: ['notify-bridge.internal:8420']
scrape_interval: 30s
```
### Available metrics
| Metric | Type | Labels | Meaning |
| --- | --- | --- | --- |
| `notify_bridge_deferred_pending` | Gauge | _(none)_ | Pending rows in `deferred_dispatch`. Refreshed on each scrape. A persistent non-zero value usually means a tracker target is in extended quiet hours. |
| `notify_bridge_event_log_total` | Counter | `status`, `event_type` | Events written to `event_log`. `status` is the dispatch outcome (`dispatched`, `dropped`, `deferred`, etc.). |
| `notify_bridge_dispatch_duration_seconds` | Histogram | `channel` | Wall-clock duration of one outbound dispatch (Telegram, Discord, email, …). Useful for latency alerts. |
| `notify_bridge_provider_poll_failures_total` | Counter | `provider_type` | Polling provider tick failures (Immich poll error, Gitea API down, …). Compare against expected scan interval to compute failure rate. |
| `notify_bridge_target_send_failures_total` | Counter | `target_type`, `status_code` | Failed sends to a notification channel. `status_code` is the HTTP status (or `0` when no HTTP response was received). |
The metrics module never imports `prometheus_client` outside `api/metrics.py`.
Other modules record events through the `metrics` singleton — see that
module's docstring before adding new collectors.
## Backups
Notify Bridge produces three different kinds of backup files. Know which
one you are looking at before restoring.
| Kind | Location | Format | Trigger |
| --- | --- | --- | --- |
| Config backup | `data/backups/backup-*.json` | JSON (BackupFile schema) | Manual via `/api/backup/files` POST or scheduled job |
| Pre-migration snapshot | `data/backups/pre-migrate-*.db` | SQLite DB file | Automatic on every boot before migrations |
| Pending restore | `data/pending_restore.json` | JSON | Staged via `/api/backup/prepare-restore`, consumed at next restart |
Config backups capture user configuration (providers, trackers, targets,
templates, …). They do **not** include `event_log`, `deferred_dispatch`,
or any other operational table. Pre-migration snapshots are full DB
copies and contain everything.
### Manual backup
The admin UI has a one-click button under Settings → Backup. Equivalent
HTTP call:
```bash
curl -fsS -X POST \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/files?secrets_mode=exclude"
```
The download endpoint produces a downloadable JSON envelope with no
secrets unless `secrets_mode=include` is passed:
```bash
curl -fsS -X GET \
-H "Authorization: Bearer $ADMIN_JWT" \
-OJ "https://notify-bridge.example.com/api/backup/export?secrets_mode=exclude"
```
### Scheduled backup
Configure under Settings → Backup or via `PUT /api/backup/scheduled` with:
```json
{
"backup_scheduled_enabled": "true",
"backup_scheduled_interval_hours": "24",
"backup_secrets_mode": "exclude",
"backup_retention_count": "5"
}
```
Saved files land in `data/backups/`; retention prunes the oldest files
beyond `backup_retention_count`. Backups can be downloaded individually:
```bash
curl -fsS -X GET \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/files/backup-2026-05-16T12-00-00.json" \
-o backup-latest.json
```
### Cron snippet for off-host backup
```bash
# /etc/cron.d/notify-bridge-backup
0 3 * * * www-data \
curl -fsS -X POST \
-H "Authorization: Bearer $(cat /etc/notify-bridge/admin.token)" \
"https://notify-bridge.example.com/api/backup/files?secrets_mode=exclude" \
-o /var/backups/notify-bridge/backup-$(date +\%F).json
```
### Restore procedure
Restoring REPLACES configuration. Always export the current state first.
```bash
# 1. Stage the backup file (validates and writes to data/pending_restore.json)
curl -fsS -X POST \
-H "Authorization: Bearer $ADMIN_JWT" \
-F "file=@backup-2026-05-16T12-00-00.json" \
"https://notify-bridge.example.com/api/backup/prepare-restore?conflict_mode=overwrite"
# 2. Trigger graceful restart so startup applies the staged restore.
# Same-origin Origin/Referer is enforced — call from the admin UI when
# possible, or from the same host. Requires the supervisor to respawn
# the process (Docker restart policy, systemd, PM2, etc.).
curl -fsS -X POST \
-H "Origin: https://notify-bridge.example.com" \
-H "Referer: https://notify-bridge.example.com/settings/backup" \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/apply-restart"
```
If the process is **not** supervised, `/api/backup/apply-restart` returns
`409`. Restart the backend manually after staging — startup applies the
pending restore on the next boot.
To cancel a staged restore before applying:
```bash
curl -fsS -X DELETE \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/pending-restore"
```
### Recovery from a corrupted DB
If migrations crash on boot or the DB file is unreadable, roll back to a
pre-migration snapshot:
```bash
# Stop the backend, then
cd /var/lib/docker/volumes/notify-bridge-data/_data
ls -1t backups/pre-migrate-*.db | head -5 # pick the snapshot
cp notify_bridge.db notify_bridge.db.broken # keep the broken DB for forensics
cp backups/pre-migrate-2026-05-16T11-58-30.db notify_bridge.db
rm -f notify_bridge.db-wal notify_bridge.db-shm # WAL belongs to the broken file
```
Restart the container. The startup snapshot will run again and capture
the rolled-back state, so you have a clean recovery point if the next
boot needs another rollback.
## Logs
* Output goes to **stderr only**. The Docker log driver captures it.
* Set `NOTIFY_BRIDGE_LOG_FORMAT=json` for line-delimited JSON suitable
for Loki, ELK, or CloudWatch.
* Secret values (bot tokens, API keys, passwords) are masked at the log
formatter level — see `notify_bridge_server.logging_setup`.
* No file rotation is built in. Use the Docker JSON log driver's
`max-size`/`max-file` options or send logs to your aggregator.
```yaml
# docker-compose.yml snippet
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
```
## Common operational scenarios
### "Notifications stopped firing"
1. Hit `/api/ready`. If `scheduler` is `fail`, restart the backend; the
scheduler died in a way it cannot recover from.
2. Check `notify_bridge_deferred_pending`. A non-zero value during quiet
hours is normal; a value that grows monotonically across days is a
bug — inspect the `deferred_dispatch` table.
3. Inspect the most recent `event_log` rows in the admin Events page or:
```sql
SELECT created_at, event_type, dispatch_status, details
FROM event_log
ORDER BY created_at DESC LIMIT 50;
```
Look for a `dispatch_status` other than `dispatched`.
4. If a single tracker is silent, verify the provider's last poll status
in the admin UI (Providers page) — `notify_bridge_provider_poll_failures_total`
tells you which provider type is failing.
5. If you've configured a `bridge_self` tracker but never received a
self-monitoring alert when something failed, see the next section —
`bridge_self` failures are deliberately log-only to prevent recursion.
### Bridge self-monitoring is log-only on its own failures
The built-in `bridge_self` provider emits notifications when polls,
dispatches, or target sends fail. To prevent infinite-recursion (a
`bridge_self` notification failing → triggering another `bridge_self`
notification → ...), failures of `bridge_self` events themselves are
**not** counted toward target-failure thresholds and are logged only.
If your `bridge_self` notifications stop arriving, it means the
notification target you wired them to is itself failing. Grep stderr for:
```text
bridge_self target-failure emission failed
emit_bridge_self_event failed
```
The fix is always at the target layer (Telegram bot blocked, Matrix
homeserver down, SMTP credentials rotated). The bridge cannot tell you
about its own outbound failure — that's what the operator's external
monitoring (Prometheus alert on `notify_bridge_target_send_failures_total`)
is for.
### "Webhook returns 500"
Inspect the `webhook_payload_log` table for the matching request:
```sql
SELECT received_at, status_code, error_message, payload_excerpt
FROM webhook_payload_log
ORDER BY received_at DESC LIMIT 20;
```
Common causes: payload schema change in the source service, a tracker
referencing a deleted provider, a Jinja template that errors out (look
for `template render failed` in logs).
### "Telegram bot rate-limited (429)"
The Telegram client implements exponential backoff with jitter on
`Retry-After`. No operator action is required for transient throttling.
If the rate-limit persists, check:
* The bot is being driven by multiple Notify Bridge instances pointing
at the same chat (split-brain — only one instance should own a bot).
* A template is producing very large messages (Telegram limits message
size to 4096 chars). Look for `MessageTooLong` in the logs.
### "DB lock contention"
SQLite WAL mode and `busy_timeout=10000` make this rare. If you see
`SQLITE_BUSY` in logs:
* Check for long-running transactions (most often a stuck migration).
* Confirm the WAL files are on the same filesystem as the main DB —
splitting them across mounts is a known cause.
* Run `sqlite3 notify_bridge.db "PRAGMA wal_checkpoint(TRUNCATE);"` to
flush the WAL. Safe to run while the backend is up.
## Upgrades
1. Pre-migration snapshot is taken automatically before any migration
runs. The latest five snapshots are retained by default.
2. Migrations are idempotent — re-running an upgrade is safe.
3. If a migration fails, the snapshot from step 1 is the recovery point.
See "Recovery from a corrupted DB" above.
4. Always test major version upgrades in staging first. The upgrade flow
is the same in staging: pull the new image, restart the container.
The release tag stream lives at the project Gitea / GitHub releases page.
Release notes are written to `RELEASE_NOTES.md` for the upcoming version
and copied into the Gitea release body by the `release.yml` workflow.
+90 -15
View File
@@ -1,16 +1,53 @@
# v0.8.0 (2026-05-12)
# v0.8.1 (2026-05-16)
## ⚠️ Breaking Changes
- **Telegram webhook secret is now mandatory.** `NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET` must be set when running Telegram bots in webhook mode — the inbound endpoint returns 401 without it. Polling-mode bots are unaffected ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Generic webhooks with `auth_mode="none"` require explicit opt-in.** Existing unauthenticated webhook providers must set `acknowledge_unauthenticated=true` in their config or they will be rejected. Generic webhook ingest is also rate-limited to 60 requests/min per source IP ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Every user now gets a `bridge_self` provider auto-seeded.** It is internal-only and excluded from the "create provider" wizard, but appears in the provider list. Wire it to a Telegram/Email/Matrix target to receive bridge health alerts ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
## User-facing changes
### Features
- **Quiet hours now defer notifications instead of dropping them.** Events that arrive during a tracker's quiet window are stored on disk and re-fired at the window end. Asset events for the same `(link, event_type, collection)` coalesce so a flurry of adds/removes during the night collapses into a single morning notification ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- **Upstream release check.** New "Release Cassette" in Settings polls a configurable Gitea or GitHub repo on a schedule and surfaces the latest tag in the UI so you know when a newer Notify Bridge is available. Pre-release filtering and interval are operator-configurable; the install ships pointed at this repo's own upstream ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- **Frontend polish across the board.** New `MetaStrip` component, expanded `EventDetailModal`, and i18n additions land alongside cohesive Aurora-glass styling tweaks on most management pages — providers, targets, bots, trackers, command and notification templates, users, actions, layout, and dashboard ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- **Home Assistant provider.** New service provider that subscribes to a Home Assistant instance over WebSocket (long-lived connection with auth handshake, exponential-backoff reconnect, area-registry enrichment) and emits 4 event types: `state_changed`, `automation_triggered`, `call_service`, `event_fired`. Trackers support entity-glob, domain-allowlist, and exact-id filters via the new `TagInput` UI control ([22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2))
- **Home Assistant bot commands.** `/status`, `/entities [glob]`, `/state <entity_id>`, `/areas` — query your HA instance from chat. Multi-command WS sessions reuse a single handshake; sensitive attributes (camera access tokens, entity pictures, etc.) are blocklisted and `/state` output is capped at 30 attributes to stay within Telegram message limits ([22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2))
- **Bridge self-monitoring (`bridge_self` provider).** A new internal provider type emits health events from the bridge itself: `bridge_self_poll_failures` (consecutive tracker poll failures), `bridge_self_deferred_backlog` (pending defers above threshold), `bridge_self_target_failures` (consecutive 5xx/network failures per target). Per-user thresholds default to 3 / 100 / 5 and are configurable. Self-loop guard ensures bridge_self failures never count toward target-failure thresholds ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **bridge_self bot commands.** `/status`, `/thresholds`, `/reset`, `/health` let operators inspect bridge health and reset counters from chat. Includes Jinja2 templates for both locales ([8651767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8651767))
- **On-watch stats scope selector.** New icon toggle on the "On watch" provider deck switches between page-scoped stats (legacy) and full-corpus stats that aggregate across every event matching the active filters ([dec0839](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/dec0839))
### Documentation
### Bug Fixes
- README rewritten to cover every supported provider, target type, bot command, and smart action — including the deploy / env-var matrix ([bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc), [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036))
- **Immich periodic summary now honors `periodic_interval_days`.** A configured 3-day interval was firing every day because the dispatch path never consulted the interval or start date. The scheduler now gates on `(today - start_date).days % interval == 0` and logs `interval_not_due` skips so operators can distinguish suppressed-by-interval from other skip causes ([90f958b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/90f958b))
- **Planka webhook crash fixed.** The handler had a `NameError` on every call when reading the request body — webhook ingest from Planka now works ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **HA quiet-hours dropped events.** `ha_state_changed`, `automation_triggered`, `service_called`, and `event_fired` were missing from the deferrable set and were silently dropped during quiet windows. They now defer like every other event type ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Notifier no longer cancels peer sends on a single failure.** Switched the per-receiver fan-out to `asyncio.gather(return_exceptions=True)`; one bad chat won't cancel sends to the rest ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Quiet-hours gate now respects event-type-disabled.** When a tracker has the event type disabled, that wins over the deferral path — events are dropped, not stored to be drained later ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **NUT first poll seeds silently.** No more spurious `ups_on_battery` notification on the very first poll after adding a NUT provider ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **HA disconnect/reconnect now logged.** Status changes write `ha_status_*` rows to the EventLog so operators can see WS supervisor health in the UI ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
### Performance
- **Jinja2 template compilation cached** with `lru_cache(maxsize=512)` — repeated renders of the same template no longer reparse the source ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Per-locale render cache in `NotificationDispatcher`** skips re-rendering identical content for receivers sharing a locale ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Tracker list cached per `provider_id`** with a 5s TTL plus explicit invalidation on tracker CRUD — relieves the HA chat-bus rate-query pressure ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Nav-counts collapsed from 16 round-trips to a single `UNION ALL`** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **HA event_log skips empty events** — `assets_added`/`assets_removed` events with empty payloads are no longer persisted ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
### Security
- **DNS-rebinding SSRF protection.** `PinnedResolver` is now wired into the shared aiohttp session — outbound URL validation pins the resolved IP for the lifetime of the request ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Mass-assignment guards** added on Action create/update; cron expressions with sub-minute granularity are rejected ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Backup import hardening.** JSON depth capped at 10, node count at 100k; `_sanitize_config` now extends to all JSON-typed fields on import ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Telegram `_safe_get` walks redirects manually** with SSRF revalidation at every hop ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Bcrypt 72-byte password length cap** with a clear `422` response (was silently truncating before) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Webhook payload body redaction.** Sensitive substring set extended with `oauth`, `client_secret`, `webhook_secret`, `csrf` in both header filter and template-extras filter ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
### Operations
- **Deep healthcheck at `/api/ready`** — checks DB `SELECT 1`, scheduler running, HA supervisor presence; returns `{ready, checks, errors, version}` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Prometheus metrics at `/api/metrics`** — `deferred_pending`, `event_log_total`, `dispatch_duration`, `poll_failures`, `send_failures` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **New `OPERATIONS.md`** covering deploy, healthchecks, metrics, backup/restore procedures, log handling, common scenarios, and upgrade flow ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
---
@@ -18,14 +55,50 @@
### Architecture
- New `deferred_dispatch` table with two migrations: an `ON DELETE SET NULL` FK rebuild on `event_log_id` (so the daily event-log retention sweep no longer deadlocks against pending defers), and a partial unique index on `(link_id, collection_id, event_type) WHERE status='pending'` to make coalescing race-safe under SQLite's serializable writes ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Drain scheduler with three layers: a one-shot APScheduler `date` job per window-end (idempotent, minute-bucketed), a 5-minute periodic catch-up scan as safety net for misfire-grace overflow and process-restart gaps, and `load_pending_drain_jobs` to re-arm scheduled drains on boot ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Release-check provider abstraction (`packages/core/.../release/`) with Gitea and GitHub adapters, SSRF-safe outbound URL validation, a registry/factory, and a server-side scheduler probe with cached state and on-settings-change cache invalidation ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Version resolution helper (`packages/server/.../version.py`) that returns the max of installed-package metadata vs source `pyproject.toml` — fixes the long-running editable-install bug where bumping the version without reinstalling kept the old number visible in the UI ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- **Shared dispatch pipeline.** Extracted webhook ingest's event-log + deferred-dispatch + quiet-hours code path from `api/webhooks.py` into `services/event_dispatch.py` so HA subscription and webhook ingest now share the same pipeline ([22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2))
- **`ServiceProvider` ABC gains optional `subscribe()`** for push-style providers; `HomeAssistantServiceProvider` uses it via a per-provider supervisor task started in the FastAPI lifespan ([22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2))
- **Provider construction switched from if/elif ladder to factory registry** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Provider credential resolution unified** across all 5 dispatch sites ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **`NotificationDispatcher` hoisted out of the per-tracker loop** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
### Database
- **New UNIQUE indexes:** `service_provider.webhook_token`, `telegram_bot.webhook_path_id`, partial UNIQUE on `telegram_bot.bot_id`, `telegram_chat(bot_id, chat_id)`, `notification_tracker_target` unique link, partial UNIQUE on `bridge_self` provider per user ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Composite `ix_event_log_user_event_type_created` index** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **`save_chat_from_webhook` switched to `ON CONFLICT DO UPDATE`** (was racy under concurrent webhook delivery) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **`ondelete=CASCADE` on user-id FKs** (model annotation; app-side cascade delete added for existing data) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **`delete_notification_tracker` converted from N+1 to bulk DELETE/UPDATE** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Module-level `asyncio.Lock` replaced with lazy `_get_lock()` pattern** (avoids cross-event-loop binding) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **VACUUM INTO snapshot now `PRAGMA integrity_check`-verified** before being returned ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Batched `receivers/chats/bots` in `load_link_data`** (was per-target N+1) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **`flag_modified` on JSON column reassignments** in deferred_dispatch ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
### Frontend
- **76 `catch (err: any)` sites converted to `errMsg(err)` helper** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **`globalProviderFilter` made a pure getter**; reconciliation moved to a one-time `$effect` in `+layout` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Provider-filter binding simplified** — paired `$effect`s and the `_syncingFilter` flag removed; now a one-way derived value ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **`entity-cache` got a separate `_refreshing` flag** for background re-fetches so loading spinners don't appear on revalidation ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **`api.ts` 401 handling rewritten:** `AuthRedirectError` class + dedup `_redirecting` flag, `goto()` instead of `window.location.href` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Accessibility:** `aria-expanded` on mobile More menu, `role=switch` + `aria-checked` on Telegram bot toggles ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Provider-specific hardcoding removed:** Immich-only block extracted to descriptor `featureDiscoveryHint` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **5 `svelte-check` null-narrowing errors fixed** in `EventDetailModal` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **New `TagInput` component** for free-text glob/domain lists; new toggle `ConfigField` type for HA descriptor ([22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2))
### CI/Build
- **CI pytest gate added** to `.gitea/workflows/build.yml` and `release.yml` (wheel-built install to dodge editable-install slowness on the hosted runner) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
### Tests
- New test suites: `test_deferred_dispatch.py` (drain + coalescing + retention interaction), `test_release_provider.py` (Gitea and GitHub adapter parsing and error paths), and `test_release_service.py` (scheduler-level caching and settings invalidation) ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- **New test suites:** `test_bridge_self` (11), `test_gitea_parser` (9), `test_planka_parser` (6), `test_immich_change_detector` (6), `test_backup_roundtrip` (1) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
### Other
- **`command_sync` snapshot+expunge bot before exiting `AsyncSession`** (was raising on detached-instance access) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **HA `asyncio.shield` now drains inner task on cancellation** (was leaking tasks on supervisor restart) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **APScheduler drain job ID resolution upgraded to seconds** (was minute-bucketed; collisions possible) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
- **Webhook payload rollback failures now logged** (were swallowed) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
---
@@ -33,9 +106,11 @@
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2) | feat: deferred dispatch, release-check provider, settings polish | alexei.dolgolyov |
| [bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc) | docs: expand README with all providers, targets, bot commands, and smart actions | alexei.dolgolyov |
| [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036) | docs: sync README deploy section with actual env vars | alexei.dolgolyov |
| ---- | ------- | ------ |
| [8651767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8651767) | feat: bridge_self bot commands — status, thresholds, reset, health | alexei.dolgolyov |
| [10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc) | feat: production readiness — security, perf, bug fixes, bridge self-monitoring | alexei.dolgolyov |
| [22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2) | feat: Home Assistant provider — WebSocket subscription + bot commands | alexei.dolgolyov |
| [90f958b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/90f958b) | fix(server): honor periodic_interval_days for Immich periodic summary | alexei.dolgolyov |
| [dec0839](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/dec0839) | feat: on-watch stats scope selector (page vs all) | alexei.dolgolyov |
</details>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.8.0",
"version": "0.8.1",
"type": "module",
"scripts": {
"dev": "vite dev",
+49
View File
@@ -505,3 +505,52 @@ button:focus-visible, a:focus-visible {
scroll-behavior: auto !important;
}
}
/* Shared toggle switch — used by provider config forms, tracking-config
extraTrackingFields, and anywhere else we render a boolean field.
Kept global so adding a new ConfigField type='toggle' caller doesn't
need to copy the CSS into its scoped <style>. */
.toggle-switch {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
height: 1.75rem;
}
.toggle-switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch .toggle-track {
position: relative;
width: 2.5rem;
height: 1.375rem;
background: var(--color-border);
border-radius: 9999px;
transition: background 0.2s ease;
}
.toggle-switch .toggle-track::after {
content: '';
position: absolute;
top: 0.1875rem;
left: 0.1875rem;
width: 1rem;
height: 1rem;
background: var(--color-foreground);
border-radius: 50%;
transition: transform 0.2s ease;
}
.toggle-switch input:checked + .toggle-track {
background: var(--color-primary);
}
.toggle-switch input:checked + .toggle-track::after {
transform: translateX(1.125rem);
background: var(--color-primary-foreground);
}
+40 -8
View File
@@ -2,8 +2,41 @@
* API client with JWT auth for the Notify Bridge backend.
*/
import { goto } from '$app/navigation';
const API_BASE = '/api';
/**
* Thrown when the API client decides to redirect the user to /login (after a
* terminal 401). Caller-side `try/catch` blocks can branch on
* `instanceof AuthRedirectError` to skip showing an "Unauthorized" snackbar
* — the redirect itself is the user-visible signal.
*/
export class AuthRedirectError extends Error {
constructor() {
super('Unauthorized — redirecting to login');
this.name = 'AuthRedirectError';
}
}
// Module-level dedupe — a burst of concurrent requests that all get 401 (e.g.
// the dashboard's parallel cache loads) should only schedule a single
// `goto('/login')` instead of stacking N navigations.
let _redirecting = false;
/** Centralised "send the user to /login" path used by both api() and fetchAuth(). */
function redirectToLogin(): void {
if (_redirecting) return;
_redirecting = true;
clearTokens();
if (typeof window !== 'undefined') {
// SvelteKit's goto() with replaceState avoids leaving the failed page
// in the back-stack (no "back-button to broken view" UX). We don't
// reset `_redirecting` — the page about to unmount makes it moot.
goto('/login', { replaceState: true });
}
}
/** Normalize a caught error to a user-safe message. */
export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
if (err instanceof Error && err.message) return err.message;
@@ -129,11 +162,11 @@ export async function api<T = any>(
}
if (res.status === 401 && token) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Unauthorized');
redirectToLogin();
// Tagged so the caller's catch can distinguish "we already showed
// the user a redirect" from a real authorization failure they
// should snackbar.
throw new AuthRedirectError();
}
if (res.status === 204) return undefined as T;
@@ -204,9 +237,8 @@ export async function fetchAuth(
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') window.location.href = '/login';
throw new ApiError('Unauthorized', 401);
redirectToLogin();
throw new AuthRedirectError();
}
if (!res.ok) {
@@ -240,34 +240,42 @@
{/if}
</dl>
<!-- Action buttons — deep-link + highlight the related entity card -->
<!-- Action buttons — deep-link + highlight the related entity card.
IDs are snapshotted into local consts so the deferred onclick
closures don't lose the narrowed type that the `{#if ...}` gate
proves at template-render time. -->
<div class="actions">
{#if displayEvent.provider_id}
<button type="button" onclick={() => openEntity('/providers', displayEvent.provider_id)}>
{@const providerId = displayEvent.provider_id}
<button type="button" onclick={() => openEntity('/providers', providerId)}>
<MdiIcon name="mdiServer" size={14} />
{t('events.openProvider')}
</button>
{/if}
{#if displayEvent.telegram_bot_id && isCommand}
<button type="button" onclick={() => openEntity('/bots', displayEvent.telegram_bot_id)}>
{@const botId = displayEvent.telegram_bot_id}
<button type="button" onclick={() => openEntity('/bots', botId)}>
<MdiIcon name="mdiRobotHappy" size={14} />
{t('events.openBot')}
</button>
{/if}
{#if displayEvent.command_tracker_id && isCommand}
<button type="button" onclick={() => openEntity('/command-trackers', displayEvent.command_tracker_id)}>
{@const cmdTrackerId = displayEvent.command_tracker_id}
<button type="button" onclick={() => openEntity('/command-trackers', cmdTrackerId)}>
<MdiIcon name="mdiChat" size={14} />
{t('events.openCommandTracker')}
</button>
{/if}
{#if displayEvent.action_id && isAction}
<button type="button" onclick={() => openEntity('/actions', displayEvent.action_id)}>
{@const actionId = displayEvent.action_id}
<button type="button" onclick={() => openEntity('/actions', actionId)}>
<MdiIcon name="mdiPlayCircle" size={14} />
{t('events.openAction')}
</button>
{/if}
{#if !isCommand && !isAction && displayEvent.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', displayEvent.tracker_id)}>
{@const trackerId = displayEvent.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', trackerId)}>
<MdiIcon name="mdiRadar" size={14} />
{t('events.openTracker')}
</button>
@@ -17,6 +17,7 @@
columns = 2,
disabled = false,
compact = false,
onChange,
}: {
items: GridItem[];
value: string | number | null;
@@ -24,6 +25,13 @@
columns?: number;
disabled?: boolean;
compact?: boolean;
/**
* Optional one-way change callback. Fired in addition to updating
* `value` so callers that own state externally (e.g. a global store)
* can avoid the read-modify-write feedback loop that `bind:value` plus
* a sync `$effect` produces.
*/
onChange?: (value: string | number) => void;
} = $props();
let open = $state(false);
@@ -63,6 +71,7 @@
value = item.value;
open = false;
search = '';
onChange?.(item.value);
}
function handleKeydown(e: KeyboardEvent) {
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
/**
* Free-text chip input. Bind a string[] of values; commit a new chip on
* Enter, comma, or blur. Backspace on empty input deletes the last chip
* for parity with native chip-input UX.
*
* Used by ProviderDescriptor.userFilters with inputMode === 'tags' for
* free-text filter keys like Home Assistant's entity_glob and
* domain_allowlist. Distinct from MultiEntitySelect, which renders a
* picker dropdown sourced from an enumerable list.
*/
import MdiIcon from './MdiIcon.svelte';
interface Props {
values: string[];
onchange: (values: string[]) => void;
placeholder?: string;
icon?: string;
/** Strip / reject anything matching this regex on each entry. */
sanitize?: (raw: string) => string | null;
}
let { values, onchange, placeholder = '', icon, sanitize }: Props = $props();
let draft = $state('');
function addRaw(raw: string): void {
const trimmed = raw.trim();
if (!trimmed) return;
const cleaned = sanitize ? sanitize(trimmed) : trimmed;
if (!cleaned) return;
if (values.includes(cleaned)) return;
onchange([...values, cleaned]);
}
function commitDraft(): void {
if (!draft.trim()) return;
// Allow comma-separated paste — split on commas and add each.
for (const piece of draft.split(',')) {
addRaw(piece);
}
draft = '';
}
function removeAt(index: number): void {
onchange(values.filter((_, i) => i !== index));
}
function onKey(e: KeyboardEvent): void {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitDraft();
} else if (e.key === 'Backspace' && draft === '' && values.length > 0) {
e.preventDefault();
removeAt(values.length - 1);
}
}
</script>
<div class="tag-input">
{#each values as value, i (`${i}-${value}`)}
<span class="tag-chip">
{#if icon}<MdiIcon name={icon} size={12} />{/if}
<span class="tag-text">{value}</span>
<button
type="button"
aria-label="Remove"
class="tag-remove"
onclick={() => removeAt(i)}
>×</button>
</span>
{/each}
<input
type="text"
bind:value={draft}
onkeydown={onKey}
onblur={commitDraft}
placeholder={values.length === 0 ? placeholder : ''}
class="tag-draft"
autocomplete="off"
spellcheck="false"
/>
</div>
<style>
.tag-input {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
min-height: 2.5rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
background: var(--color-background);
cursor: text;
}
.tag-input:focus-within {
border-color: var(--color-primary);
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
background: var(--color-muted);
border-radius: 9999px;
font-size: 0.75rem;
line-height: 1;
color: var(--color-foreground);
}
.tag-text {
font-family: var(--font-mono, monospace);
}
.tag-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
padding: 0;
font-size: 0.875rem;
line-height: 1;
background: transparent;
border: none;
color: var(--color-muted-foreground);
cursor: pointer;
border-radius: 9999px;
}
.tag-remove:hover {
background: var(--color-border);
color: var(--color-foreground);
}
.tag-draft {
flex: 1;
min-width: 8rem;
border: none;
outline: none;
background: transparent;
font-size: 0.875rem;
color: var(--color-foreground);
padding: 0.125rem 0;
}
.tag-draft::placeholder {
color: var(--color-muted-foreground);
}
</style>
+24 -1
View File
@@ -120,6 +120,16 @@ export const sortFilterItems = (): GridItem[] => [
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
];
// --- Provider stats scope (dashboard "On watch" deck) ---
//
// Toggles whether the provider deck stats reflect only the events visible
// on the current page or aggregate across all events matching the filters.
export const providerStatsModeItems = (): GridItem[] => [
{ value: 'page', icon: 'mdiFileDocumentOutline', label: t('dashboard.statsModePage'), desc: t('gridDesc.statsModePage') },
{ value: 'all', icon: 'mdiInfinity', label: t('dashboard.statsModeAll'), desc: t('gridDesc.statsModeAll') },
];
// --- Auto-refresh interval (dashboard events list) ---
//
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
@@ -175,6 +185,19 @@ export const providerTypeFilterItems = (): GridItem[] => [
...allDescriptors().map(descriptorToGridItem),
];
/** Provider types the user is allowed to create from the "new provider" wizard.
*
* Excludes ``bridge_self`` because it's auto-created exactly once per user
* (see ``packages/server/.../seeds.py``). Letting users pick it from the
* wizard would either duplicate the row or surface a confusing 409.
*/
const _USER_CREATABLE_PROVIDER_TYPES = (): string[] =>
allDescriptors()
.filter((d) => d.type !== 'bridge_self')
.map((d) => d.type);
/** Provider type selector (no "All" option). */
export const providerTypeItems = (): GridItem[] =>
allDescriptors().map(descriptorToGridItem);
allDescriptors()
.filter((d) => _USER_CREATABLE_PROVIDER_TYPES().includes(d.type))
.map(descriptorToGridItem);
+50 -1
View File
@@ -157,6 +157,9 @@
"eventsLabel": "events",
"onWatchTitle": "On",
"onWatchEmphasis": "watch",
"statsModeTitle": "Provider deck stats scope",
"statsModePage": "Page",
"statsModeAll": "All",
"noProviders": "No providers yet.",
"addProvider": "Add provider",
"addProviderHint": "Connect a service to start tracking",
@@ -232,6 +235,20 @@
"typeNut": "NUT (UPS)",
"typeGooglePhotos": "Google Photos",
"typeWebhook": "Generic Webhook",
"typeHomeAssistant": "Home Assistant",
"typeBridgeSelf": "Bridge Self-Monitoring",
"bridgeSelfPollThreshold": "Tracker poll failure threshold",
"bridgeSelfPollThresholdHint": "Notify after this many consecutive poll failures for any tracker.",
"bridgeSelfDeferredThreshold": "Deferred backlog threshold",
"bridgeSelfDeferredThresholdHint": "Notify when pending deferred-dispatch rows exceed this count.",
"bridgeSelfTargetThreshold": "Target send failure threshold",
"bridgeSelfTargetThresholdHint": "Notify after this many consecutive 5xx/network failures for any target.",
"haAccessToken": "Long-Lived Access Token",
"haAccessTokenKeep": "Long-Lived Access Token (leave empty to keep current)",
"haAccessTokenHint": "Create one in HA → Profile → Long-Lived Access Tokens. Required for WebSocket subscription.",
"haAccessTokenRequired": "Home Assistant access token is required.",
"haVerifyTls": "Verify TLS certificate",
"haVerifyTlsHint": "Disable only for self-signed HA on a trusted LAN. Keep enabled for any internet-reachable instance.",
"loadError": "Failed to load providers.",
"externalDomain": "External Domain",
"optional": "optional",
@@ -317,6 +334,13 @@
"selectBoards": "Select boards...",
"upsDevices": "UPS Devices",
"selectUpsDevices": "Select UPS devices...",
"entities": "Entities",
"selectEntities": "Select entities...",
"entities_count": "entity(ies)",
"haEntityGlob": "Entity glob filter",
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
"haDomainAllowlist": "Domain allowlist",
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
"eventTypes": "Event Types",
"notificationTargets": "Notification Targets",
"scanInterval": "Scan Interval (seconds)",
@@ -641,6 +665,14 @@
"upsOverload": "UPS overloaded",
"scheduledMessage": "Scheduled message",
"webhookReceived": "Webhook received",
"haStateChanged": "Entity state changed",
"haAutomationTriggered": "Automation triggered",
"haServiceCalled": "Service called",
"haEventFired": "Other HA event (catch-all)",
"haEventFiredHint": "Fires for any HA event type not covered by the boxes above. Useful for custom integrations; expect high volume.",
"bridgeSelfPollFailures": "Tracker poll failures",
"bridgeSelfDeferredBacklog": "Deferred backlog crossed threshold",
"bridgeSelfTargetFailures": "Target send failures",
"trackImages": "Track images",
"trackVideos": "Track videos",
"favoritesOnly": "Favorites only",
@@ -1095,6 +1127,18 @@
"scopeInherit": "Inherit: derive from notification routing",
"noCollections": "No albums available."
},
"commands": {
"bridgeSelf": {
"status": "Bridge status",
"statusDesc": "Show current bridge health counters",
"thresholds": "Bridge thresholds",
"thresholdsDesc": "Show configured alert thresholds",
"reset": "Reset counter",
"resetDesc": "Manually reset a failure counter",
"health": "Bridge health",
"healthDesc": "Terse one-line health summary"
}
},
"snackbar": {
"showDetails": "Show details",
"hideDetails": "Hide details"
@@ -1177,6 +1221,7 @@
},
"common": {
"loading": "Loading...",
"auto": "Auto",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
@@ -1318,6 +1363,8 @@
"refresh30s": "Refresh every 30 seconds",
"refresh60s": "Refresh every minute",
"refresh5m": "Refresh every 5 minutes",
"statsModePage": "Count only events on the current page",
"statsModeAll": "Count all events matching the current filters",
"newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown",
@@ -1340,7 +1387,9 @@
"providerScheduler": "Time-based scheduled messages",
"providerNut": "Network UPS monitoring",
"providerGooglePhotos": "Google Photos albums & shared libraries",
"providerWebhook": "Receive events via HTTP POST"
"providerWebhook": "Receive events via HTTP POST",
"providerHomeAssistant": "Home Assistant event bus over WebSocket",
"providerBridgeSelf": "Internal health alerts when polling, dispatch, or sends fail"
},
"webhookLogs": {
"title": "Recent Payloads",
+50 -1
View File
@@ -157,6 +157,9 @@
"eventsLabel": "событий",
"onWatchTitle": "На",
"onWatchEmphasis": "слежении",
"statsModeTitle": "Область статистики провайдеров",
"statsModePage": "Страница",
"statsModeAll": "Все",
"noProviders": "Пока нет провайдеров.",
"addProvider": "Добавить",
"addProviderHint": "Подключите сервис, чтобы начать слежение",
@@ -232,6 +235,20 @@
"typeNut": "NUT (ИБП)",
"typeGooglePhotos": "Google Фото",
"typeWebhook": "Универсальный вебхук",
"typeHomeAssistant": "Home Assistant",
"typeBridgeSelf": "Самомониторинг моста",
"bridgeSelfPollThreshold": "Порог сбоев опроса трекера",
"bridgeSelfPollThresholdHint": "Уведомлять после стольких подряд сбоев опроса любого трекера.",
"bridgeSelfDeferredThreshold": "Порог очереди отложенной отправки",
"bridgeSelfDeferredThresholdHint": "Уведомлять, когда количество ожидающих записей deferred_dispatch превысит это значение.",
"bridgeSelfTargetThreshold": "Порог сбоев отправки в адресат",
"bridgeSelfTargetThresholdHint": "Уведомлять после стольких подряд сбоев 5xx/сети при отправке в любой адресат.",
"haAccessToken": "Долгоживущий токен доступа",
"haAccessTokenKeep": "Долгоживущий токен (оставьте пустым для сохранения)",
"haAccessTokenHint": "Создайте в HA → Профиль → Long-Lived Access Tokens. Нужен для WebSocket-подписки.",
"haAccessTokenRequired": "Токен доступа Home Assistant обязателен.",
"haVerifyTls": "Проверять TLS-сертификат",
"haVerifyTlsHint": "Отключайте только для самоподписанного HA в доверенной локальной сети. Оставляйте включённым для любого экземпляра, доступного из интернета.",
"loadError": "Не удалось загрузить провайдеры.",
"externalDomain": "Внешний домен",
"optional": "необязательно",
@@ -317,6 +334,13 @@
"selectBoards": "Выберите доски...",
"upsDevices": "ИБП устройства",
"selectUpsDevices": "Выберите ИБП...",
"entities": "Сущности",
"selectEntities": "Выберите сущности...",
"entities_count": "сущность(ей)",
"haEntityGlob": "Фильтр по entity (glob)",
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
"haDomainAllowlist": "Разрешённые домены",
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
"eventTypes": "Типы событий",
"notificationTargets": "Получатели уведомлений",
"scanInterval": "Интервал проверки (секунды)",
@@ -641,6 +665,14 @@
"upsOverload": "Перегрузка ИБП",
"scheduledMessage": "Запланированное сообщение",
"webhookReceived": "Вебхук получен",
"haStateChanged": "Состояние сущности изменилось",
"haAutomationTriggered": "Сработала автоматизация",
"haServiceCalled": "Вызвана служба",
"haEventFired": "Прочее событие HA (catch-all)",
"haEventFiredHint": "Срабатывает на любые типы событий HA, не охваченные чекбоксами выше. Полезно для пользовательских интеграций; ожидайте большой объём.",
"bridgeSelfPollFailures": "Сбои опроса трекера",
"bridgeSelfDeferredBacklog": "Очередь отложенной отправки превысила порог",
"bridgeSelfTargetFailures": "Сбои отправки в адресат",
"trackImages": "Фото",
"trackVideos": "Видео",
"favoritesOnly": "Только избранные",
@@ -1095,6 +1127,18 @@
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
"noCollections": "Нет доступных альбомов."
},
"commands": {
"bridgeSelf": {
"status": "Состояние моста",
"statusDesc": "Показать счётчики состояния моста",
"thresholds": "Пороги моста",
"thresholdsDesc": "Показать настроенные пороги оповещений",
"reset": "Сбросить счётчик",
"resetDesc": "Вручную сбросить счётчик сбоев",
"health": "Здоровье моста",
"healthDesc": "Краткая однострочная сводка состояния"
}
},
"snackbar": {
"showDetails": "Показать детали",
"hideDetails": "Скрыть детали"
@@ -1177,6 +1221,7 @@
},
"common": {
"loading": "Загрузка...",
"auto": "Авто",
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
@@ -1318,6 +1363,8 @@
"refresh30s": "Обновлять каждые 30 секунд",
"refresh60s": "Обновлять каждую минуту",
"refresh5m": "Обновлять каждые 5 минут",
"statsModePage": "Учитывать только события на текущей странице",
"statsModeAll": "Учитывать все события под текущими фильтрами",
"newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается",
@@ -1340,7 +1387,9 @@
"providerScheduler": "Запланированные сообщения по расписанию",
"providerNut": "Мониторинг ИБП через NUT",
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
"providerWebhook": "Приём событий через HTTP POST"
"providerWebhook": "Приём событий через HTTP POST",
"providerHomeAssistant": "Шина событий Home Assistant по WebSocket",
"providerBridgeSelf": "Внутренние оповещения о сбоях опроса, отправки или диспатча"
},
"webhookLogs": {
"title": "Последние запросы",
+98
View File
@@ -0,0 +1,98 @@
import type { ProviderDescriptor } from './types';
/**
* Bridge self-monitoring provider descriptor.
*
* The bridge_self provider has no remote URL and no credentials. The only
* configuration surface is the three thresholds below, used by the server
* to decide when an internal failure deserves a notification.
*
* Exactly one bridge_self provider exists per user, auto-seeded on user
* creation (see ``packages/server/src/notify_bridge_server/database/seeds.py``).
*/
export const bridgeSelfDescriptor: ProviderDescriptor = {
type: 'bridge_self',
defaultName: 'Bridge Self-Monitoring',
icon: 'mdiAlertCircleOutline',
hasUrl: false,
configFields: [
{
key: 'poll_failure_threshold',
configKey: 'poll_failure_threshold',
label: 'providers.bridgeSelfPollThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 3,
hint: 'providers.bridgeSelfPollThresholdHint',
},
{
key: 'deferred_backlog_threshold',
configKey: 'deferred_backlog_threshold',
label: 'providers.bridgeSelfDeferredThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 100,
hint: 'providers.bridgeSelfDeferredThresholdHint',
},
{
key: 'target_failure_threshold',
configKey: 'target_failure_threshold',
label: 'providers.bridgeSelfTargetThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 5,
hint: 'providers.bridgeSelfTargetThresholdHint',
},
],
buildConfig(form) {
const toInt = (raw: unknown, fallback: number): number => {
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n >= 1 ? n : fallback;
};
return {
config: {
poll_failure_threshold: toInt(form.poll_failure_threshold, 3),
deferred_backlog_threshold: toInt(form.deferred_backlog_threshold, 100),
target_failure_threshold: toInt(form.target_failure_threshold, 5),
},
};
},
hasConfigChanged(form, existing) {
const toInt = (raw: unknown, fallback: number): number => {
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n >= 1 ? n : fallback;
};
return (
toInt(form.poll_failure_threshold, 3) !== toInt(existing.poll_failure_threshold, 3) ||
toInt(form.deferred_backlog_threshold, 100) !== toInt(existing.deferred_backlog_threshold, 100) ||
toInt(form.target_failure_threshold, 5) !== toInt(existing.target_failure_threshold, 5)
);
},
eventFields: [
{
key: 'track_bridge_self_poll_failures',
label: 'trackingConfig.bridgeSelfPollFailures',
default: true,
},
{
key: 'track_bridge_self_deferred_backlog',
label: 'trackingConfig.bridgeSelfDeferredBacklog',
default: true,
},
{
key: 'track_bridge_self_target_failures',
label: 'trackingConfig.bridgeSelfTargetFailures',
default: true,
},
],
collectionMeta: null,
webhookBased: false,
};
@@ -0,0 +1,96 @@
import type { ProviderDescriptor } from './types';
export const homeAssistantDescriptor: ProviderDescriptor = {
type: 'home_assistant',
defaultName: 'Home Assistant',
icon: 'mdiHomeAssistant',
hasUrl: true,
urlPlaceholder: 'http://homeassistant.local:8123',
configFields: [
{
key: 'access_token', configKey: 'access_token',
label: 'providers.haAccessToken', editLabel: 'providers.haAccessTokenKeep',
type: 'password', required: 'create-only', hint: 'providers.haAccessTokenHint',
},
{
key: 'verify_tls', configKey: 'verify_tls',
label: 'providers.haVerifyTls',
type: 'toggle', optional: true, hint: 'providers.haVerifyTlsHint',
defaultValue: true,
},
],
buildConfig(form, editing) {
const config: Record<string, unknown> = { url: form.url };
if (form.access_token) config.access_token = form.access_token;
// Coerce truthy/falsy form values to a real boolean. The toggle
// control binds to `checked`, so this is normally already a bool,
// but legacy form state may carry the string defaults.
config.verify_tls = form.verify_tls === false || form.verify_tls === 'false' ? false : true;
if (!editing && !form.access_token) {
return { config, error: 'providers.haAccessTokenRequired' };
}
return { config };
},
hasConfigChanged(form, existing) {
const existingVerify = existing.verify_tls !== false;
const formVerify = !(form.verify_tls === false || form.verify_tls === 'false');
return (
form.url !== (existing.url || '') ||
!!form.access_token ||
existingVerify !== formVerify
);
},
eventFields: [
{ key: 'track_ha_state_changed', label: 'trackingConfig.haStateChanged', default: true },
{ key: 'track_ha_automation_triggered', label: 'trackingConfig.haAutomationTriggered', default: false },
{ key: 'track_ha_service_called', label: 'trackingConfig.haServiceCalled', default: false },
{
key: 'track_ha_event_fired',
label: 'trackingConfig.haEventFired',
default: false,
hint: 'trackingConfig.haEventFiredHint',
},
],
// entity_glob / domain_allowlist tag-style filters. Stored on the
// tracker's `filters` JSON column (not the flat form root) — the
// TrackerForm reads `inputMode: 'tags'` to render a chip input rather
// than a picker, and `filterKey` routes the value into
// `tracker.filters[filterKey]` at save time.
userFilters: [
{
key: 'entity_glob',
filterKey: 'entity_glob',
inputMode: 'tags',
label: 'notificationTracker.haEntityGlob',
placeholder: 'notificationTracker.haEntityGlobPlaceholder',
icon: 'mdiAsterisk',
},
{
key: 'domain_allowlist',
filterKey: 'domain_allowlist',
inputMode: 'tags',
label: 'notificationTracker.haDomainAllowlist',
placeholder: 'notificationTracker.haDomainAllowlistPlaceholder',
icon: 'mdiTagOutline',
},
],
collectionMeta: {
label: 'notificationTracker.entities',
icon: 'mdiViewList',
placeholder: 'notificationTracker.selectEntities',
countLabel: 'notificationTracker.entities_count',
desc: (col: { state?: string; domain?: string; entity_id?: string; id?: string }) => {
const parts: string[] = [];
if (col.domain) parts.push(col.domain);
if (col.state) parts.push(col.state);
if (parts.length === 0) return col.entity_id || col.id || '';
return parts.join(' · ');
},
},
};
+11
View File
@@ -113,6 +113,17 @@ export const immichDescriptor: ProviderDescriptor = {
desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets`,
},
// Periodic summaries / scheduled picks / memories / quiet hours all live on
// the linked tracking & template configs — surface that connection on the
// tracker form so users don't need to read docs to find them.
featureDiscoveryHint: {
messageKey: 'notificationTracker.featureDiscovery',
ctas: [
{ href: '/tracking-configs?edit={tracking_config_id}', labelKey: 'notificationTracker.openTrackingConfig', icon: 'mdiArrowRight' },
{ href: '/template-configs?edit={template_config_id}', labelKey: 'notificationTracker.openTemplateConfig', icon: 'mdiArrowRight' },
],
},
async onBeforeSave({ form, previousCollectionIds, collections, api: apiFn }) {
const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id));
if (newIds.length === 0) return { proceed: true };
+4
View File
@@ -13,6 +13,8 @@ import { schedulerDescriptor } from './scheduler';
import { nutDescriptor } from './nut';
import { googlePhotosDescriptor } from './google-photos';
import { webhookDescriptor } from './webhook';
import { homeAssistantDescriptor } from './home-assistant';
import { bridgeSelfDescriptor } from './bridge-self';
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
['immich', immichDescriptor],
@@ -22,6 +24,8 @@ const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
['nut', nutDescriptor],
['google_photos', googlePhotosDescriptor],
['webhook', webhookDescriptor],
['home_assistant', homeAssistantDescriptor],
['bridge_self', bridgeSelfDescriptor],
]);
/** Look up a provider descriptor by type. Returns null for unknown types. */
+37 -8
View File
@@ -20,7 +20,7 @@ export interface ConfigField {
configKey?: string;
/** i18n key for the field label. */
label: string;
type: 'text' | 'password' | 'number' | 'grid-select';
type: 'text' | 'password' | 'number' | 'grid-select' | 'toggle';
/** Grid-select item source function name from grid-items.ts. */
gridItems?: string;
gridColumns?: number;
@@ -123,17 +123,30 @@ export interface CollectionMeta {
// ── User-identity filters (TrackerForm) ──────────────────────────────
/**
* Declares a filter that picks user identities from the provider's known
* senders. Rendered as a MultiEntitySelect populated from the provider's
* `/users` endpoint. The picked values are stored as `string[]` under
* `tracker.filters[key]`.
* Declares a filter rendered on the tracker form. Two input modes:
*
* * ``picker`` (default) — populated from the provider's ``/users``
* endpoint, rendered as a ``MultiEntitySelect``. Used for sender
* allowlists / blocklists where the valid values are known.
* * ``tags`` — free-text chip input. Used for glob patterns and other
* filter values that aren't enumerable in advance.
*
* Either way the picked values are stored as ``string[]`` under
* ``tracker.filters[filterKey ?? key]``.
*/
export interface UserFilterMeta {
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
/** Form field key — used internally for binding. */
key: string;
/** i18n key for the label rendered above the picker. */
/**
* Filter key inside ``tracker.filters``. Defaults to ``key`` when
* omitted (backward compat with the original sender allowlist usage).
*/
filterKey?: string;
/** ``picker`` (default) or ``tags`` for free-text chip input. */
inputMode?: 'picker' | 'tags';
/** i18n key for the label rendered above the input. */
label: string;
/** i18n key for the picker placeholder. */
/** i18n key for the placeholder (picker dropdown or chip input). */
placeholder: string;
/** MDI icon shown on chips and dropdown rows. */
icon: string;
@@ -183,6 +196,22 @@ export interface ProviderDescriptor {
/** Whether this provider stores incoming payload history for debugging. */
payloadHistory?: boolean;
// ── Tracker-form discovery hint ──
/**
* Optional info banner shown on the TrackerForm to point users at related
* configuration pages they would otherwise have to discover from docs.
*
* The hint is rendered as a single i18n message followed by zero or more
* call-to-action links. ``ctas[].href`` may include ``{tracking_config_id}``
* / ``{template_config_id}`` placeholders that the form substitutes from
* the tracker's currently selected default-config IDs (or omits the
* ``?edit=...`` query when the value is 0).
*/
featureDiscoveryHint?: {
messageKey: string;
ctas?: Array<{ href: string; labelKey: string; icon?: string }>;
};
// ── Provider-specific hooks ──
/**
* Called after collection selection changes (before save).
+19 -1
View File
@@ -16,8 +16,19 @@ const DEFAULT_TTL_MS = 30_000; // 30 seconds
export interface EntityCache<T extends { id: number }> {
/** Reactive list of cached entities. */
readonly items: T[];
/** True only during the very first fetch (no cached data yet). */
/**
* True only during the very first fetch — when there is no cached data
* to show yet. Background re-fetches keep `loading` false so consumers
* keep rendering the previous list and don't flash a spinner; observe
* `refreshing` instead if a subtle indicator is needed.
*/
readonly loading: boolean;
/**
* True during any non-first fetch (cached items already populated).
* Lets consumers distinguish "show skeleton" (loading) from "show subtle
* shimmer/disabled state" (refreshing) without sharing one flag.
*/
readonly refreshing: boolean;
/** Timestamp of last successful fetch. */
readonly fetchedAt: number;
/** Fetch entities — returns cached data if fresh, else hits network. */
@@ -43,6 +54,7 @@ export function createEntityCache<T extends { id: number }>(
): EntityCache<T> {
let _items = $state<T[]>([]);
let _loading = $state(false);
let _refreshing = $state(false);
let _fetchedAt = $state(0);
function isFresh(): boolean {
@@ -56,8 +68,12 @@ export function createEntityCache<T extends { id: number }>(
const existing = inflightRequests.get(endpoint);
if (existing) return existing;
// First-load vs background-refresh state. We split these so consumers
// can keep the previous list visible during a re-fetch (refreshing)
// instead of flashing a spinner placeholder (loading).
const isFirstLoad = _fetchedAt === 0;
if (isFirstLoad) _loading = true;
else _refreshing = true;
const request = api<T[]>(endpoint)
.then((data) => {
@@ -67,6 +83,7 @@ export function createEntityCache<T extends { id: number }>(
})
.finally(() => {
_loading = false;
_refreshing = false;
inflightRequests.delete(endpoint);
});
@@ -104,6 +121,7 @@ export function createEntityCache<T extends { id: number }>(
return {
get items() { return _items; },
get loading() { return _loading; },
get refreshing() { return _refreshing; },
get fetchedAt() { return _fetchedAt; },
fetch,
invalidate,
@@ -26,15 +26,14 @@ function loadFromStorage(): void {
loadFromStorage();
export const globalProviderFilter = {
get id() {
// If providers are loaded and the stored ID doesn't match any, auto-clear
if (_providerId != null && providersCache.items.length > 0 &&
!providersCache.items.some(p => p.id === _providerId)) {
globalProviderFilter.clear();
return null;
}
return _providerId;
},
/**
* Pure getter — returns whatever was last stored, never mutates. Stale-ID
* reconciliation against `providersCache` is the responsibility of a
* one-time `$effect` in `+layout.svelte` (see `reconcileStaleProviderId`),
* because writing during read inside a `$state`-derived getter triggers
* Svelte 5's `state_unsafe_mutation` warning.
*/
get id() { return _providerId; },
get initialized() { return _initialized; },
set(id: number | null) {
@@ -52,9 +51,24 @@ export const globalProviderFilter = {
this.set(null);
},
/**
* Drop the stored provider ID if it no longer matches any item in the
* providers cache. Safe to call from a `$effect` after the cache has been
* fetched. Returns true when reconciliation actually changed state, so the
* caller can short-circuit follow-up work.
*/
reconcileWithCache(): boolean {
if (_providerId != null && providersCache.items.length > 0 &&
!providersCache.items.some(p => p.id === _providerId)) {
this.clear();
return true;
}
return false;
},
/** The currently selected provider object (reactive). */
get provider() {
const id = this.id; // triggers stale-ID auto-clear
const id = _providerId;
if (id == null) return null;
return providersCache.items.find(p => p.id === id) ?? null;
},
+4
View File
@@ -372,6 +372,10 @@ export interface DashboardStatus {
total_events: number;
recent_events: EventLog[];
command_trackers?: number;
/** Provider name → total event count across ALL events matching the
* current filters (ignores pagination). Powers the "On watch" deck
* when the user opts out of page-scoped stats. */
provider_event_counts?: Record<string, number>;
}
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
+31 -24
View File
@@ -5,7 +5,7 @@
import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { api } from '$lib/api';
import { api, errMsg } from '$lib/api';
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
import { t, getLocale, setLocale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
@@ -46,30 +46,40 @@
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
]);
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
let _syncingFilter = false;
// One-way: the store is the source of truth, the filter widget displays it.
// IconGridSelect mutations route through `onChange` (see template) so we
// never need a paired `$effect` to mirror the local <-> store value, which
// previously required a `_syncingFilter` reentrancy flag.
let providerFilterValue = $derived(globalProviderFilter.id ?? 0);
// Reserve the provider-filter row from first paint until the cache resolves.
// Without this, the row appears mid-paint and pushes nav items down on every
// hard reload — the most visible "jump" the user reported.
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
// Sync filter value → store
// Reconcile a stale persisted provider ID against the freshly-loaded
// providers cache. Lives here (not in the store getter) because writing
// `_providerId` from a `$state`-derived getter triggers Svelte's
// `state_unsafe_mutation`. Runs once per cache refresh.
$effect(() => {
const v = providerFilterValue;
if (_syncingFilter) return;
globalProviderFilter.set(v === 0 ? null : v);
// Track `fetchedAt` so we re-run after the cache loads.
void providersCache.fetchedAt;
void providersCache.items.length;
globalProviderFilter.reconcileWithCache();
});
// Sync store → filter value (handles auto-clear of stale IDs)
$effect(() => {
const storeId = globalProviderFilter.id;
if (storeId === null && providerFilterValue !== 0) {
_syncingFilter = true;
providerFilterValue = 0;
_syncingFilter = false;
}
});
function setProviderFilter(v: string | number) {
const num = typeof v === 'number' ? v : Number(v);
globalProviderFilter.set(num === 0 ? null : num);
}
// Collapsed-rail filter cycles through providers via the same setter so the
// store stays the single write path.
function cycleProviderFilter() {
const ids = [0, ...allProviders.map(p => p.id)];
const idx = ids.indexOf(providerFilterValue);
setProviderFilter(ids[(idx + 1) % ids.length]);
}
let showPasswordForm = $state(false);
let redirecting = $state(false);
@@ -91,7 +101,7 @@
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }, 2000);
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
} catch (err: unknown) { const m = errMsg(err); pwdMsg = m; pwdSuccess = false; snackError(m); }
}
// Read persisted UI state synchronously so first paint already matches the
@@ -446,18 +456,14 @@
{#if showProviderFilter}
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
{#if collapsed}
<button onclick={() => {
const ids = [0, ...allProviders.map(p => p.id)];
const idx = ids.indexOf(providerFilterValue);
providerFilterValue = ids[(idx + 1) % ids.length];
}}
<button onclick={cycleProviderFilter}
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200"
title={globalProviderFilter.provider?.name || t('common.allProviders')}
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
</button>
{:else}
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 3)} compact />
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 3)} compact />
{/if}
</div>
{/if}
@@ -595,6 +601,7 @@
<NavIcon name="mdiMagnify" size={20} />
</button>
<button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')}
aria-expanded={mobileMoreOpen}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
style="color: {mobileMoreOpen ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
<NavIcon name="mdiDotsHorizontal" size={20} />
@@ -609,7 +616,7 @@
transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 4)} compact />
</div>
{/if}
<div class="space-y-3">
+46 -6
View File
@@ -18,7 +18,7 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerStatsModeItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
@@ -76,6 +76,17 @@
return stored ? parseInt(stored, 10) || 10 : 10;
}
// "On watch" provider deck stats scope. ``'page'`` = derive counts from
// the events visible on the current page (legacy behavior); ``'all'`` =
// use the server-aggregated ``provider_event_counts`` map covering every
// event that matches the active filters.
const PROVIDER_STATS_MODE_KEY = 'dashboard_provider_stats_mode';
function loadProviderStatsMode(): string {
if (typeof localStorage === 'undefined') return 'page';
const stored = localStorage.getItem(PROVIDER_STATS_MODE_KEY);
return stored === 'all' ? 'all' : 'page';
}
// 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
@@ -95,6 +106,7 @@
let eventsLoading = $state(false);
let confirmClearEvents = $state(false);
let refreshSeconds = $state(loadRefreshSeconds());
let providerStatsMode = $state(loadProviderStatsMode());
let selectedEvent = $state<EventLog | null>(null);
// Stagger entry animation should play once on initial load only —
// without this, every pagination/filter change re-runs the cascade
@@ -128,6 +140,14 @@
if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_REFRESH_KEY, String(v));
});
// Persist the provider deck stats mode the same way.
let _providerStatsHydrated = false;
$effect(() => {
const v = providerStatsMode;
if (!_providerStatsHydrated) { _providerStatsHydrated = true; return; }
if (typeof localStorage !== 'undefined') localStorage.setItem(PROVIDER_STATS_MODE_KEY, v);
});
async function clearEvents() {
try {
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
@@ -197,6 +217,7 @@
targets: next.targets,
total_events: next.total_events,
command_trackers: next.command_trackers,
provider_event_counts: next.provider_event_counts,
};
return;
}
@@ -298,9 +319,21 @@
: displayProviders);
// === Provider deck — derive activity counts from recent events ===
//
// ``providerStatsMode`` controls the scope: ``'page'`` derives counts
// from the visible page (legacy), ``'all'`` uses the server-aggregated
// ``provider_event_counts`` map covering every event under the active
// filters regardless of pagination.
const providerEventCounts = $derived.by(() => {
const counts = new Map<string, number>();
if (!status) return counts;
if (providerStatsMode === 'all' && status.provider_event_counts) {
for (const [name, total] of Object.entries(status.provider_event_counts)) {
if (!name) continue;
counts.set(name, total);
}
return counts;
}
for (const ev of status.recent_events) {
const k = ev.provider_name || '';
if (!k) continue;
@@ -812,11 +845,18 @@
<h2 class="panel-title">{t('dashboard.onWatchTitle')} <em>{t('dashboard.onWatchEmphasis')}</em></h2>
<p class="panel-meta"><b>{providerDeck.length}</b> {t('dashboard.providersShort')}</p>
</div>
<button type="button" onclick={() => toggleSection('on_watch')}
class="ghost-icon-btn"
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button>
<div class="panel-head-actions">
<div class="w-32" title={t('dashboard.statsModeTitle')}>
<IconGridSelect items={providerStatsModeItems()}
bind:value={providerStatsMode}
columns={2} compact />
</div>
<button type="button" onclick={() => toggleSection('on_watch')}
class="ghost-icon-btn"
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button>
</div>
</header>
{#if sectionExpanded.on_watch}
+6 -6
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { actionsCache, providersCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -99,8 +99,8 @@
capabilitiesCache.fetch(),
]);
loadError = '';
} catch (err: any) {
loadError = err.message || t('actions.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('actions.loadError'));
} finally { loaded = true; highlightFromUrl(); }
}
@@ -138,7 +138,7 @@
}
showForm = false; editing = null; actionsCache.invalidate(); await load();
snackSuccess(t('actions.saved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
submitting = false;
}
@@ -151,7 +151,7 @@
await api(`/actions/${id}`, { method: 'DELETE' });
actionsCache.invalidate(); await load();
snackSuccess(t('actions.deleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function executeAction(id: number, dryRun = false) {
@@ -165,7 +165,7 @@
: `${t('actions.execute')}: ${affected} ${t('actions.affected')}`;
snackSuccess(msg);
actionsCache.invalidate(); await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
executing = { ...executing, [id]: false };
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
@@ -47,7 +47,7 @@
loading = true;
try {
rules = await api<ActionRule[]>(`/actions/${actionId}/rules`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
loading = false;
}
@@ -63,7 +63,7 @@
people = Array.isArray(p) ? p : [];
albums = Array.isArray(a) ? a : [];
} catch {
// People/album endpoints may not exist yet degrade gracefully
// People/album endpoints may not exist yet — degrade gracefully
}
}
@@ -79,7 +79,7 @@
resetNewRule();
await loadRules();
snackSuccess(t('actions.ruleSaved'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
submitting = false;
}
@@ -91,7 +91,7 @@
});
await loadRules();
snackSuccess(t('actions.ruleSaved'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function deleteRule(ruleId: number) {
@@ -99,7 +99,7 @@
await api(`/actions/${actionId}/rules/${ruleId}`, { method: 'DELETE' });
await loadRules();
snackSuccess(t('actions.ruleDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleRule(rule: ActionRule) {
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -29,7 +29,7 @@
emailBotsCache.fetch(true),
matrixBotsCache.fetch(true),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
</script>
+5 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { emailBotsCache } from '$lib/stores/caches.svelte';
@@ -89,7 +89,7 @@
snackSuccess(t('snack.emailBotCreated'));
}
emailForm = defaultEmailForm(); nameManuallyEdited = false; showEmailForm = false; editingEmail = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { emailSubmitting = false; }
}
@@ -99,10 +99,10 @@
id,
onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDeleteEmail = null; }
}
@@ -115,7 +115,7 @@
const res = await api(`/email-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
else snackError(res.error || t('emailBot.operationFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
emailTesting = { ...emailTesting, [botId]: false };
}
</script>
+5 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { matrixBotsCache } from '$lib/stores/caches.svelte';
@@ -85,7 +85,7 @@
snackSuccess(t('snack.matrixBotCreated'));
}
matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { matrixSubmitting = false; }
}
@@ -95,10 +95,10 @@
id,
onconfirm: async () => {
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDeleteMatrix = null; }
}
@@ -111,7 +111,7 @@
const res = await api(`/matrix-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
else snackError(res.error || t('matrixBot.operationFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
matrixTesting = { ...matrixTesting, [botId]: false };
}
</script>
+29 -17
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { slide, fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, errMsg, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { telegramBotsCache } from '$lib/stores/caches.svelte';
@@ -105,7 +105,7 @@
snackSuccess(t('snack.botRegistered'));
}
form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; showForm = false; editing = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const m = errMsg(err); error = m; snackError(m); }
finally { submitting = false; }
}
@@ -115,10 +115,10 @@
id,
onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
@@ -147,7 +147,7 @@
try {
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
}
@@ -156,11 +156,15 @@
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
snackSuccess(t('telegramBot.chatDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
const LANG_ITEMS = [
{ value: '', label: '—', icon: 'mdiTranslate', desc: 'Auto' },
// `desc` is the only locale-sensitive field — language *names* are intentionally
// shown in their own language (English under EN, Русский under RU, …) so users
// recognise them regardless of the current UI locale. Only the "Auto" sentinel
// for the no-override row is translated.
let LANG_ITEMS = $derived([
{ value: '', label: '—', icon: 'mdiTranslate', desc: t('common.auto') },
{ value: 'en', label: 'EN', icon: 'mdiAlphaECircle', desc: 'English' },
{ value: 'ru', label: 'RU', icon: 'mdiAlphaRCircle', desc: 'Русский' },
{ value: 'uk', label: 'UK', icon: 'mdiAlphaUCircle', desc: 'Українська' },
@@ -177,7 +181,7 @@
{ value: 'tr', label: 'TR', icon: 'mdiAlphaTCircle', desc: 'Türkçe' },
{ value: 'ar', label: 'AR', icon: 'mdiAlphaACircle', desc: 'العربية' },
{ value: 'hi', label: 'HI', icon: 'mdiAlphaHCircle', desc: 'हिन्दी' },
];
]);
async function updateChatLanguage(botId: number, chat: TelegramChat, lang: string) {
try {
@@ -189,7 +193,7 @@
c.id === chat.id ? { ...c, language_override: lang } : c
);
snackSuccess(t('telegramBot.languageUpdated'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleChatCommands(botId: number, chat: TelegramChat) {
@@ -202,7 +206,7 @@
chats[botId] = (chats[botId] || []).map(c =>
c.id === chat.id ? { ...c, commands_enabled: newVal } : c
);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function loadListenerStatus(botId: number) {
@@ -232,7 +236,7 @@
t.id === trk.id ? { ...t, enabled: !t.enabled } : t
),
};
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function syncCommands(botId: number) {
@@ -241,7 +245,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -254,7 +258,7 @@
await loadWebhookStatus(botId);
}
snackSuccess(t('snack.botUpdated'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -274,7 +278,7 @@
} else {
snackError(res.error || t('telegramBot.webhookFailed'));
}
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -284,7 +288,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -315,7 +319,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
chatTesting = { ...chatTesting, [key]: false };
}
@@ -464,6 +468,10 @@
</div>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<button
type="button"
role="switch"
aria-checked={chat.commands_enabled}
aria-label={t('telegramBot.commandsToggle')}
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)}>
@@ -511,6 +519,10 @@
<a href="/command-trackers" class="font-medium text-[var(--color-primary)] hover:underline">{trk.name}</a>
</div>
<button
type="button"
role="switch"
aria-checked={trk.enabled}
aria-label={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{trk.enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
onclick={() => toggleListenerEnabled(bot.id, trk)}>
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
@@ -105,7 +105,7 @@
commandTemplateConfigsCache.fetch(),
capabilitiesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
@@ -214,7 +214,7 @@
snackSuccess(t('snack.commandConfigSaved'));
}
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { submitting = false; }
}
@@ -227,10 +227,10 @@
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandConfigDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
snackError(err.message);
snackError(errMsg(err));
}
finally { confirmDelete = null; }
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize';
@@ -213,8 +213,8 @@
allCmdTplConfigs = cfgs;
allCapabilities = caps;
varsRef = vars;
} catch (err: any) {
error = err.message || t('common.loadError');
} catch (err: unknown) {
error = errMsg(err, t('common.loadError'));
snackError(error);
} finally {
loaded = true;
@@ -354,9 +354,10 @@
editing = null;
await load();
snackSuccess(t('snack.cmdTemplateSaved'));
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
}
}
@@ -407,8 +408,8 @@
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
@@ -444,11 +445,12 @@
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.cmdTemplateDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
const m = errMsg(err);
error = m;
snackError(m);
} finally {
confirmDelete = null;
}
@@ -587,7 +589,7 @@
{#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>
<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>
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { t } from '$lib/i18n';
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
@@ -107,7 +107,7 @@
providersCache.fetch(), commandConfigsCache.fetch(),
telegramBotsCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
@@ -168,7 +168,7 @@
snackSuccess(t('snack.commandTrackerCreated'));
}
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { submitting = false; }
}
@@ -180,7 +180,7 @@
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandTrackerDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
finally { confirmDelete = null; }
}
};
@@ -193,7 +193,7 @@
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
toggling = { ...toggling, [trk.id]: false };
}
@@ -226,7 +226,7 @@
snackSuccess(t('snack.listenerAdded'));
await loadListeners(trkId);
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
addingListener = { ...addingListener, [trkId]: false };
}
@@ -235,7 +235,7 @@
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
snackSuccess(t('snack.listenerRemoved'));
await loadListeners(trkId);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
// Per-listener album scope editing
@@ -264,7 +264,7 @@
snackSuccess(t('snack.listenerScopeSaved'));
await loadListeners(scopeEditor.trkId);
scopeEditor = null;
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
function providerName(id: number): string {
@@ -398,7 +398,7 @@
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
<button onclick={() => toggleListeners(trk.id)}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('commandTracker.listeners')} {expandedTracker === trk.id ? '' : ''}
{t('commandTracker.listeners')} {expandedTracker === trk.id ? 'в–І' : 'в–ј'}
</button>
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
</div>
@@ -473,7 +473,7 @@
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
{t('backup.selectAll')}
</button>
<span aria-hidden="true">·</span>
<span aria-hidden="true">В·</span>
<button type="button" class="underline hover:text-[var(--color-primary)]"
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
{t('backup.deselectAll')}
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { login } from '$lib/auth.svelte';
import { t, getLocale, setLocale } from '$lib/i18n';
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
@@ -37,7 +37,7 @@
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
if (res.needs_setup) goto('/setup');
} catch {
// The backend is unreachable surface that distinctly so the user
// The backend is unreachable — surface that distinctly so the user
// doesn't blame the login form for a network/backend problem.
backendDown = true;
}
@@ -50,8 +50,8 @@
try {
await login(username, password);
window.location.href = '/';
} catch (err: any) {
error = err.message || t('auth.loginFailed');
} catch (err: unknown) {
error = errMsg(err, t('auth.loginFailed'));
}
submitting = false;
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { api, parseDate } from '$lib/api';
import { api, parseDate , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { t, getLocale } from '$lib/i18n';
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
@@ -166,8 +166,8 @@
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
capabilitiesCache.fetch(),
]);
} catch (err: any) {
loadError = err.message || t('common.loadFailed');
} catch (err: unknown) {
loadError = errMsg(err, t('common.loadFailed'));
snackError(loadError);
} finally { loaded = true; highlightFromUrl(); }
}
@@ -289,7 +289,7 @@
snackSuccess(t('snack.trackerCreated'));
}
showForm = false; editing = null; linkWarning = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); } finally { submitting = false; }
}
async function autoCreateLinks() {
@@ -301,8 +301,8 @@
try {
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
created++;
} catch (err: any) {
snackError(`Failed to create link for "${album.name}": ${err.message}`);
} catch (err: unknown) {
snackError(`Failed to create link for "${album.name}": ${errMsg(err)}`);
}
}
}
@@ -324,7 +324,7 @@
await api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
await load();
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
} catch (err: unknown) { snackError(errMsg(err)); } finally { toggling = { ...toggling, [tracker.id]: false }; }
}
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
@@ -335,7 +335,7 @@
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.trackerDeleted'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
confirmDelete = null;
}
@@ -451,7 +451,7 @@
newLinkTemplateConfigId[trackerId] = 0;
await load();
snackSuccess(t('snack.targetLinked'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
addingTarget = { ...addingTarget, [trackerId]: false };
}
@@ -460,7 +460,7 @@
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetUnlinked'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
@@ -470,7 +470,7 @@
body: JSON.stringify({ [field]: value }),
});
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
@@ -492,8 +492,8 @@
} else {
snackSuccess(t('snack.targetTestSent'));
}
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
ttTesting = { ...ttTesting, [key]: '' };
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -23,7 +23,7 @@
let replacing = $state<Record<string, boolean>>({});
/**
* Expired and password-protected links can't be repaired in place the
* Expired and password-protected links can't be repaired in place — the
* Immich API has no "reset" endpoint. The only remedy is to recreate the
* link (which the backend does by POSTing a new one and returning it).
* We surface the action per-row so users don't have to leave the form.
@@ -39,8 +39,8 @@
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
if (onupdate) onupdate(remaining);
} catch (err: any) {
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
} catch (err: unknown) {
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + errMsg(err));
} finally {
replacing = { ...replacing, [album.id]: false };
}
@@ -7,6 +7,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
import TagInput from '$lib/components/TagInput.svelte';
import { getDescriptor } from '$lib/providers';
interface Props {
@@ -58,9 +59,36 @@
}: Props = $props();
let descriptor = $derived(getDescriptor(providerType));
let isScheduler = $derived(providerType === 'scheduler');
let isWebhook = $derived(descriptor?.webhookBased ?? false);
let colMeta = $derived(descriptor?.collectionMeta);
// Providers without a collection (currently just the scheduler) drive the
// scheduler-specific form layout. Reading from the descriptor keeps the
// branch out of the component and lets a future provider opt in by
// declaring `collectionMeta: null`.
let isScheduler = $derived(colMeta == null);
let isWebhook = $derived(descriptor?.webhookBased ?? false);
/**
* Resolve `{tracking_config_id}` / `{template_config_id}` placeholders in a
* descriptor-declared CTA href. When the corresponding form field is unset
* (value 0), strip the entire `?edit=...` query so the link still goes to
* the list page. Centralising this here avoids per-provider href logic
* leaking back into the template.
*/
function resolveHintHref(href: string): string {
const tcId = form.default_tracking_config_id;
const tplId = form.default_template_config_id;
if (href.includes('{tracking_config_id}')) {
return tcId
? href.replace('{tracking_config_id}', String(tcId))
: href.split('?')[0];
}
if (href.includes('{template_config_id}')) {
return tplId
? href.replace('{template_config_id}', String(tplId))
: href.split('?')[0];
}
return href;
}
// Custom variable management for scheduler
function addVariable() {
@@ -123,14 +151,24 @@
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
{#each descriptor.userFilters as uf (uf.key)}
{@const filterKey = uf.filterKey ?? uf.key}
<div>
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
<MultiEntitySelect
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
values={form.filters[uf.key] || []}
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
placeholder={t(uf.placeholder)}
/>
{#if uf.inputMode === 'tags'}
<TagInput
values={form.filters[filterKey] || []}
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
placeholder={t(uf.placeholder)}
icon={uf.icon}
/>
{:else}
<MultiEntitySelect
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
values={form.filters[filterKey] || []}
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
placeholder={t(uf.placeholder)}
/>
{/if}
</div>
{/each}
{/if}
@@ -220,29 +258,27 @@
{/if}
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
live on the tracking config, not on the tracker itself. Surface this
here so users don't have to stumble onto the feature by reading docs. -->
{#if providerType === 'immich'}
live on the tracking config, not on the tracker itself. The hint
content (message + CTAs) is declared on the provider descriptor so
each provider can surface its own discoverability links without
embedding `if (type === 'xyz')` here. -->
{#if descriptor?.featureDiscoveryHint}
{@const hint = descriptor.featureDiscoveryHint}
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
<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>
<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>
<p style="color: var(--color-muted-foreground);">{t(hint.messageKey)}</p>
{#if hint.ctas && hint.ctas.length > 0}
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
{#each hint.ctas as cta}
<a href={resolveHintHref(cta.href)}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name={cta.icon ?? 'mdiArrowRight'} size={12} />
{t(cta.labelKey)}
</a>
{/each}
</div>
{/if}
</div>
</div>
{/if}
+11 -6
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -169,8 +169,8 @@
try {
await providersCache.fetch(true);
loadError = '';
} catch (err: any) {
loadError = err.message || t('providers.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('providers.loadError'));
} finally { loaded = true; highlightFromUrl(); }
// Ping all providers in background (use unfiltered list)
for (const p of allProviders) {
@@ -237,7 +237,7 @@
}
showForm = false; editing = null; providersCache.invalidate(); await load();
snackSuccess(t('snack.providerSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
submitting = false;
}
@@ -248,10 +248,10 @@
const id = confirmDelete.id;
confirmDelete = null;
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
}
</script>
@@ -321,6 +321,11 @@
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
min={field.min} max={field.max}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if field.type === 'toggle'}
<label class="toggle-switch">
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
<span class="toggle-track"></span>
</label>
{:else}
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
required={field.required === true || (field.required === 'create-only' && !editing)}
@@ -107,6 +107,11 @@
{:else if field.type === 'number'}
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if field.type === 'toggle'}
<label class="toggle-switch">
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
<span class="toggle-track"></span>
</label>
{:else}
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
required={field.required === true || field.required === 'create-only'}
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, fetchAuth } from '$lib/api';
import { api, fetchAuth , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -97,9 +97,10 @@
scheduledSettings = settings;
backupFiles = files;
pending = p;
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
} finally {
loaded = true;
}
@@ -110,7 +111,7 @@
await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled'));
pending = null;
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function applyAndRestart(): Promise<void> {
@@ -131,9 +132,9 @@
if (attempts < 120) setTimeout(poll, 1000);
};
setTimeout(poll, 1500);
} catch (err: any) {
} catch (err: unknown) {
restartingOverlay = false;
snackError(err.message);
snackError(errMsg(err));
}
}
@@ -144,8 +145,8 @@
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
snackSuccess(t('backup.manualCreated'));
await refreshFiles();
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
creatingBackup = false;
}
@@ -178,8 +179,8 @@
a.click();
URL.revokeObjectURL(url);
snackSuccess(t('backup.exportSuccess'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
exporting = false;
}
@@ -202,8 +203,8 @@
formData.append('file', importFile);
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
validationResult = await res.json();
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
validating = false;
}
@@ -230,8 +231,8 @@
snackSuccess(t('backup.restorePrepared'));
postRestoreModalOpen = true;
importFile = null;
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
importing = false;
}
@@ -246,8 +247,8 @@
body: JSON.stringify(scheduledSettings),
});
snackSuccess(t('backup.scheduleSaved'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
savingSchedule = false;
}
@@ -258,8 +259,8 @@
loadingFiles = true;
try {
backupFiles = await api<BackupFile[]>('/backup/files');
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
loadingFiles = false;
}
@@ -275,8 +276,8 @@
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
@@ -286,8 +287,8 @@
snackSuccess(t('backup.fileDeleted'));
confirmDeleteFile = '';
await refreshFiles();
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
</script>
+2 -1
View File
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { setup } from '$lib/auth.svelte';
import { errMsg } from '$lib/api';
import { t } from '$lib/i18n';
import { initTheme } from '$lib/theme.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -25,7 +26,7 @@
try {
await setup(username, password);
window.location.href = '/';
} catch (err: any) { error = err.message || t('auth.setupFailed'); }
} catch (err: unknown) { error = errMsg(err, t('auth.setupFailed')); }
submitting = false;
}
</script>
+28 -26
View File
@@ -3,7 +3,7 @@
import { SvelteSet } from 'svelte/reactivity';
import { slide } from 'svelte/transition';
import { page } from '$app/state';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
@@ -26,7 +26,7 @@
import ReceiverSection from './ReceiverSection.svelte';
import BotGroupHeader from './BotGroupHeader.svelte';
// ── Helpers ──
// ──── Helpers ────
function getBotName(target: NotificationTarget): string | null {
if (target.type === 'telegram' && target.config?.bot_id) {
@@ -74,7 +74,7 @@
return recv.receiver_key || '?';
}
// ── Constants ──
// ──── Constants ────
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
type TargetType = typeof ALL_TYPES[number];
@@ -142,7 +142,7 @@
return tiles;
}
// ── Derived state ──
// ──── Derived state ────
let allTargets = $derived(targetsCache.items);
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
@@ -158,7 +158,7 @@
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
// ── Target form state ──
// ──── Target form state ────
let showForm = $state(false);
let editing = $state<number | null>(null);
@@ -204,7 +204,7 @@
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── Receiver inline form state ──
// ──── Receiver inline form state ────
let addingReceiverForTarget = $state<number | null>(null);
let receiverForm = $state<Record<string, any>>({});
@@ -228,7 +228,7 @@
if (!expandedTargets.has(id)) expandedTargets.add(id);
}
// ── Effects ──
// ──── Effects ────
// Reset form when switching target type tabs
$effect(() => {
@@ -239,11 +239,11 @@
addingReceiverForTarget = null;
});
// ── Data loading ──
// ──── Data loading ────
onMount(load);
// ── Bot grouping ──
// ──── Bot grouping ────
type TargetGroup = {
key: string;
@@ -355,8 +355,8 @@
emailBotsCache.fetch(), matrixBotsCache.fetch(),
]);
loadError = '';
} catch (err: any) {
loadError = err.message || t('common.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('common.loadError'));
snackError(loadError);
} finally {
loaded = true;
@@ -382,7 +382,7 @@
} catch (e) { console.warn('Failed to discover bot chats:', e); }
}
// ── Target CRUD ──
// ──── Target CRUD ────
function openNew() {
form = defaultForm();
@@ -475,9 +475,10 @@
editing = null;
await load();
snackSuccess(t('snack.targetSaved'));
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
} finally {
submitting = false;
}
@@ -488,7 +489,7 @@
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
@@ -497,15 +498,16 @@
await api(`/targets/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
const m = errMsg(err);
error = m;
snackError(m);
}
}
// ── Receiver CRUD ──
// ──── Receiver CRUD ────
async function openReceiverForm(targetId: number, targetType: string) {
// Force a remount of any picker palette when the same target is reopened
@@ -575,8 +577,8 @@
addingReceiverForTarget = null;
await load();
snackSuccess(t('targets.receiverAdded'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
receiverSubmitting = false;
}
@@ -590,7 +592,7 @@
});
await load();
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function removeReceiver(targetId: number, receiverId: number) {
@@ -598,7 +600,7 @@
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
await load();
snackSuccess(t('targets.receiverDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleBroadcastChild(targetId: number, childId: number) {
@@ -613,7 +615,7 @@
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
});
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function testReceiver(targetId: number, receiverId: number) {
@@ -622,7 +624,7 @@
const res = await api<{ success: boolean; error?: string }>(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
}
</script>
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
@@ -261,7 +261,7 @@
capabilitiesCache.fetch(),
supportedLocalesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
}
@@ -347,7 +347,7 @@
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
showForm = false; editing = null; await load();
snackSuccess(t('snack.templateSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
/**
@@ -404,8 +404,8 @@
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
@@ -472,10 +472,10 @@
id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
@@ -626,7 +626,7 @@
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);"> {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
{/if}
{/if}
</CollapsibleSlot>
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
@@ -158,8 +158,8 @@
error: res?.error || '',
locale,
};
} catch (err: any) {
previewModal = { slotName, rendered: '', error: err.message, locale };
} catch (err: unknown) {
previewModal = { slotName, rendered: '', error: errMsg(err), locale };
} finally {
previewLoading = false;
}
@@ -217,7 +217,7 @@
});
async function load() {
try { await trackingConfigsCache.fetch(true); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
}
@@ -285,7 +285,7 @@
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
showForm = false; editing = null; await load();
snackSuccess(t('snack.trackingConfigSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
@@ -294,10 +294,10 @@
id,
onconfirm: async () => {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
+225 -227
View File
@@ -1,227 +1,225 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
let users = $state<User[]>([]);
let showForm = $state(false);
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Admin reset password
let resetUserId = $state<number | null>(null);
let resetUsername = $state('');
let resetPassword = $state('');
let resetMsg = $state('');
let resetSuccess = $state(false);
// Admin edit username/role
let editUserId = $state<number | null>(null);
let editUsername = $state('');
let editRole = $state('user');
let editMsg = $state('');
let editSuccess = $state(false);
onMount(load);
async function load() {
try { users = await api('/users'); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
catch (err: any) { error = err.message; snackError(err.message); }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDelete = null; }
}
};
}
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
function openEditUser(user: any) {
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
}
async function saveUserEdit(e: SubmitEvent) {
e.preventDefault(); editMsg = ''; editSuccess = false;
try {
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
editMsg = t('snack.userUpdated');
editSuccess = true;
snackSuccess(editMsg);
await load();
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged');
resetSuccess = true;
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
}
function userTiles(user: User): MetaTile[] {
const tiles: MetaTile[] = [];
const isAdmin = user.role === 'admin';
tiles.push({
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
tone: isAdmin ? 'orchid' : 'sky',
});
tiles.push({
icon: 'mdiCalendarOutline',
label: parseDate(user.created_at).toLocaleDateString(),
hint: t('users.joined'),
tone: 'lavender',
mono: true,
});
if (user.id === auth.user?.id) {
tiles.push({
icon: 'mdiAccountStar',
label: t('users.you', 'you'),
tone: 'mint',
});
}
return tiles;
}
</script>
<PageHeader
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb={t('crumbs.systemAccess')}
count={users.length}
countLabel={t('users.countLabel')}
>
<Button size="sm" onclick={() => showForm = !showForm}>
{showForm ? t('users.cancel') : t('users.addUser')}
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<ErrorBanner message={error} />{/if}
<form onsubmit={create} class="space-y-3">
<div>
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
<Button type="submit">{t('users.create')}</Button>
</form>
</Card>
{/if}
{#if users.length === 0}
<Card>
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="list-stack stagger-children">
{#each users as user}
<Card hover>
<div class="list-row">
<div class="list-row__identity">
<p class="font-medium truncate">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<MetaStrip tiles={userTiles(user)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
{/if}
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="reset-pwd" type="password" bind:value={resetPassword} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<Button type="submit" class="w-full">
{t('common.save')}
</Button>
</form>
</Modal>
<!-- Admin edit username/role modal -->
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
<form onsubmit={saveUserEdit} class="space-y-3">
<div>
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="edit-username" bind:value={editUsername} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="edit-role" bind:value={editRole}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
{#if editMsg}
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
{/if}
<Button type="submit" class="w-full">{t('common.save')}</Button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<script lang="ts">
import { onMount } from 'svelte';
import { api, parseDate , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
let users = $state<User[]>([]);
let showForm = $state(false);
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Admin reset password
let resetUserId = $state<number | null>(null);
let resetUsername = $state('');
let resetPassword = $state('');
let resetMsg = $state('');
let resetSuccess = $state(false);
// Admin edit username/role
let editUserId = $state<number | null>(null);
let editUsername = $state('');
let editRole = $state('user');
let editMsg = $state('');
let editSuccess = $state(false);
onMount(load);
async function load() {
try { users = await api('/users'); }
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); } catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); } catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { confirmDelete = null; }
}
};
}
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
function openEditUser(user: any) {
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
}
async function saveUserEdit(e: SubmitEvent) {
e.preventDefault(); editMsg = ''; editSuccess = false;
try {
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
editMsg = t('snack.userUpdated');
editSuccess = true;
snackSuccess(editMsg);
await load();
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
} catch (err: unknown) { const __m = errMsg(err); editMsg = __m; editSuccess = false; snackError(__m); }
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged');
resetSuccess = true;
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: unknown) { const __m = errMsg(err); resetMsg = __m; resetSuccess = false; snackError(__m); }
}
function userTiles(user: User): MetaTile[] {
const tiles: MetaTile[] = [];
const isAdmin = user.role === 'admin';
tiles.push({
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
tone: isAdmin ? 'orchid' : 'sky',
});
tiles.push({
icon: 'mdiCalendarOutline',
label: parseDate(user.created_at).toLocaleDateString(),
hint: t('users.joined'),
tone: 'lavender',
mono: true,
});
if (user.id === auth.user?.id) {
tiles.push({
icon: 'mdiAccountStar',
label: t('users.you', 'you'),
tone: 'mint',
});
}
return tiles;
}
</script>
<PageHeader
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb={t('crumbs.systemAccess')}
count={users.length}
countLabel={t('users.countLabel')}
>
<Button size="sm" onclick={() => showForm = !showForm}>
{showForm ? t('users.cancel') : t('users.addUser')}
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<ErrorBanner message={error} />{/if}
<form onsubmit={create} class="space-y-3">
<div>
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
<Button type="submit">{t('users.create')}</Button>
</form>
</Card>
{/if}
{#if users.length === 0}
<Card>
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="list-stack stagger-children">
{#each users as user}
<Card hover>
<div class="list-row">
<div class="list-row__identity">
<p class="font-medium truncate">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} В· {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<MetaStrip tiles={userTiles(user)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
{/if}
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="reset-pwd" type="password" bind:value={resetPassword} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<Button type="submit" class="w-full">
{t('common.save')}
</Button>
</form>
</Modal>
<!-- Admin edit username/role modal -->
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
<form onsubmit={saveUserEdit} class="space-y-3">
<div>
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="edit-username" bind:value={editUsername} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="edit-role" bind:value={editRole}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
{#if editMsg}
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
{/if}
<Button type="submit" class="w-full">{t('common.save')}</Button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.8.0"
version = "0.8.1"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -65,6 +65,18 @@ class EventType(str, Enum):
UPS_REPLACE_BATTERY = "ups_replace_battery"
UPS_OVERLOAD = "ups_overload"
# Home Assistant events
HA_STATE_CHANGED = "ha_state_changed"
HA_AUTOMATION_TRIGGERED = "ha_automation_triggered"
HA_SERVICE_CALLED = "ha_service_called"
HA_EVENT_FIRED = "ha_event_fired"
# Bridge self-monitoring events — emitted by the bridge itself when
# internal failures cross configured thresholds.
BRIDGE_SELF_POLL_FAILURES = "bridge_self_poll_failures"
BRIDGE_SELF_DEFERRED_BACKLOG = "bridge_self_deferred_backlog"
BRIDGE_SELF_TARGET_FAILURES = "bridge_self_target_failures"
@dataclass
class ServiceEvent:
@@ -107,6 +107,12 @@ class NotificationDispatcher:
# Optional shared session owned by the caller; when supplied we reuse
# its connection pool instead of opening a fresh per-dispatch session.
self._shared_session = session
# Per-dispatch render cache, keyed by locale. Populated by
# ``_send_to_target`` and consumed inside ``_message_for_receiver``
# so a 100-receiver fan-out renders each unique locale once.
# Initialized to empty so handlers called outside the normal
# dispatch path (tests) still see a valid dict.
self._render_cache: dict[str, str] = {}
@contextlib.asynccontextmanager
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
@@ -198,20 +204,49 @@ class NotificationDispatcher:
def _message_for_receiver(
self, receiver: Receiver, default_message: str,
event: ServiceEvent, target: TargetConfig,
cache: dict[str, str] | None = None,
) -> str:
if receiver.locale and receiver.locale != target.locale:
return self._render_message(event, target, receiver.locale)
return default_message
"""Render message respecting receiver locale, with optional cache.
The ``cache`` dict (typically created in ``_send_to_target`` and
threaded through the per-channel ``_send_*`` handlers) memoizes
per-locale renders so a 100-receiver fan-out with two locales
renders twice instead of one hundred times.
"""
loc = receiver.locale or target.locale
if loc == target.locale:
return default_message
if cache is not None:
cached = cache.get(loc)
if cached is not None:
return cached
rendered = self._render_message(event, target, loc)
cache[loc] = rendered
return rendered
return self._render_message(event, target, loc)
async def _send_to_target(
self, event: ServiceEvent, target: TargetConfig
) -> dict[str, Any]:
"""Dispatch to a single target via the registered handler."""
"""Dispatch to a single target via the registered handler.
Builds a per-locale render cache once and threads it through the
send handler. The cache is keyed by receiver locale; the default
locale's render lives in ``default_message`` and is short-circuited
before any cache lookup.
"""
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)
# Stash the cache on the dispatcher instance for the duration of
# this dispatch — handlers pick it up via _message_for_receiver.
# Avoids changing every _send_* signature.
self._render_cache: dict[str, str] = {}
try:
return await send_method(self, target, default_message, event)
finally:
self._render_cache = {}
# ------------------------------------------------------------------
# Asset preload (Telegram-specific)
@@ -352,7 +387,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id:
return {"success": False, "error": "Invalid telegram receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
text_result = await client.send_message(
chat_id=receiver.chat_id,
text=message,
@@ -407,7 +442,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
return {"success": False, "error": "Invalid webhook receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
payload = {
"message": message,
"event_type": event.event_type.value,
@@ -450,7 +485,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, EmailReceiver) or not receiver.email:
return {"success": False, "error": "Invalid email receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
# body_html=None lets EmailClient build a safely-escaped HTML
# alternative from body_text instead of trusting user content.
return await email_client.send(
@@ -479,7 +514,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
return {"success": False, "error": "Invalid discord receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
return await client.send(receiver.webhook_url, message, username=username)
results = await self._fan_out(target.receivers, send_one)
@@ -501,7 +536,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
return {"success": False, "error": "Invalid slack receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
return await client.send(receiver.webhook_url, message, username=username)
results = await self._fan_out(target.receivers, send_one)
@@ -530,7 +565,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
return {"success": False, "error": "Invalid ntfy receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
return await client.send(
server_url, receiver.topic, message,
title=title, priority=receiver.priority, auth_token=auth_token,
@@ -563,7 +598,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
return {"success": False, "error": "Invalid matrix receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
# 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
@@ -222,21 +222,48 @@ class TelegramClient:
"""SSRF-guarded GET that returns ``(data, error)``.
Validates the URL via ``avalidate_outbound_url`` before any HTTP
traffic. Errors are returned (not raised) and stripped of any
embedded secrets before they propagate to the operator-visible
result dict.
traffic. Redirects are walked manually so each ``Location`` is
re-validated without this an attacker-controlled origin could
302 to a private-IP target after the initial guard passed.
Errors are returned (not raised) and stripped of any embedded
secrets before they propagate to the operator-visible result
dict.
"""
max_redirects = 3
current_url = url
try:
await avalidate_outbound_url(url)
await avalidate_outbound_url(current_url)
except UnsafeURLError as err:
return None, f"Unsafe URL: {redact_exc(err)}"
try:
async with self._session.get(
url, headers=headers or {}, timeout=_DOWNLOAD_TIMEOUT,
) as resp:
if resp.status != 200:
return None, f"HTTP {resp.status}"
return await resp.read(), None
for _ in range(max_redirects + 1):
async with self._session.get(
current_url,
headers=headers or {},
timeout=_DOWNLOAD_TIMEOUT,
allow_redirects=False,
) as resp:
if resp.status in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location")
if not loc:
return None, f"HTTP {resp.status} without Location header"
# ``resp.url`` is a yarl.URL; ``.join`` resolves
# relative redirects (``/foo/bar``) against it.
from yarl import URL as _URL
try:
next_url = str(resp.url.join(_URL(loc)))
except (ValueError, TypeError):
return None, "Malformed redirect Location"
try:
await avalidate_outbound_url(next_url)
except UnsafeURLError as err:
return None, f"Unsafe redirect: {redact_exc(err)}"
current_url = next_url
continue
if resp.status != 200:
return None, f"HTTP {resp.status}"
return await resp.read(), None
return None, f"Too many redirects (>{max_redirects})"
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
return None, redact_exc(err)
@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Awaitable, Callable
if TYPE_CHECKING:
from notify_bridge_core.models.events import ServiceEvent
@@ -21,6 +21,14 @@ class ServiceProviderType(str, Enum):
NUT = "nut"
GOOGLE_PHOTOS = "google_photos"
WEBHOOK = "webhook"
HOME_ASSISTANT = "home_assistant"
BRIDGE_SELF = "bridge_self"
# Callback signature for push-style providers: a coroutine that accepts a
# parsed ServiceEvent and is expected to enqueue it for dispatch. Returning
# None keeps the contract narrow — error handling stays inside the callback.
EventEmitCallback = Callable[["ServiceEvent"], Awaitable[None]]
class ServiceProvider(ABC):
@@ -28,10 +36,27 @@ class ServiceProvider(ABC):
A service provider connects to an external service (e.g., Immich photo server)
and can poll for changes, producing generic ServiceEvent objects.
Two ingest modes coexist on this base class:
* Polling providers (Immich, NUT, Google Photos, Scheduler) implement
:meth:`poll` and leave :attr:`supports_subscription` False.
* Webhook providers (Gitea, Planka, generic Webhook) no-op :meth:`poll`
and receive events out-of-band via ``api/webhooks.py``.
* Subscription providers (Home Assistant) flip
:attr:`supports_subscription` to True and implement :meth:`subscribe`
to run a long-lived task that pushes events through an
``emit`` callback. They typically no-op :meth:`poll`.
"""
provider_type: ServiceProviderType
# When True, the lifecycle layer (server-side subscription manager) starts
# a long-running task that calls :meth:`subscribe` instead of registering
# this provider with the polling scheduler. Default False keeps the
# legacy poll/webhook flow intact for every existing provider.
supports_subscription: bool = False
@abstractmethod
async def connect(self) -> bool:
"""Connect to the service and verify connectivity.
@@ -59,6 +84,27 @@ class ServiceProvider(ABC):
Tuple of (list of events detected, updated state dict).
"""
async def subscribe(self, emit: EventEmitCallback) -> None:
"""Run a long-lived subscription that calls ``emit`` for each event.
Override on providers with :attr:`supports_subscription` = True. The
implementation is expected to:
* Loop until cancelled (the subscription manager uses
:func:`asyncio.Task.cancel` on shutdown).
* Handle its own reconnect with exponential backoff never propagate
transient network errors to the caller.
* Pass parsed :class:`ServiceEvent` instances to ``emit`` for
enqueueing/dispatch. The callback is responsible for routing.
The default implementation raises :class:`NotImplementedError` so
accidental wiring of a polling provider into the subscription manager
fails loudly rather than silently doing nothing.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support subscription-based ingest"
)
@abstractmethod
def get_available_variables(self) -> list[TemplateVariableDefinition]:
"""Return the template variables this provider makes available."""
@@ -0,0 +1,39 @@
"""Bridge self-monitoring service provider.
Unlike external providers (Immich, Gitea, NUT, ...), the ``bridge_self``
provider does not connect to any remote service. Its sole purpose is to
give operators a configurable surface (thresholds + notification slots
+ trackers + targets) for events that the bridge itself emits when its
internal subsystems fail.
Three failure conditions are surfaced as :class:`ServiceEvent` instances
through the same dispatch pipeline that all other providers use:
* ``bridge_self_poll_failures`` N consecutive poll failures for
any tracker exceed the configured threshold.
* ``bridge_self_deferred_backlog`` pending ``deferred_dispatch`` row
count crosses the configured threshold.
* ``bridge_self_target_failures`` N consecutive 5xx / network failures
for a single notification target.
Events are constructed by ``services/bridge_self.py`` on the server side
(it owns DB access for looking up the bridge_self provider per user)
and then fed into ``dispatch_provider_event`` like any other event.
"""
from notify_bridge_core.providers.base import ServiceProviderType
from notify_bridge_core.templates.variables import registry
from .event_parser import build_event
from .provider import BRIDGE_SELF_VARIABLES, BridgeSelfServiceProvider
# Register variables so the validator and template-vars API see them.
registry.register_provider_variables(
ServiceProviderType.BRIDGE_SELF, BRIDGE_SELF_VARIABLES,
)
__all__ = [
"BRIDGE_SELF_VARIABLES",
"BridgeSelfServiceProvider",
"build_event",
]
@@ -0,0 +1,89 @@
"""Bridge self-monitoring event parser.
The bridge generates these events from internal subsystems (watcher,
scheduler, dispatcher) the parser turns a flat payload dict into the
generic :class:`ServiceEvent` shape that the rest of the dispatch
pipeline expects.
Payload shape::
{
"failure_type": "poll_failures" | "deferred_backlog" | "target_failures",
"subject_id": int, # tracker_id, target_id, or 0
"subject_name": str,
"count": int, # consecutive failures or pending count
"threshold": int,
"last_error": str, # may be empty
"details": dict[str, Any], # extra context
}
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.providers.base import ServiceProviderType
# Defensive cap on the persisted error message; very long tracebacks would
# bloat the EventLog details JSON column otherwise.
_MAX_ERROR_LEN = 1000
_FAILURE_TYPE_TO_EVENT: dict[str, EventType] = {
"poll_failures": EventType.BRIDGE_SELF_POLL_FAILURES,
"deferred_backlog": EventType.BRIDGE_SELF_DEFERRED_BACKLOG,
"target_failures": EventType.BRIDGE_SELF_TARGET_FAILURES,
}
def build_event(
payload: dict[str, Any],
*,
provider_name: str = "Bridge Self-Monitoring",
timestamp: datetime | None = None,
) -> ServiceEvent | None:
"""Convert a self-monitoring payload dict into a ServiceEvent.
Returns None for malformed payloads (unknown failure_type or missing
keys) the caller drops without raising so a misbehaving emitter
can never tip over the dispatch pipeline.
"""
if not isinstance(payload, dict):
return None
failure_type = payload.get("failure_type")
event_type = _FAILURE_TYPE_TO_EVENT.get(str(failure_type) if failure_type else "")
if event_type is None:
return None
subject_id = int(payload.get("subject_id") or 0)
subject_name = str(payload.get("subject_name") or "")
count = int(payload.get("count") or 0)
threshold = int(payload.get("threshold") or 0)
last_error = str(payload.get("last_error") or "")[:_MAX_ERROR_LEN]
details = payload.get("details") if isinstance(payload.get("details"), dict) else {}
when = timestamp or datetime.now(timezone.utc)
return ServiceEvent(
event_type=event_type,
provider_type=ServiceProviderType.BRIDGE_SELF,
provider_name=provider_name,
# ``collection_id`` / ``collection_name`` are required fields on
# ServiceEvent; we use the subject so quiet-hours / dedupe logic
# treats different subjects as distinct streams.
collection_id=str(subject_id),
collection_name=subject_name or str(failure_type),
timestamp=when,
extra={
"failure_type": str(failure_type),
"subject_id": subject_id,
"subject_name": subject_name,
"count": count,
"threshold": threshold,
"last_error": last_error,
"details": dict(details),
},
)
@@ -0,0 +1,148 @@
"""Bridge self-monitoring service provider — emits internal-failure events.
This is a passive provider: it does not connect to anything, never polls,
and never subscribes. It exists so the rest of the bridge's CRUD / config /
template / target plumbing has a single ``ServiceProvider`` to attach
self-monitoring trackers and notification slots to.
Events are constructed by the server-side helper
``services/bridge_self.emit_bridge_self_event`` and pushed into
``dispatch_provider_event`` directly the provider itself is not asked
to produce events.
"""
from __future__ import annotations
from typing import Any
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.providers.base import (
ServiceProvider,
ServiceProviderType,
)
from notify_bridge_core.templates.variables import TemplateVariableDefinition
# Configuration keys recognised on the bridge_self provider's ``config`` JSON.
DEFAULT_POLL_FAILURE_THRESHOLD = 3
DEFAULT_DEFERRED_BACKLOG_THRESHOLD = 100
DEFAULT_TARGET_FAILURE_THRESHOLD = 5
# Template variables exposed to bridge_self templates.
BRIDGE_SELF_VARIABLES: list[TemplateVariableDefinition] = [
TemplateVariableDefinition(
name="failure_type",
type="string",
description="Which self-monitoring condition fired",
example="poll_failures",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="subject_id",
type="int",
description="ID of the affected entity (tracker_id, target_id, or 0)",
example="42",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="subject_name",
type="string",
description="Human-readable name of the affected entity",
example="My Immich Tracker",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="count",
type="int",
description="Consecutive failure count or current backlog size",
example="3",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="threshold",
type="int",
description="Configured threshold that was crossed",
example="3",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="last_error",
type="string",
description="Last underlying error message (truncated)",
example="Connection refused",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="details",
type="dict",
description="Extra structured context for the event",
example='{"provider_id": 7}',
provider_type=ServiceProviderType.BRIDGE_SELF,
),
]
class BridgeSelfServiceProvider(ServiceProvider):
"""Passive provider — exposes nothing remote, holds only thresholds.
Polling is a no-op and ``connect`` always succeeds; the bridge itself
is what generates events for this provider.
"""
provider_type = ServiceProviderType.BRIDGE_SELF
supports_subscription = False
def __init__(self, name: str = "Bridge Self-Monitoring") -> None:
self._name = name
async def connect(self) -> bool:
return True
async def disconnect(self) -> None:
return None
async def poll(
self,
collection_ids: list[str],
tracker_state: dict[str, Any],
) -> tuple[list[ServiceEvent], dict[str, Any]]:
# No external service to poll. Returning empty keeps the contract
# so accidental scheduling no-ops cleanly.
return [], tracker_state
def get_available_variables(self) -> list[TemplateVariableDefinition]:
return list(BRIDGE_SELF_VARIABLES)
def get_provider_config_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"poll_failure_threshold": {
"type": "integer",
"minimum": 1,
"default": DEFAULT_POLL_FAILURE_THRESHOLD,
"description": "Consecutive tracker poll failures before alerting",
},
"deferred_backlog_threshold": {
"type": "integer",
"minimum": 1,
"default": DEFAULT_DEFERRED_BACKLOG_THRESHOLD,
"description": "Pending deferred_dispatch rows before alerting",
},
"target_failure_threshold": {
"type": "integer",
"minimum": 1,
"default": DEFAULT_TARGET_FAILURE_THRESHOLD,
"description": "Consecutive target send failures before alerting",
},
},
"required": [],
}
async def list_collections(self) -> list[dict[str, Any]]:
# No collection concept — operators don't pick anything for this provider.
return []
async def test_connection(self) -> dict[str, Any]:
return {"ok": True, "message": "Bridge self-monitoring is always available"}
@@ -444,6 +444,133 @@ WEBHOOK_CAPABILITIES = ProviderCapabilities(
],
)
# ---------------------------------------------------------------------------
# Home Assistant provider capabilities
# ---------------------------------------------------------------------------
HOME_ASSISTANT_CAPABILITIES = ProviderCapabilities(
provider_type="home_assistant",
display_name="Home Assistant",
webhook_based=False,
supported_filters=[
{
"key": "collections",
"label": "Entities",
"type": "tags",
"placeholder": "light.kitchen",
},
{
"key": "entity_glob",
"label": "Entity glob",
"type": "tags",
"placeholder": "light.*",
},
{
"key": "domain_allowlist",
"label": "Domains",
"type": "tags",
"placeholder": "light, binary_sensor",
},
],
notification_slots=[
{"name": "message_ha_state_changed", "description": "Entity state changed"},
{"name": "message_ha_automation_triggered", "description": "Automation triggered"},
{"name": "message_ha_service_called", "description": "HA service called"},
{"name": "message_ha_event_fired", "description": "Other HA event fired"},
],
events=[
{"name": "ha_state_changed", "description": "Entity state changed"},
{"name": "ha_automation_triggered", "description": "Automation triggered"},
{"name": "ha_service_called", "description": "HA service called"},
{"name": "ha_event_fired", "description": "Other HA event fired (catch-all)"},
],
command_slots=[
# Response templates
{"name": "start", "description": "/start greeting message"},
{"name": "help", "description": "/help command listing"},
{"name": "status", "description": "/status connection summary"},
{"name": "entities", "description": "/entities matching glob"},
{"name": "state", "description": "/state single-entity drill-down"},
{"name": "areas", "description": "/areas with entity counts"},
{"name": "rate_limited", "description": "Rate limit warning message"},
{"name": "no_results", "description": "Empty results fallback"},
# Description slots
{"name": "desc_help", "description": "Menu description for /help"},
{"name": "desc_status", "description": "Menu description for /status"},
{"name": "desc_entities", "description": "Menu description for /entities"},
{"name": "desc_state", "description": "Menu description for /state"},
{"name": "desc_areas", "description": "Menu description for /areas"},
# Usage examples
{"name": "usage_entities", "description": "Usage example for /entities"},
{"name": "usage_state", "description": "Usage example for /state"},
],
commands=[
{"name": "status", "description": "Show connection status"},
{"name": "entities", "description": "List entities (optional glob)"},
{"name": "state", "description": "Show state for one entity"},
{"name": "areas", "description": "List HA areas with entity counts"},
{"name": "help", "description": "Show commands"},
],
)
# ---------------------------------------------------------------------------
# Bridge self-monitoring capabilities
# ---------------------------------------------------------------------------
BRIDGE_SELF_CAPABILITIES = ProviderCapabilities(
provider_type="bridge_self",
display_name="Bridge Self-Monitoring",
webhook_based=False,
supported_filters=[],
notification_slots=[
{
"name": "message_bridge_self_poll_failures",
"description": "Tracker poll failures crossed threshold",
},
{
"name": "message_bridge_self_deferred_backlog",
"description": "Deferred dispatch backlog crossed threshold",
},
{
"name": "message_bridge_self_target_failures",
"description": "Target send failures crossed threshold",
},
],
events=[
{"name": "bridge_self_poll_failures", "description": "Tracker poll failures"},
{"name": "bridge_self_deferred_backlog", "description": "Deferred backlog high"},
{"name": "bridge_self_target_failures", "description": "Target send failures"},
],
command_slots=[
# Response templates
{"name": "start", "description": "/start greeting message"},
{"name": "help", "description": "/help command listing"},
{"name": "status", "description": "/status full counter snapshot"},
{"name": "thresholds", "description": "/thresholds configured alert thresholds"},
{"name": "reset", "description": "/reset manual counter reset"},
{"name": "health", "description": "/health terse one-line summary"},
{"name": "rate_limited", "description": "Rate limit warning message"},
{"name": "no_results", "description": "Empty results fallback"},
# Description slots
{"name": "desc_help", "description": "Menu description for /help"},
{"name": "desc_status", "description": "Menu description for /status"},
{"name": "desc_thresholds", "description": "Menu description for /thresholds"},
{"name": "desc_reset", "description": "Menu description for /reset"},
{"name": "desc_health", "description": "Menu description for /health"},
# Usage examples
{"name": "usage_reset", "description": "Usage example for /reset"},
],
commands=[
{"name": "status", "description": "Show current bridge health counters"},
{"name": "thresholds", "description": "Show configured alert thresholds"},
{"name": "reset", "description": "Manually reset a failure counter"},
{"name": "health", "description": "Terse one-line health summary"},
{"name": "help", "description": "Show commands"},
],
)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
@@ -456,6 +583,8 @@ _REGISTRY: dict[str, ProviderCapabilities] = {
"nut": NUT_CAPABILITIES,
"google_photos": GOOGLE_PHOTOS_CAPABILITIES,
"webhook": WEBHOOK_CAPABILITIES,
"home_assistant": HOME_ASSISTANT_CAPABILITIES,
"bridge_self": BRIDGE_SELF_CAPABILITIES,
}
@@ -0,0 +1,34 @@
"""Home Assistant service provider implementation."""
from notify_bridge_core.providers.base import ServiceProviderType
from notify_bridge_core.templates.variables import registry
from .client import (
HomeAssistantApiError,
HomeAssistantAuthError,
HomeAssistantWSClient,
_redact as redact_ha_message,
)
from .event_parser import parse_event
from .provider import (
DEFAULT_HA_EVENT_TYPES,
HOME_ASSISTANT_VARIABLES,
HomeAssistantServiceProvider,
)
# Register HA variables in the global registry — same pattern as the other
# providers in this package.
registry.register_provider_variables(
ServiceProviderType.HOME_ASSISTANT, HOME_ASSISTANT_VARIABLES,
)
__all__ = [
"DEFAULT_HA_EVENT_TYPES",
"HOME_ASSISTANT_VARIABLES",
"HomeAssistantApiError",
"HomeAssistantAuthError",
"HomeAssistantServiceProvider",
"HomeAssistantWSClient",
"parse_event",
"redact_ha_message",
]
@@ -0,0 +1,506 @@
"""Home Assistant WebSocket client.
Implements the slice of the HA WebSocket API we need for Phase 1:
* Authenticate with a long-lived access token.
* Subscribe to events (optionally filtered by ``event_type``).
* Fetch the state list (``get_states``) for entity picker UI.
* Fetch the entity and area registries to build an ``entity_id -> area_id``
lookup that the parser uses to enrich ``state_changed`` events with the
area name.
* Run an indefinite subscription loop with exponential backoff reconnect.
The HA protocol reference is at
https://developers.home-assistant.io/docs/api/websocket/ message ids are
ascending integers, server replies use the same id, and authentication must
complete before any other command is accepted.
"""
from __future__ import annotations
import asyncio
import itertools
import logging
import random
import time
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Awaitable, Callable
from urllib.parse import urlparse, urlunparse
import aiohttp
_LOGGER = logging.getLogger(__name__)
class HomeAssistantAuthError(Exception):
"""Raised when HA rejects our access token. Fatal — no point retrying."""
class HomeAssistantApiError(Exception):
"""Raised when an HA WS command returns ``success: false``."""
# Default reconnect backoff: 2s, 4s, 8s, ..., capped at 60s with jitter.
_RECONNECT_BASE_SECONDS = 2.0
_RECONNECT_MAX_SECONDS = 60.0
_RECONNECT_JITTER_RATIO = 0.2
# Bounded queue between the WS receive loop and the emit consumer. Overflow
# drops the oldest event (FIFO) and logs at WARNING — better to lose one
# state_changed than fall behind the firehose indefinitely.
_EMIT_QUEUE_SIZE = 1000
def _ws_url_from_base(base_url: str) -> str:
"""Derive the HA WebSocket URL from the user-provided HTTP(S) base URL.
``http://homeassistant.local:8123`` -> ``ws://homeassistant.local:8123/api/websocket``.
The user enters their normal HA URL; we transform the scheme + append
the API path. This keeps the UI single-field and avoids confusion about
which URL form to use.
Userinfo (``user:pass@host``) is **stripped** credentials embedded in
the URL would otherwise flow into log lines and exception strings via
``aiohttp`` error messages. The HA WS protocol uses an access-token
handshake; HTTP basic auth in the URL is never the intended path.
"""
parsed = urlparse(base_url.rstrip("/"))
if parsed.scheme in ("ws", "wss"):
scheme = parsed.scheme
elif parsed.scheme == "https":
scheme = "wss"
else:
scheme = "ws"
# ``netloc`` may contain ``user:pass@host:port``; ``hostname`` + ``port``
# rebuild it without the credential prefix.
host = parsed.hostname or ""
if parsed.port is not None:
netloc = f"{host}:{parsed.port}"
else:
netloc = host
return urlunparse(
(scheme, netloc, "/api/websocket", "", "", "")
)
def _redact(text: str) -> str:
"""Strip embedded credentials from text before logging.
``aiohttp`` exception strings include the URL, so a malformed
``https://token@host`` would otherwise expose the token. This is a
defense-in-depth measure ``_ws_url_from_base`` already strips
userinfo from the connect URL, but third-party libs may quote the
user-supplied input separately.
"""
if not text:
return text
# Match ``scheme://[user[:pass]@]host`` and drop the userinfo segment.
import re
return re.sub(
r"(?P<scheme>\w+://)(?:[^/@\s]+@)",
r"\g<scheme>",
text,
)
class HomeAssistantWSClient:
"""Single-instance WebSocket client for one HA server."""
def __init__(
self,
session: aiohttp.ClientSession,
base_url: str,
access_token: str,
verify_tls: bool = True,
) -> None:
self._session = session
self._ws_url = _ws_url_from_base(base_url)
self._access_token = access_token
self._verify_tls = verify_tls
self._id_counter = itertools.count(1)
# ------------------------------------------------------------------
# Connection primitives
# ------------------------------------------------------------------
@asynccontextmanager
async def _connect(self) -> AsyncIterator[aiohttp.ClientWebSocketResponse]:
"""Open a fresh WS, complete the auth handshake, and yield the socket.
Raises :class:`HomeAssistantAuthError` on invalid token (fatal) and
:class:`HomeAssistantApiError` on other handshake failures (caller
decides whether to retry).
"""
ws = await self._session.ws_connect(
self._ws_url,
ssl=None if self._verify_tls else False,
heartbeat=30,
autoping=True,
)
try:
await self._authenticate(ws)
yield ws
finally:
await ws.close()
async def _authenticate(self, ws: aiohttp.ClientWebSocketResponse) -> None:
"""Run the HA auth handshake on a freshly-opened socket."""
greeting = await ws.receive_json(timeout=10)
if greeting.get("type") != "auth_required":
raise HomeAssistantApiError(
f"Expected auth_required, got {greeting.get('type')!r}"
)
await ws.send_json({"type": "auth", "access_token": self._access_token})
result = await ws.receive_json(timeout=10)
msg_type = result.get("type")
if msg_type == "auth_ok":
return
if msg_type == "auth_invalid":
raise HomeAssistantAuthError(
result.get("message") or "Home Assistant rejected the access token"
)
raise HomeAssistantApiError(
f"Unexpected auth response: {msg_type!r}"
)
async def _send_command(
self,
ws: aiohttp.ClientWebSocketResponse,
payload: dict[str, Any],
) -> int:
"""Send a command with an auto-assigned id; return that id."""
msg_id = next(self._id_counter)
await ws.send_json({"id": msg_id, **payload})
return msg_id
async def _await_result(
self,
ws: aiohttp.ClientWebSocketResponse,
msg_id: int,
timeout: float = 15.0,
) -> Any:
"""Wait for a ``result`` message matching ``msg_id`` and return its payload.
``time.monotonic`` is the right clock here wall-clock deadlines
would jump on NTP sync, and ``asyncio.get_event_loop().time()``
is deprecated when called outside a running-loop context.
"""
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise HomeAssistantApiError(
f"Timed out waiting for result of command id={msg_id}"
)
msg = await ws.receive_json(timeout=remaining)
if msg.get("id") != msg_id:
# Ignore unsolicited events that arrive between sending a
# request-style command and its result.
continue
if msg.get("type") != "result":
continue
if not msg.get("success", False):
err = msg.get("error", {})
raise HomeAssistantApiError(
f"HA command failed: {err.get('code')} {err.get('message')}"
)
return msg.get("result")
# ------------------------------------------------------------------
# Multi-command session
# ------------------------------------------------------------------
@asynccontextmanager
async def session(self) -> AsyncIterator["HomeAssistantSession"]:
"""Open one authenticated WS and let the caller run multiple commands.
Each one-shot method (``get_states``, ``get_area_registry``, ...)
opens a brand-new connection with a full TCP + WS + auth handshake.
For callers that need to chain several queries (e.g. /status: connection
check + entity list + area count) that overhead adds up 3 separate
TLS handshakes and 3 auth round-trips for what is really one logical
request.
Usage:
async with client.session() as sess:
states = await sess.get_states()
areas = await sess.get_area_registry()
The session shares the same id counter as the client, so message ids
are unique across both one-shot calls and session-scoped calls if
they happen to run concurrently against the same client instance.
"""
async with self._connect() as ws:
yield HomeAssistantSession(self, ws)
# ------------------------------------------------------------------
# One-shot commands
# ------------------------------------------------------------------
async def test_connection(self) -> tuple[bool, str]:
"""Connect, authenticate, and immediately close. Returns ``(ok, message)``."""
try:
async with self._connect() as _ws:
return True, "OK"
except HomeAssistantAuthError as err:
return False, f"Auth failed: {err}"
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
return False, f"Connection failed: {err}"
except HomeAssistantApiError as err:
return False, str(err)
async def get_states(self) -> list[dict[str, Any]]:
"""Fetch the current state of every entity HA knows about."""
async with self._connect() as ws:
msg_id = await self._send_command(ws, {"type": "get_states"})
result = await self._await_result(ws, msg_id)
return list(result or [])
async def get_area_registry(self) -> list[dict[str, Any]]:
"""Fetch the area registry (``area_id`` -> name + metadata)."""
async with self._connect() as ws:
msg_id = await self._send_command(
ws, {"type": "config/area_registry/list"}
)
result = await self._await_result(ws, msg_id)
return list(result or [])
async def get_entity_registry(self) -> list[dict[str, Any]]:
"""Fetch the entity registry (entity_id -> area_id + metadata)."""
async with self._connect() as ws:
msg_id = await self._send_command(
ws, {"type": "config/entity_registry/list"}
)
result = await self._await_result(ws, msg_id)
return list(result or [])
async def get_entity_to_area_lookup(self) -> dict[str, str]:
"""Build ``{entity_id: area_name}`` using the entity + area registries.
Best-effort: returns an empty dict on any failure so the parser still
works without area enrichment.
"""
try:
entities = await self.get_entity_registry()
areas = await self.get_area_registry()
except (HomeAssistantApiError, aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Could not fetch HA registry, areas disabled: %s", err)
return {}
area_names = {a.get("area_id"): a.get("name") for a in areas if a.get("area_id")}
lookup: dict[str, str] = {}
for entry in entities:
entity_id = entry.get("entity_id")
area_id = entry.get("area_id")
if not isinstance(entity_id, str) or not area_id:
continue
name = area_names.get(area_id)
if name:
lookup[entity_id] = str(name)
return lookup
# ------------------------------------------------------------------
# Subscription loop with reconnect
# ------------------------------------------------------------------
async def run_subscription(
self,
on_event: Callable[[dict[str, Any]], Awaitable[None]],
event_types: list[str] | None = None,
on_status_change: Callable[[str, str | None], None] | None = None,
refresh_areas: Callable[[], Awaitable[dict[str, str]]] | None = None,
) -> None:
"""Run an indefinite subscription loop, reconnecting on drop.
Parameters
----------
on_event:
Coroutine called with the inner ``event`` dict (the WS envelope is
stripped). Slow callbacks apply TCP backpressure naturally; the
internal queue prevents unbounded memory growth if the callback
stalls.
event_types:
Restrict the subscription to these HA event types. ``None`` or
empty subscribes to everything (very loud only use for debug).
on_status_change:
Callback invoked with ``("connected", None)`` after a successful
handshake and ``("disconnected", reason)`` when a connection drops.
Useful for surfacing connection state in the event log.
refresh_areas:
Optional coroutine called on each (re)connect to refresh the
area lookup. The result is not used by ``run_subscription``
itself the caller stores it where its ``on_event`` can read.
"""
attempt = 0
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=_EMIT_QUEUE_SIZE)
overflow_count = 0
async def _drain() -> None:
while True:
evt = await queue.get()
try:
await on_event(evt)
except Exception: # noqa: BLE001
_LOGGER.exception("on_event callback raised; continuing")
finally:
queue.task_done()
drain_task = asyncio.create_task(_drain(), name="ha-emit-drain")
try:
while True:
try:
async with self._connect() as ws:
attempt = 0
if on_status_change is not None:
on_status_change("connected", None)
if refresh_areas is not None:
try:
# Note: refresh_areas opens its own WS in our
# current design (each one-shot command does).
# Fine for v1 — a few hundred ms once per
# (re)connect.
await refresh_areas()
except Exception: # noqa: BLE001
_LOGGER.exception("Area refresh failed; continuing without")
# Subscribe. Passing per-event-type subscriptions is
# cheaper than subscribing to everything and filtering
# in Python — HA does the filtering.
if event_types:
for evt_type in event_types:
sub_id = await self._send_command(
ws,
{"type": "subscribe_events", "event_type": evt_type},
)
await self._await_result(ws, sub_id)
else:
sub_id = await self._send_command(
ws, {"type": "subscribe_events"}
)
await self._await_result(ws, sub_id)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
payload = msg.json()
if payload.get("type") != "event":
continue
event_obj = payload.get("event")
if not isinstance(event_obj, dict):
continue
try:
queue.put_nowait(event_obj)
except asyncio.QueueFull:
overflow_count += 1
if overflow_count % 50 == 1:
_LOGGER.warning(
"HA event queue full, dropped %d events so far "
"(consumer is slower than HA event rate)",
overflow_count,
)
# Drop oldest, retry put. This keeps the
# most recent state visible at the cost
# of older transient changes.
try:
queue.get_nowait()
queue.task_done()
except asyncio.QueueEmpty:
pass
try:
queue.put_nowait(event_obj)
except asyncio.QueueFull:
pass
elif msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.ERROR,
):
raise aiohttp.ClientConnectionError(
f"WS closed: {msg.type.name}"
)
else:
# PING/PONG handled by aiohttp autoping=True;
# BINARY/CONTINUATION are not used by HA today.
# Log at debug so a future protocol change is
# visible without spamming production logs.
_LOGGER.debug(
"Ignored WS message of type %s", msg.type.name,
)
except HomeAssistantAuthError as err:
# Fatal — caller must fix the access token. Reraise so
# the provider can mark itself unhealthy.
if on_status_change is not None:
on_status_change("disconnected", _redact(f"auth: {err}"))
raise
except asyncio.CancelledError:
if on_status_change is not None:
on_status_change("disconnected", "cancelled")
raise
except Exception as err: # noqa: BLE001
redacted = _redact(str(err))
if on_status_change is not None:
on_status_change("disconnected", redacted)
delay = min(
_RECONNECT_BASE_SECONDS * (2 ** attempt),
_RECONNECT_MAX_SECONDS,
)
delay *= 1 + random.uniform(-_RECONNECT_JITTER_RATIO, _RECONNECT_JITTER_RATIO)
_LOGGER.warning(
"HA WS connection lost (%s); reconnecting in %.1fs",
redacted, delay,
)
attempt = min(attempt + 1, 10)
await asyncio.sleep(delay)
finally:
drain_task.cancel()
# Drain task may finish via CancelledError (normal) or via an
# unhandled exception thrown by on_event. Either way is fine here
# — we're tearing down. Split the two cases for clarity rather
# than catching `Exception` + `CancelledError` in one clause.
try:
await drain_task
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001
_LOGGER.exception("HA drain task raised during shutdown")
# ---------------------------------------------------------------------------
# Multi-command session
# ---------------------------------------------------------------------------
class HomeAssistantSession:
"""A multi-command HA WS session bound to a single authenticated socket.
Created via :meth:`HomeAssistantWSClient.session`. Use when you need to
issue several commands in a row sharing the connection saves the TCP
+ WS + auth round trips for every command after the first.
The session forwards id assignment to the parent client's monotonic
counter so ids stay unique across all sessions sharing the same client.
"""
def __init__(
self,
client: HomeAssistantWSClient,
ws: aiohttp.ClientWebSocketResponse,
) -> None:
self._client = client
self._ws = ws
async def send(self, payload: dict[str, Any], timeout: float = 15.0) -> Any:
"""Send one command and wait for its ``result`` envelope."""
msg_id = await self._client._send_command(self._ws, payload)
return await self._client._await_result(self._ws, msg_id, timeout=timeout)
async def get_states(self) -> list[dict[str, Any]]:
result = await self.send({"type": "get_states"})
return list(result or [])
async def get_area_registry(self) -> list[dict[str, Any]]:
result = await self.send({"type": "config/area_registry/list"})
return list(result or [])
async def get_entity_registry(self) -> list[dict[str, Any]]:
result = await self.send({"type": "config/entity_registry/list"})
return list(result or [])
@@ -0,0 +1,267 @@
"""Home Assistant event parser — HA WebSocket event dict -> ServiceEvent.
The HA event bus delivers events with this envelope:
.. code-block:: json
{
"id": 7,
"type": "event",
"event": {
"event_type": "state_changed",
"data": { ... event-type-specific ... },
"origin": "LOCAL",
"time_fired": "2026-05-13T12:34:56.789Z",
"context": { ... }
}
}
The parser accepts the inner ``event`` dict (the WS client strips the outer
envelope before calling us) and emits a :class:`ServiceEvent` ready for the
existing dispatch path. Areas are looked up via an optional ``area_lookup``
mapping so the parser stays pure the WS client maintains the registry
cache and passes its current snapshot on each call.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Any
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.providers.base import ServiceProviderType
_LOGGER = logging.getLogger(__name__)
# Defensive caps for fields that get persisted to the event_log row. Home
# Assistant's own constraints keep entity ids well under 70 chars, but a
# misbehaving custom integration could emit kilobyte-sized strings that
# would bloat the JSON details column.
_MAX_ENTITY_ID_LEN = 255
_MAX_EVENT_DATA_BYTES = 4096
def _parse_time_fired(raw: Any) -> datetime:
"""Parse HA's ``time_fired`` ISO string, falling back to now() on garbage.
HA always sends UTC with a ``Z`` suffix or explicit ``+00:00``. Datetime
parsing is wrapped because a malformed payload should not break the
pipeline better to dispatch with a slightly-off timestamp than drop.
"""
if isinstance(raw, str):
try:
# ``datetime.fromisoformat`` accepts ``+00:00`` natively; rewrite
# the trailing ``Z`` since pre-3.11 stdlib rejects it.
cleaned = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
return datetime.fromisoformat(cleaned)
except ValueError:
_LOGGER.debug("Unparseable HA time_fired %r, using now()", raw)
return datetime.now(timezone.utc)
def _domain_of(entity_id: str) -> str:
"""Return the HA domain prefix (``light.kitchen`` -> ``light``)."""
if "." in entity_id:
return entity_id.split(".", 1)[0]
return ""
def _friendly_name(state_obj: dict[str, Any] | None, entity_id: str) -> str:
"""Pull ``friendly_name`` from attributes or fall back to entity_id."""
if not state_obj:
return entity_id
attrs = state_obj.get("attributes") or {}
name = attrs.get("friendly_name")
return str(name) if name else entity_id
def parse_event(
ha_event: dict[str, Any],
provider_name: str,
area_lookup: dict[str, str] | None = None,
) -> ServiceEvent | None:
"""Parse one HA event dict into a :class:`ServiceEvent`.
Returns None for malformed payloads (missing ``event_type`` etc.) so the
caller can drop without raising. Genuine network/parsing exceptions
bubble up only known-bad payload shapes return None.
"""
if not isinstance(ha_event, dict):
return None
event_type_raw = ha_event.get("event_type")
if not isinstance(event_type_raw, str):
return None
data = ha_event.get("data") or {}
timestamp = _parse_time_fired(ha_event.get("time_fired"))
area_lookup = area_lookup or {}
if event_type_raw == "state_changed":
return _parse_state_changed(data, timestamp, provider_name, area_lookup)
if event_type_raw == "automation_triggered":
return _parse_automation_triggered(data, timestamp, provider_name)
if event_type_raw == "call_service":
return _parse_call_service(data, timestamp, provider_name)
# Everything else maps to the generic "event_fired" slot. Tracking
# configs decide whether to enable this loud catch-all.
return _parse_generic_event(event_type_raw, data, timestamp, provider_name)
def _parse_state_changed(
data: dict[str, Any],
timestamp: datetime,
provider_name: str,
area_lookup: dict[str, str],
) -> ServiceEvent | None:
entity_id = data.get("entity_id")
if not isinstance(entity_id, str):
return None
entity_id = entity_id[:_MAX_ENTITY_ID_LEN]
old_state_obj = data.get("old_state") if isinstance(data.get("old_state"), dict) else None
new_state_obj = data.get("new_state") if isinstance(data.get("new_state"), dict) else None
# ``new_state`` is None when an entity is removed — surface it as a
# transition to the literal string "removed" so templates can branch.
old_state_val = old_state_obj.get("state") if old_state_obj else None
new_state_val = new_state_obj.get("state") if new_state_obj else "removed"
attributes = (new_state_obj or {}).get("attributes") or {}
friendly_name = _friendly_name(new_state_obj or old_state_obj, entity_id)
domain = _domain_of(entity_id)
extra: dict[str, Any] = {
"entity_id": entity_id,
"friendly_name": friendly_name,
"domain": domain,
"old_state": old_state_val,
"new_state": new_state_val,
"attributes": attributes,
"device_class": attributes.get("device_class"),
"unit_of_measurement": attributes.get("unit_of_measurement"),
"area": area_lookup.get(entity_id),
"ha_event_type": "state_changed",
}
if new_state_obj and "last_changed" in new_state_obj:
extra["last_changed"] = new_state_obj["last_changed"]
if new_state_obj and "last_updated" in new_state_obj:
extra["last_updated"] = new_state_obj["last_updated"]
return ServiceEvent(
event_type=EventType.HA_STATE_CHANGED,
provider_type=ServiceProviderType.HOME_ASSISTANT,
provider_name=provider_name,
collection_id=entity_id,
collection_name=friendly_name,
timestamp=timestamp,
extra=extra,
)
def _parse_automation_triggered(
data: dict[str, Any],
timestamp: datetime,
provider_name: str,
) -> ServiceEvent | None:
entity_id = data.get("entity_id")
if isinstance(entity_id, str):
entity_id = entity_id[:_MAX_ENTITY_ID_LEN]
automation_name = data.get("name") or (entity_id if isinstance(entity_id, str) else "automation")
source = data.get("source") or ""
collection_id = entity_id if isinstance(entity_id, str) else f"automation.{automation_name}"
collection_id = collection_id[:_MAX_ENTITY_ID_LEN]
return ServiceEvent(
event_type=EventType.HA_AUTOMATION_TRIGGERED,
provider_type=ServiceProviderType.HOME_ASSISTANT,
provider_name=provider_name,
collection_id=collection_id,
collection_name=str(automation_name),
timestamp=timestamp,
extra={
"entity_id": entity_id,
"automation_name": str(automation_name),
"trigger_source": str(source),
"ha_event_type": "automation_triggered",
},
)
def _parse_call_service(
data: dict[str, Any],
timestamp: datetime,
provider_name: str,
) -> ServiceEvent | None:
domain = data.get("domain")
service = data.get("service")
if not isinstance(domain, str) or not isinstance(service, str):
return None
domain = domain[:_MAX_ENTITY_ID_LEN]
service = service[:_MAX_ENTITY_ID_LEN]
service_data = data.get("service_data") if isinstance(data.get("service_data"), dict) else {}
qualified = f"{domain}.{service}"
target_entity = None
if isinstance(service_data, dict):
raw_target = service_data.get("entity_id")
if isinstance(raw_target, str):
target_entity = raw_target
elif isinstance(raw_target, list) and raw_target:
target_entity = ", ".join(str(x) for x in raw_target)
return ServiceEvent(
event_type=EventType.HA_SERVICE_CALLED,
provider_type=ServiceProviderType.HOME_ASSISTANT,
provider_name=provider_name,
collection_id=qualified,
collection_name=qualified,
timestamp=timestamp,
extra={
"service_domain": domain,
"service_name": service,
"service_called": qualified,
"service_data": service_data,
"target_entity": target_entity,
"ha_event_type": "call_service",
},
)
def _parse_generic_event(
event_type_raw: str,
data: dict[str, Any],
timestamp: datetime,
provider_name: str,
) -> ServiceEvent | None:
event_type_raw = event_type_raw[:_MAX_ENTITY_ID_LEN]
# Cap the serialized payload so a custom HA integration that emits
# a multi-megabyte event_data dict doesn't blow up the event_log JSON
# column. Templates can still reference fields up to the cap; beyond it
# the dict is replaced with a marker so the limit is visible to authors.
capped_data: Any = data
try:
serialized = json.dumps(data, default=str)
except (TypeError, ValueError):
# Unserializable payload — keep the dict in-memory so templates can
# still read scalar fields, but flag the size as 0 to avoid surprises.
serialized = ""
if len(serialized.encode("utf-8")) > _MAX_EVENT_DATA_BYTES:
capped_data = {
"_truncated": True,
"_original_size_bytes": len(serialized.encode("utf-8")),
"_note": f"event_data exceeded {_MAX_EVENT_DATA_BYTES}B and was dropped",
}
return ServiceEvent(
event_type=EventType.HA_EVENT_FIRED,
provider_type=ServiceProviderType.HOME_ASSISTANT,
provider_name=provider_name,
collection_id=event_type_raw,
collection_name=event_type_raw,
timestamp=timestamp,
extra={
"ha_event_type": event_type_raw,
"event_data": capped_data,
},
)
@@ -0,0 +1,323 @@
"""Home Assistant service provider — WebSocket subscription based.
Unlike polling providers (Immich, NUT, Google Photos) and webhook providers
(Gitea, Planka), the HA provider maintains a long-lived WebSocket connection
to the HA server and pushes events into the dispatch pipeline as they
arrive. The lifecycle is owned by the server-side subscription manager
(see ``services/ha_subscription.py``).
"""
from __future__ import annotations
import logging
from typing import Any, Callable
import aiohttp
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.providers.base import (
EventEmitCallback,
ServiceProvider,
ServiceProviderType,
)
from notify_bridge_core.templates.variables import TemplateVariableDefinition
from .client import HomeAssistantWSClient
from .event_parser import parse_event
# Status callback signature: ``(state, detail)`` where ``state`` is one of
# ``"connected"`` / ``"disconnected"`` and ``detail`` is an optional already-
# redacted reason string (or None on connect).
StatusChangeCallback = Callable[[str, str | None], None]
_LOGGER = logging.getLogger(__name__)
# Home Assistant template variables exposed to Jinja2.
HOME_ASSISTANT_VARIABLES: list[TemplateVariableDefinition] = [
TemplateVariableDefinition(
name="entity_id",
type="string",
description="HA entity id (e.g. light.kitchen)",
example="light.kitchen",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="friendly_name",
type="string",
description="Human-readable entity name from attributes.friendly_name",
example="Kitchen Light",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="domain",
type="string",
description="HA domain prefix of the entity (e.g. light, sensor, binary_sensor)",
example="light",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="old_state",
type="string",
description="Previous state string before the change",
example="off",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="new_state",
type="string",
description="New state string (literal 'removed' when entity was deleted)",
example="on",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="attributes",
type="dict",
description="Full attributes dict of the new state",
example='{"brightness": 255, "color_mode": "brightness"}',
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="device_class",
type="string",
description="Device class from attributes (motion, door, temperature, ...)",
example="motion",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="unit_of_measurement",
type="string",
description="Unit suffix for numeric sensors",
example="°C",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="area",
type="string",
description="Area name from the HA area registry (empty when not assigned)",
example="Kitchen",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="last_changed",
type="string",
description="ISO timestamp of last state change",
example="2026-05-13T12:34:56.789+00:00",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="last_updated",
type="string",
description="ISO timestamp of last attribute or state update",
example="2026-05-13T12:34:56.789+00:00",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="automation_name",
type="string",
description="Automation name (automation_triggered events)",
example="Front Door Notification",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="trigger_source",
type="string",
description="Why an automation fired (automation_triggered events)",
example="state of binary_sensor.front_door",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="service_called",
type="string",
description="Qualified service name (call_service events)",
example="light.turn_on",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="service_domain",
type="string",
description="Service domain (call_service events)",
example="light",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="service_name",
type="string",
description="Service name within domain (call_service events)",
example="turn_on",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="service_data",
type="dict",
description="Service payload (call_service events)",
example='{"entity_id": "light.kitchen"}',
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="target_entity",
type="string",
description="entity_id targeted by a service call (comma-joined for multi-target)",
example="light.kitchen",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="ha_event_type",
type="string",
description="Raw HA event_type (state_changed, automation_triggered, ...)",
example="state_changed",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="event_data",
type="dict",
description="Raw event data (generic event_fired events)",
example='{"key": "value"}',
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
]
# Default event types subscribed to when the user does not override. Only
# state_changed is on by default — the others are loud and opt-in via the
# tracking-config event checkboxes.
DEFAULT_HA_EVENT_TYPES: tuple[str, ...] = ("state_changed",)
class HomeAssistantServiceProvider(ServiceProvider):
"""Home Assistant WebSocket subscription provider."""
provider_type = ServiceProviderType.HOME_ASSISTANT
supports_subscription = True
def __init__(
self,
session: aiohttp.ClientSession,
url: str,
access_token: str,
verify_tls: bool = True,
event_types: list[str] | None = None,
name: str = "Home Assistant",
) -> None:
self._client = HomeAssistantWSClient(
session=session,
base_url=url,
access_token=access_token,
verify_tls=verify_tls,
)
self._name = name
self._event_types = list(event_types) if event_types else list(DEFAULT_HA_EVENT_TYPES)
# ``_area_lookup`` is refreshed on every (re)connect by run_subscription's
# ``refresh_areas`` hook so the parser can enrich state_changed events
# with the current area name.
self._area_lookup: dict[str, str] = {}
@property
def client(self) -> HomeAssistantWSClient:
return self._client
async def connect(self) -> bool:
ok, _ = await self._client.test_connection()
return ok
async def disconnect(self) -> None:
# Session lifecycle is managed by the caller; the WS connection is
# owned by run_subscription which exits on cancel.
return None
async def poll(
self,
collection_ids: list[str],
tracker_state: dict[str, Any],
) -> tuple[list[ServiceEvent], dict[str, Any]]:
# Subscription-based ingest. The polling scheduler MUST NOT call us
# — the subscription manager owns this provider's lifecycle instead.
return [], tracker_state
async def subscribe(
self,
emit: EventEmitCallback,
on_status_change: StatusChangeCallback | None = None,
) -> None:
async def _on_event(ha_event: dict[str, Any]) -> None:
event = parse_event(
ha_event,
provider_name=self._name,
area_lookup=self._area_lookup,
)
if event is None:
return
await emit(event)
async def _refresh_areas() -> dict[str, str]:
try:
self._area_lookup = await self._client.get_entity_to_area_lookup()
except Exception: # noqa: BLE001
# Best-effort: keep the previous lookup on failure.
_LOGGER.exception("Failed to refresh HA area lookup")
return self._area_lookup
await self._client.run_subscription(
on_event=_on_event,
event_types=self._event_types,
refresh_areas=_refresh_areas,
on_status_change=on_status_change,
)
def get_available_variables(self) -> list[TemplateVariableDefinition]:
return list(HOME_ASSISTANT_VARIABLES)
def get_provider_config_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Home Assistant base URL (http://homeassistant.local:8123)",
"example": "http://homeassistant.local:8123",
},
"access_token": {
"type": "string",
"description": "Long-lived access token (HA Profile -> Long-Lived Access Tokens)",
"secret": True,
},
"verify_tls": {
"type": "boolean",
"description": "Validate TLS certificate. Disable only for self-signed HA setups on trusted networks.",
"default": True,
},
"event_types": {
"type": "array",
"items": {"type": "string"},
"description": "HA event types to subscribe to. Defaults to ['state_changed'].",
"default": list(DEFAULT_HA_EVENT_TYPES),
},
},
"required": ["url", "access_token"],
}
async def list_collections(self) -> list[dict[str, Any]]:
"""Return the current entity list for the entity-picker UI."""
try:
states = await self._client.get_states()
except Exception as err: # noqa: BLE001
_LOGGER.warning("Could not fetch HA states: %s", err)
return []
out: list[dict[str, Any]] = []
for state in states:
entity_id = state.get("entity_id")
if not isinstance(entity_id, str):
continue
attrs = state.get("attributes") or {}
out.append({
"id": entity_id,
"name": attrs.get("friendly_name") or entity_id,
"state": state.get("state"),
"domain": entity_id.split(".", 1)[0] if "." in entity_id else "",
})
return out
async def test_connection(self) -> dict[str, Any]:
ok, message = await self._client.test_connection()
return {"ok": ok, "message": message}
@@ -29,10 +29,21 @@ _LOGGER = logging.getLogger(__name__)
# calls per poll cycle. TTL is conservative (1h) and a hashed key keeps the
# raw api_key out of dict keys in case of a memory dump.
_USERS_CACHE_TTL_SECONDS = 3600
_users_cache_lock = asyncio.Lock()
# Lazy init: ``asyncio.Lock()`` at module import binds to whichever event
# loop is current at import time (often none, or the wrong one when tests
# spin up dedicated loops). Defer creation to first use.
_users_cache_lock: asyncio.Lock | None = None
_users_cache: dict[str, tuple[float, dict[str, str]]] = {}
def _get_users_cache_lock() -> asyncio.Lock:
"""Return the module users-cache lock, creating it on first call."""
global _users_cache_lock
if _users_cache_lock is None:
_users_cache_lock = asyncio.Lock()
return _users_cache_lock
def _users_cache_key(url: str, api_key: str) -> str:
digest = hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()
return digest[:32]
@@ -51,7 +62,7 @@ async def _get_cached_users(
if entry is not None and (now - entry[0]) < _USERS_CACHE_TTL_SECONDS:
return entry[1]
async with _users_cache_lock:
async with _get_users_cache_lock():
# Re-check after acquiring the lock — another coroutine may have
# refreshed the entry while we waited.
entry = _users_cache.get(key)
@@ -200,10 +200,28 @@ class NutServiceProvider(ServiceProvider):
try:
for ups_name in collection_ids:
prev = tracker_state.get(ups_name, {})
# First-ever observation has no baseline — emitting transition
# events for whatever flags the device happens to carry would
# spam the user with "OB"/"LB"/"REPLBATT" alerts on every fresh
# tracker even when nothing changed. Seed state silently and
# skip event emission until the next poll provides a baseline.
is_first_observation = ups_name not in tracker_state
try:
variables = await client.list_var(ups_name)
data = NutUpsData.from_variables(ups_name, variables)
if is_first_observation:
new_state[ups_name] = {
"name": data.description or ups_name,
"status": data.status,
"battery_charge": data.battery_charge,
"comms_ok": True,
"asset_ids": [],
"pending_asset_ids": [],
"shared": False,
}
continue
# Check for comms restored
if not prev.get("comms_ok", True):
events.append(self._make_event(
@@ -0,0 +1 @@
Terse one-line health summary
@@ -0,0 +1 @@
Show available commands
@@ -0,0 +1 @@
Reset a failure counter (tracker:&lt;id&gt;, target:&lt;id&gt;, or all)
@@ -0,0 +1 @@
Show current bridge health counters
@@ -0,0 +1 @@
Show configured alert thresholds
@@ -0,0 +1,5 @@
{%- if healthy -%}
✅ {{ summary }}
{%- else -%}
🚨 {{ summary }}
{%- endif %}
@@ -0,0 +1,5 @@
🩺 <b>Available commands:</b>
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- if cmd.usage %} ↳ {{ cmd.usage }}{% endif %}
{%- endfor %}
@@ -0,0 +1 @@
⏳ Too many requests. Please wait {{ wait }}s before trying again.
@@ -0,0 +1,14 @@
{%- if success %}
✅ <b>Counter reset</b>
{%- if subject_type == 'all' %}
Cleared {{ previous_count }} of your failure counters (trackers + targets).
{%- else %}
{{ subject_type|capitalize }} <b>{{ subject_name }}</b>{% if subject_id %} (id <code>{{ subject_id }}</code>){% endif %}
Previous count: <b>{{ previous_count }}</b> → 0
{%- endif %}
{%- else %}
❌ <b>Reset failed</b>
{%- if error_message %}
<i>{{ error_message }}</i>
{%- endif %}
{%- endif %}
@@ -0,0 +1,2 @@
👋 Hi! I'm your Notify Bridge bot for <b>Bridge Self-Monitoring</b>.
Use /help to see available commands.
@@ -0,0 +1,28 @@
🩺 <b>Bridge Status</b>
{%- if poll_failures %}
🚨 <b>Tracker poll failures</b>
{%- for f in poll_failures %}
• <b>{{ f.tracker_name }}</b> (id <code>{{ f.tracker_id }}</code>) — {{ f.count }} consecutive
{%- endfor %}
{%- endif %}
{%- if deferred_pending is none %}
⏳ <b>Deferred backlog</b>
Pending: <b>unknown</b> (DB unavailable) · Threshold: {{ deferred_threshold }}
{%- elif deferred_pending %}
⏳ <b>Deferred backlog</b>
Pending: <b>{{ deferred_pending }}</b> · Threshold: {{ deferred_threshold }}
{%- endif %}
{%- if target_failures %}
📡 <b>Target send failures</b>
{%- for f in target_failures %}
• <b>{{ f.target_name }}</b> (id <code>{{ f.target_id }}</code>) — {{ f.count }} consecutive
{%- endfor %}
{%- endif %}
{%- if not poll_failures and not target_failures and deferred_pending == 0 %}
✅ All counters at zero. Nothing to report.
{%- endif %}
@@ -0,0 +1,4 @@
⚙️ <b>Bridge Thresholds</b>
Tracker poll failures: <b>{{ poll_failure_threshold }}</b>
Deferred backlog: <b>{{ deferred_backlog_threshold }}</b>
Target send failures: <b>{{ target_failure_threshold }}</b>
@@ -0,0 +1 @@
/reset tracker:42 (or target:&lt;id&gt;, or all)
@@ -0,0 +1,9 @@
🗺️ <b>Areas</b>
{%- if areas %}
{%- for a in areas %}
<b>{{ a.name }}</b> — {{ a.entity_count }} entity(ies)
{%- endfor %}
<i>Total: {{ total }}</i>
{%- else %}
No areas configured in Home Assistant.
{%- endif %}
@@ -0,0 +1 @@
List HA areas with entity counts
@@ -0,0 +1 @@
List entities (optional glob)
@@ -0,0 +1 @@
Show full state for one entity
@@ -0,0 +1 @@
Show Home Assistant connection status
@@ -0,0 +1,11 @@
🔍 <b>Entities</b>{% if glob %} matching <code>{{ glob }}</code>{% endif %}
{%- if entities %}
{%- for e in entities %}
<code>{{ e.entity_id }}</code> — <b>{{ e.state }}</b>{% if e.unit_of_measurement %} {{ e.unit_of_measurement }}{% endif %}{% if e.friendly_name and e.friendly_name != e.entity_id %} · <i>{{ e.friendly_name }}</i>{% endif %}
{%- endfor %}
{%- if total > shown %}
<i>Showing {{ shown }} of {{ total }} — refine the glob to narrow further.</i>
{%- endif %}
{%- else %}
No entities matched.
{%- endif %}
@@ -0,0 +1,4 @@
🏠 <b>Home Assistant commands</b>
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- endfor %}
@@ -0,0 +1 @@
⏳ Too many requests. Please wait a moment and try again.
@@ -0,0 +1,3 @@
🏠 <b>Home Assistant bot</b>
Send /help to see what I can do.
@@ -0,0 +1,27 @@
{%- if found %}
🏠 <b>{{ friendly_name }}</b>
<code>{{ entity_id }}</code>
State: <b>{{ state }}</b>{% if unit_of_measurement %} {{ unit_of_measurement }}{% endif %}
{%- if device_class %}
Class: <i>{{ device_class }}</i>
{%- endif %}
{%- if last_changed %}
Last changed: <i>{{ last_changed }}</i>
{%- endif %}
{%- if attributes %}
<b>Attributes</b>
{%- for key, value in attributes.items() %}
• {{ key }}: <code>{{ (value if value is string else value | tojson) | string | truncate(120) }}</code>
{%- endfor %}
{%- if hidden_attr_count and hidden_attr_count > 0 %}
<i>… and {{ hidden_attr_count }} more attribute(s) hidden (sensitive or truncated for length)</i>
{%- endif %}
{%- endif %}
{%- elif reason == 'missing_arg' %}
Usage: <code>/state &lt;entity_id&gt;</code>
{%- elif reason == 'not_found' %}
Entity <code>{{ entity_id }}</code> not found.
{%- else %}
Could not load state for <code>{{ entity_id }}</code>: {{ error }}
{%- endif %}
@@ -0,0 +1,8 @@
🏠 <b>{{ provider_name }}</b>
{%- if ok %}
<i>Connected</i> · {{ url }}
Entities: <b>{{ entity_count }}</b> · Areas: <b>{{ area_count }}</b>
{%- else %}
<i>Disconnected</i>
<code>{{ message }}</code>
{%- endif %}
@@ -0,0 +1 @@
/entities [glob] e.g. /entities light.*
@@ -0,0 +1 @@
/state &lt;entity_id&gt; e.g. /state light.kitchen
@@ -64,6 +64,24 @@ PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = {
# Usage example slots
"usage_latest", "usage_search", "usage_random",
],
"home_assistant": [
# Response templates
"start", "help", "status", "entities", "state", "areas",
"rate_limited", "no_results",
# Description slots
"desc_help", "desc_status", "desc_entities", "desc_state", "desc_areas",
# Usage examples
"usage_entities", "usage_state",
],
"bridge_self": [
# Response templates
"start", "help", "status", "thresholds", "reset", "health",
"rate_limited", "no_results",
# Description slots
"desc_help", "desc_status", "desc_thresholds", "desc_reset", "desc_health",
# Usage examples
"usage_reset",
],
}
# Backward-compatible aliases
@@ -0,0 +1 @@
Краткая однострочная сводка состояния
@@ -0,0 +1 @@
Показать доступные команды
@@ -0,0 +1 @@
Сбросить счётчик сбоев (tracker:&lt;id&gt;, target:&lt;id&gt; или all)
@@ -0,0 +1 @@
Показать счётчики состояния моста
@@ -0,0 +1 @@
Показать настроенные пороги оповещений

Some files were not shown because too many files have changed in this diff Show More