Compare commits

...

7 Commits

Author SHA1 Message Date
alexei.dolgolyov 645331d320 chore: release v0.2.1
Release / release (push) Successful in 1m27s
2026-04-22 02:35:38 +03:00
alexei.dolgolyov 6c3dd67c1b feat(tracking): per-config quiet hours with app-level IANA timezone
Add quiet_hours_enabled/start/end to TrackingConfig (HH:MM strings
interpreted in the app-level timezone AppSetting). The dispatch path
loads the app timezone once per run and passes it through
event_allowed_by_config -> in_quiet_hours, so overnight windows like
22:00-07:00 work correctly in any IANA tz.

Frontend exposes a Timezone field under Settings and a Quiet Hours
section on the Immich tracking-config form with time-picker inputs.
2026-04-22 02:31:48 +03:00
alexei.dolgolyov 56993d2ca3 fix(security,perf): harden restore, CSRF, token_version + perf pass
Security
- Sign pending_restore.json (SHA256 stored in AppSetting, verified on
  startup apply) + refuse path outside data_dir, tighten to 0600.
- Require same-origin Origin/Referer on POST /api/backup/apply-restart —
  Bearer-in-localStorage is CSRF-reachable from any XSS'd admin tab.
- Bump token_version on role/username change and admin password reset so
  demoted admins lose admin in already-issued JWTs.  Guard last-admin
  TOCTOU via COUNT + post-commit re-check that rolls back a race.
- SSRF guard (validate_outbound_url) in ImmichClient.__init__ and the
  external_domain setter — admin-mutable URLs were bypassing the check
  that webhook/slack/discord paths already used.  Dev restart script now
  sets NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 so homelab Immich still works.
- Redact + cap Immich error bodies to ~120 chars before they flow into
  ActionExecution.error / EventLog.details (both UI-visible).
- Deny-list sensitive keys (api_key / token / secret / password /
  authorization / cookie / ...) in template-context merges so a rogue
  template can't exfiltrate provider creds via {{ api_key }}.
- Cap user-controlled Immich search params (query ≤256, person_ids ≤50,
  size ≤100) so a Telegram listener can't DoS upstream.
- Stream upload reads with running byte counter + content-length precheck
  instead of buffering the full body and then rejecting.
- Log Telegram parse_mode fallbacks instead of swallowing silently;
  template escape bugs now surface in server logs.
- Rollback partial imports on pending-restore failure (error recorded on
  a fresh session).

Performance
- Fix N+1 in _refresh_telegram_chat_titles: single IN query instead of
  session.get per chat.
- Parallelize album + shared-link fetches in test_dispatch (asyncio.gather)
  and per-receiver Telegram test sends in notifier (semaphore 5).
- Early-exit collect_scheduled_assets(limit=0) so the periodic-summary
  test path skips full per-album filter/sample (was O(album_assets)).
- Emit explicit CREATE INDEX IF NOT EXISTS for event_log user_id /
  action_id / provider_id so the first boot after upgrade isn't left
  unindexed for the dashboard query.
- Add AbortController timeout (120s) to fetchAuth so uploads/downloads
  don't hang indefinitely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:28:55 +03:00
alexei.dolgolyov fe92b206b7 chore: release v0.2.0
Release / release (push) Successful in 1m20s
2026-04-22 01:35:24 +03:00
alexei.dolgolyov cf4976da2f fix(telegram): load chats/listeners before expanding to fix slide animation height 2026-04-22 01:29:44 +03:00
alexei.dolgolyov 80c034d2af fix(test-dispatch): fall back to tracker defaults, surface soft errors
- dispatch_test_notification now resolves tracking_config / template_config
  from the tracker's default_* fields when the per-link override is unset,
  matching what load_link_data does for the real watcher. Previously
  periodic/scheduled/memory tests silently failed with "no template
  defined" whenever the user configured the template config at the
  tracker level instead of on each link (the UI's normal default).
- Distinguish the two missing-template cases in the returned error
  ("no template config linked" vs. "slot missing in linked config").
- Frontend testTrackerTarget now treats {success:false,error:"..."} in a
  2xx body as a failure — previously any 2xx flashed a success snack so
  users never saw the real reason their test didn't deliver.
2026-04-22 01:25:35 +03:00
alexei.dolgolyov a7a2b4efa4 feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events
Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
  allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
  through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
  / search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
  to POST /api/search/metadata with personIds (fixes /person command and
  auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
  image when missing (falls back to any asset type); failures do not fail the
  rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
  + backfill. Status query filters by user_id directly; Immich/webhook paths
  emit user_id explicitly. action_runner writes an action_success/partial/
  failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
  tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
  (ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
  pending_restore.json; lifespan hook applies on next startup and archives
  under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
  shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
  Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
  (limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
  TelegramChat.language_override per chat instead of applying the first
  receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
  and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
  save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
  deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
  track_assets_removed default False.

Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
  labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
  create forms (trackers, command-trackers, targets, template/command
  configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
  multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
  restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
  inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.

Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
  notification_tracker).
- command_tracker_listener: + allowed_album_ids.
2026-04-22 01:13:11 +03:00
67 changed files with 3084 additions and 612 deletions
+24 -228
View File
@@ -1,245 +1,41 @@
## v0.1.0 (2026-04-21) ## v0.2.1 (2026-04-22)
First public release of **Notify Bridge** — a self-hosted bridge that turns events from home-lab services into rich, localized notifications and accepts chat commands in return. Security-focused release on top of v0.2.0. Hardens the restore/backup flow,
CSRF/SSRF surfaces, JWT revocation on role change, and template-context
### Highlights leakage; adds a new **per-tracking-config quiet hours** feature with
app-level IANA timezone support; plus a handful of performance fixes.
- Six service providers out of the box: Immich, Google Photos, Planka, Gitea, NUT (Network UPS Tools), plus a generic JSONPath webhook provider and a built-in Scheduler.
- Multi-channel delivery: Telegram, Discord, Slack, ntfy, Matrix, Email, and a broadcast target that fans out to multiple receivers.
- Provider-agnostic bot command system with rich, locale-aware command templates (Telegram + Matrix + Email bots).
- Jinja2 slot-based template system with autocomplete, live preview, locale switching, and a sandbox with timeout protection.
- Actions engine for scheduled mutations on external services (e.g. timed Immich operations).
- Dashboard with filtered charts, grouped navigation tree with badges, Ctrl+K search palette, cross-entity crosslinks, card-highlight navigation, and a global provider filter.
- Docker deployment with a Gitea CI/CD pipeline, full backup & restore, webhook payload history, person excludes for auto-organize rules, and SSRF-hardened outgoing requests.
---
### Features ### Features
#### Service providers - **Per-tracking-config quiet hours with app-level IANA timezone** — new `Timezone` app setting (defaults to `UTC`) and a `Quiet Hours` section on the Immich tracking-config form. HH:MM windows (including overnight, e.g. `22:0007:00`) are interpreted in the configured timezone and suppress all notifications for that tracker. ([6c3dd67](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6c3dd67))
- Phase 3 — Immich service provider ([cc02558](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cc02558))
- Google Photos provider backend + API hardening ([307871c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/307871c))
- Planka service provider with full notification and command support ([0fde3c6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0fde3c6))
- Gitea as webhook-based service provider ([6d28cfb](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6d28cfb))
- NUT (Network UPS Tools) service provider + provider-agnostic UI ([68ac13b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/68ac13b))
- Generic webhook provider with JSONPath payload extraction ([616b221](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/616b221))
- Scheduler provider + multi-provider UX fixes ([0562f78](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0562f78))
#### Notification targets & delivery ### Security
- Discord / Slack / ntfy / Matrix targets, command templates, delete protection, email/matrix bots ([3e3a6f0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3e3a6f0))
- Broadcast notification target + UX improvements ([d8ecb60](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8ecb60))
- Provider-strict configs, slot-based templates, broadcast targets, email bots, command templates ([846d480](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/846d480))
- Receiver OOP hierarchy with per-receiver locale resolution ([1cfa728](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1cfa728))
- Rewrite asset URLs to internal provider URL for LAN fetching ([ad2fd33](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ad2fd33))
#### Bots & commands - **Signed & verified pending-restore bundles** — SHA256 stored in `AppSetting` and checked on startup apply; files outside `data_dir` are refused and permissions tightened to `0600`. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Telegram commands, app settings, bot polling, webhook handling, UI improvements ([03ec9b3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03ec9b3)) - **Same-origin check on `POST /api/backup/apply-restart`** — Bearer-in-localStorage was CSRF-reachable from any XSS'd admin tab; require matching `Origin`/`Referer`. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Per-chat command toggle, listener name + toggle in bot tab ([b3b6c31](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b3b6c31)) - **JWT `token_version` bumps on demotion** — role/username change and admin password reset now bump `token_version` so already-issued tokens lose admin. Last-admin TOCTOU guarded by `COUNT` + post-commit recheck that rolls back on race. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Rich command templates with public links + media text-first flow ([d0bc767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d0bc767)) - **SSRF guard extended** to `ImmichClient.__init__` and the `external_domain` setter — admin-mutable URLs were bypassing the check that webhook / Slack / Discord paths already used. Dev `scripts/restart-backend.sh` now sets `NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1` so homelab Immich instances still work. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Locale-aware command templates, debounced auto-sync, entity pickers ([1167d13](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1167d13)) - **Redact & cap Immich error bodies** (~120 chars) before they flow into `ActionExecution.error` / `EventLog.details` (both UI-visible). ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Remove hardcoded command templates, enforce template system exclusively ([ddcbfda](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ddcbfda)) - **Deny-list sensitive keys** (`api_key`, `token`, `secret`, `password`, `authorization`, `cookie`, …) in template-context merges so a rogue template cannot exfiltrate provider creds via `{{ api_key }}`. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Cap user-controlled Immich search params** — `query` ≤ 256, `person_ids` ≤ 50, `size` ≤ 100 — so a Telegram listener cannot DoS upstream. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
#### Template system - **Stream upload reads** with a running byte counter + `Content-Length` precheck instead of buffering the full body and then rejecting. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Phase 4 — template system ([f36f070](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f36f070)) - **Log Telegram `parse_mode` fallbacks** instead of swallowing silently — template escape bugs now surface in server logs. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Locale-aware notification templates + UX improvements ([37388c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/37388c4)) - **Rollback partial imports** on pending-restore failure (error recorded on a fresh session). ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Collapsible accordion slots for template editing UX ([b1ab5b8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b1ab5b8))
- Smart video size warnings + Jinja2 template autocomplete ([39bac82](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/39bac82))
- Collapsible chart, paginator controls, localized template slots ([3372761](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3372761))
- Fix template preview links, default chat action, update default templates ([371ea70](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/371ea70))
#### Entities, targets & rules
- Port full CRUD API routes and frontend pages from Immich Watcher ([9eec21a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9eec21a))
- Entity relationship refactor — notification trackers, command system, chat actions ([1d445f3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1d445f3))
- Person excludes for auto-organize rules, backup & restore system ([6b22113](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6b22113))
- Actions system — scheduled mutations on external services ([6a559bf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a559bf))
- Default tracker configs, email validation, expandable target links ([6e35926](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e35926))
- Webhook payload history — store and display recent incoming payloads ([6113a00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6113a00))
- Test menu dropdown, split text/media messages, target settings, provider URL links ([5015e37](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5015e37))
#### UI / navigation / UX
- Phase 7 — frontend restructuring ([9dfd1b7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9dfd1b7))
- Port original frontend UI to Notify Bridge ([c9cab93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c9cab93))
- Grouped nav tree with badges, dashboard events section with filtered chart ([2c740ff](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2c740ff))
- Entity cache system, nav UX improvements, split CLAUDE.md ([563716f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/563716f))
- IconGridSelect, CrossLink, SearchPalette components + entity crosslinks ([06b2463](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/06b2463))
- Card highlight system for cross-entity navigation ([f0f49db](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0f49db))
- Search button in sidebar with Ctrl+K shortcut hint ([637a467](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/637a467))
- EntitySelect palette-style entity picker, replace select dropdowns ([a3a1fe3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a3a1fe3))
- Provider type selector for tracking-configs, use IconGridSelect everywhere ([9d3abd9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9d3abd9))
- Replace all select dropdowns with IconGridSelect, fix EN template seed ([a9bb912](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a9bb912))
- Filter search to IconGridSelect when item count > 4 ([a7829c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7829c4))
- Consistent IconGridSelect sizing + descriptions + filter upgrades ([31584c5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/31584c5))
- Filtering on all entity list pages ([7cbba9d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7cbba9d))
- Filter entity selectors by global provider filter ([c451f3d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c451f3d))
- Chat language display, disabled EntitySelect items, dev scripts ([82e400d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/82e400d))
- API docs link button in sidebar footer ([f90cc36](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f90cc36))
- UX & notification improvements — icons, events, chat names, link validation, templates ([03c5c66](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03c5c66))
- UX improvements — secure webhooks, locale fixes, dynamic languages, UI polish ([734e5c9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/734e5c9))
#### Security & hardening
- Security hardening — SSRF guard, template sandbox timeout, webhook log prune, auth & backup polish ([f0739ca](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0739ca))
- Comprehensive code review fixes — security, performance, quality ([e0bae39](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e0bae39))
- Comprehensive code review fixes + receivers-only architecture ([751097b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/751097b))
#### Deployment
- Docker deployment + Gitea CI/CD workflow ([1ac6a17](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1ac6a17))
#### Foundation
- Phase 9 — HAOS integration planning ([786fe5e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/786fe5e))
- Phase 8 — integration and wiring ([08814e9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/08814e9))
- Phase 6 — database models and server API ([7f99c89](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7f99c89))
- Phase 5 — notification system ([16a41ef](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/16a41ef))
- Phase 2 — core abstractions ([3ed0d8c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3ed0d8c))
- Phase 1 — project scaffolding ([b724447](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b724447))
### Bug Fixes
- Simplify add-target UX — single EntitySelect click to add ([21d8ef7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/21d8ef7))
- Provider-aware collection count labels in tracker list ([c6bb2b5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c6bb2b5))
- NUT template preview + tracking config event checkboxes ([2cc4bf6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2cc4bf6))
- Dashboard provider card shows filtered count, fix provider update 400 ([0702ec7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0702ec7))
- UI polish — overflow, placeholders, dashboard provider card ([4049efe](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4049efe))
- Pass chat_action from target config to Telegram client ([e90c128](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e90c128))
- Remove all transform from stagger/fade animations ([d8a1af0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8a1af0))
- Stagger animation breaking position:fixed overlays ([f9a4ccf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9a4ccf))
- Remove Card hover transform that breaks fixed-position overlays ([bd254de](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bd254de))
- Clipboard copy fallback for non-HTTPS contexts ([c26b71d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c26b71d))
- Nav active state — plain path link not highlighted when sibling query-param link matches ([f64ada5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f64ada5))
- Re-create missing EN default template, provider type as IconGridSelect ([db7aac5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/db7aac5))
- Search palette triggers highlight, restore CSS keyframe blink ([86115f5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/86115f5))
- Switch highlight to global store instead of URL params ([88e21e4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/88e21e4))
- Replace CSS keyframe highlight with direct style pulse for reliability ([f47df93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f47df93))
- Card highlight animation — kill stagger before highlight, keep animation:none on cleanup ([4b59f40](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4b59f40))
- Prevent stagger animation replay after card highlight ends ([4c1d5a8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4c1d5a8))
- Rename bots → telegramBots in targets page to fix undefined reference ([227b9c2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/227b9c2))
- Comprehensive API/UI review — 26 bug fixes and improvements ([91e5cd5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/91e5cd5))
- Remove auto-redirect from API client on 401 ([e43c2ed](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e43c2ed))
- Add auth guard to root layout with setup/login redirects ([7d01ae6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7d01ae6))
- Local fonts via @fontsource, favicon, autocomplete attrs ([f9c41fa](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9c41fa))
### Performance ### Performance
- Rewrite asset URLs to internal provider URL for LAN fetching ([ad2fd33](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ad2fd33))
- Lazy-load @mdi/js to reduce Vite dev server memory usage ([826be4c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/826be4c))
### Refactoring - **Fix N+1** in `_refresh_telegram_chat_titles` — single `IN` query instead of `session.get` per chat. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Comprehensive consistency review — UI/UX, code quality, functional parity ([6e51164](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e51164)) - **Parallelize album & shared-link fetches** in `test_dispatch` via `asyncio.gather`, and per-receiver Telegram test sends in the notifier with a semaphore of 5. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Comprehensive codebase review — security, performance, quality, UX ([b803d00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b803d00)) - **Early-exit `collect_scheduled_assets(limit=0)`** so the periodic-summary test path skips the full per-album filter/sample (was O(album_assets)). ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Provider descriptor registry — eliminate provider-specific hardcoding ([8cb836e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8cb836e)) - **Explicit `CREATE INDEX IF NOT EXISTS`** for `event_log` (`user_id` / `action_id` / `provider_id`) so the first boot after upgrade isn't left unindexed for the dashboard query. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Provider-agnostic bot command system + Gitea commands ([63437c1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/63437c1)) - **Add `AbortController` timeout (120s)** to `fetchAuth` so uploads/downloads don't hang indefinitely. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- Unify test dispatch with real NotificationDispatcher ([d4cb388](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d4cb388))
- Replace favorites checkbox with toggle switch in grid layout ([1a8c95e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1a8c95e))
- Rename /telegram-bots route to /bots ([b525e3e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b525e3e))
---
### Development / Internal
#### CI/Build
- Consolidate release tokens to single DEPLOY_TOKEN, rename redeploy step ([eecc9e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/eecc9e2))
- Sync release workflow with CI/CD docs, add manual build ([c41182f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c41182f))
#### Chores
- Pre-release cleanup ([90bc3cc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/90bc3cc))
- Remove accidentally committed __pycache__ ([0dcca2f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0dcca2f))
--- ---
<details> <details>
<summary>All Commits</summary> <summary>All Commits</summary>
| Hash | Message | Author | - [6c3dd67](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6c3dd67) — feat(tracking): per-config quiet hours with app-level IANA timezone _(alexei.dolgolyov)_
|------|---------|--------| - [56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2) — fix(security,perf): harden restore, CSRF, token_version + perf pass _(alexei.dolgolyov)_
| [90bc3cc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/90bc3cc) | chore: pre-release cleanup | alexei.dolgolyov |
| [eecc9e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/eecc9e2) | ci: consolidate release tokens to single DEPLOY_TOKEN, rename redeploy step | alexei.dolgolyov |
| [f0739ca](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0739ca) | feat: security hardening — SSRF guard, template sandbox timeout, webhook log prune, auth & backup polish | alexei.dolgolyov |
| [734e5c9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/734e5c9) | feat: UX improvements — secure webhooks, locale fixes, dynamic languages, UI polish | alexei.dolgolyov |
| [6b22113](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6b22113) | feat: person excludes for auto-organize rules, backup & restore system | alexei.dolgolyov |
| [6e51164](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e51164) | refactor: comprehensive consistency review — UI/UX, code quality, functional parity | alexei.dolgolyov |
| [6113a00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6113a00) | feat: webhook payload history — store and display recent incoming payloads | alexei.dolgolyov |
| [c41182f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c41182f) | ci: sync release workflow with CI/CD docs, add manual build | alexei.dolgolyov |
| [b803d00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b803d00) | refactor: comprehensive codebase review — security, performance, quality, UX | alexei.dolgolyov |
| [616b221](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/616b221) | feat: generic webhook provider with JSONPath payload extraction | alexei.dolgolyov |
| [307871c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/307871c) | feat: Google Photos provider backend + API hardening | alexei.dolgolyov |
| [3372761](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3372761) | feat: collapsible chart, paginator controls, localized template slots | alexei.dolgolyov |
| [21d8ef7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/21d8ef7) | fix: simplify add-target UX — single EntitySelect click to add | alexei.dolgolyov |
| [6e35926](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e35926) | feat: default tracker configs, email validation, expandable target links | alexei.dolgolyov |
| [d4cb388](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d4cb388) | refactor: unify test dispatch with real NotificationDispatcher | alexei.dolgolyov |
| [1a8c95e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1a8c95e) | refactor: replace favorites checkbox with toggle switch in grid layout | alexei.dolgolyov |
| [b1ab5b8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b1ab5b8) | feat: collapsible accordion slots for template editing UX | alexei.dolgolyov |
| [d0bc767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d0bc767) | feat: rich command templates with public links + media text-first flow | alexei.dolgolyov |
| [f90cc36](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f90cc36) | feat: add API docs link button in sidebar footer | alexei.dolgolyov |
| [ad2fd33](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ad2fd33) | perf: rewrite asset URLs to internal provider URL for LAN fetching | alexei.dolgolyov |
| [d8ecb60](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8ecb60) | feat: broadcast notification target + UX improvements | alexei.dolgolyov |
| [8cb836e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8cb836e) | refactor: provider descriptor registry — eliminate provider-specific hardcoding | alexei.dolgolyov |
| [c6bb2b5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c6bb2b5) | fix: provider-aware collection count labels in tracker list | alexei.dolgolyov |
| [2cc4bf6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2cc4bf6) | fix: NUT template preview + tracking config event checkboxes | alexei.dolgolyov |
| [68ac13b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/68ac13b) | feat: NUT (Network UPS Tools) service provider + provider-agnostic UI | alexei.dolgolyov |
| [c451f3d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c451f3d) | feat: filter entity selectors by global provider filter | alexei.dolgolyov |
| [0702ec7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0702ec7) | fix: dashboard provider card shows filtered count, fix provider update 400 | alexei.dolgolyov |
| [4049efe](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4049efe) | fix: UI polish — overflow, placeholders, dashboard provider card | alexei.dolgolyov |
| [1cfa728](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1cfa728) | feat: Receiver OOP hierarchy with per-receiver locale resolution | alexei.dolgolyov |
| [b3b6c31](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b3b6c31) | feat: per-chat command toggle, listener name + toggle in bot tab | alexei.dolgolyov |
| [37388c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/37388c4) | feat: locale-aware notification templates + UX improvements | alexei.dolgolyov |
| [6a559bf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a559bf) | feat: Actions system — scheduled mutations on external services | alexei.dolgolyov |
| [0fde3c6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0fde3c6) | feat: add Planka service provider with full notification and command support | alexei.dolgolyov |
| [39bac82](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/39bac82) | feat: smart video size warnings + Jinja2 template autocomplete | alexei.dolgolyov |
| [1ac6a17](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1ac6a17) | feat: Docker deployment + Gitea CI/CD workflow | alexei.dolgolyov |
| [e0bae39](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e0bae39) | feat: comprehensive code review fixes — security, performance, quality | alexei.dolgolyov |
| [31584c5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/31584c5) | feat: consistent IconGridSelect sizing + descriptions + filter upgrades | alexei.dolgolyov |
| [82e400d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/82e400d) | feat: chat language display, disabled EntitySelect items, dev scripts | alexei.dolgolyov |
| [e90c128](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e90c128) | fix: pass chat_action from target config to Telegram client | alexei.dolgolyov |
| [d8a1af0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8a1af0) | fix: remove all transform from stagger/fade animations | alexei.dolgolyov |
| [f9a4ccf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9a4ccf) | fix: stagger animation breaking position:fixed overlays | alexei.dolgolyov |
| [bd254de](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bd254de) | fix: remove Card hover transform that breaks fixed-position overlays | alexei.dolgolyov |
| [c26b71d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c26b71d) | fix: clipboard copy fallback for non-HTTPS contexts | alexei.dolgolyov |
| [7cbba9d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7cbba9d) | feat: add filtering to all entity list pages | alexei.dolgolyov |
| [63437c1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/63437c1) | refactor: provider-agnostic bot command system + Gitea commands | alexei.dolgolyov |
| [0562f78](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0562f78) | feat: add Scheduler provider + multi-provider UX fixes | alexei.dolgolyov |
| [6d28cfb](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6d28cfb) | feat: add Gitea as webhook-based service provider | alexei.dolgolyov |
| [1167d13](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1167d13) | feat: locale-aware command templates, debounced auto-sync, entity pickers | alexei.dolgolyov |
| [751097b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/751097b) | feat: comprehensive code review fixes + receivers-only architecture | alexei.dolgolyov |
| [b525e3e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b525e3e) | refactor: rename /telegram-bots route to /bots | alexei.dolgolyov |
| [f64ada5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f64ada5) | fix: nav active state — plain path link not highlighted when sibling query-param link matches | alexei.dolgolyov |
| [826be4c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/826be4c) | perf: lazy-load @mdi/js to reduce Vite dev server memory usage | alexei.dolgolyov |
| [a7829c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7829c4) | feat: add filter search to IconGridSelect when item count > 4 | alexei.dolgolyov |
| [a9bb912](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a9bb912) | feat: replace all select dropdowns with IconGridSelect, fix EN template seed | alexei.dolgolyov |
| [db7aac5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/db7aac5) | fix: re-create missing EN default template, provider type as IconGridSelect | alexei.dolgolyov |
| [9d3abd9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9d3abd9) | feat: add provider type selector to tracking-configs, use IconGridSelect everywhere | alexei.dolgolyov |
| [a3a1fe3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a3a1fe3) | feat: EntitySelect palette-style entity picker, replace select dropdowns | alexei.dolgolyov |
| [86115f5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/86115f5) | fix: search palette triggers highlight, restore CSS keyframe blink | alexei.dolgolyov |
| [88e21e4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/88e21e4) | fix: switch highlight to global store instead of URL params | alexei.dolgolyov |
| [f47df93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f47df93) | fix: replace CSS keyframe highlight with direct style pulse for reliability | alexei.dolgolyov |
| [4b59f40](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4b59f40) | fix: card highlight animation — kill stagger before highlight, keep animation:none on cleanup | alexei.dolgolyov |
| [637a467](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/637a467) | feat: add search button to sidebar with Ctrl+K shortcut hint | alexei.dolgolyov |
| [4c1d5a8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4c1d5a8) | fix: prevent stagger animation replay after card highlight ends | alexei.dolgolyov |
| [f0f49db](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0f49db) | feat: card highlight system for cross-entity navigation | alexei.dolgolyov |
| [227b9c2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/227b9c2) | fix: rename bots → telegramBots in targets page to fix undefined reference | alexei.dolgolyov |
| [06b2463](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/06b2463) | feat: IconGridSelect, CrossLink, SearchPalette components + entity crosslinks | alexei.dolgolyov |
| [563716f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/563716f) | feat: entity cache system, nav UX improvements, split CLAUDE.md | alexei.dolgolyov |
| [2c740ff](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2c740ff) | feat: grouped nav tree with badges, dashboard events section with filtered chart | alexei.dolgolyov |
| [ddcbfda](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ddcbfda) | feat: remove hardcoded command templates, enforce template system exclusively | alexei.dolgolyov |
| [3e3a6f0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3e3a6f0) | feat: Discord/Slack/ntfy/Matrix targets, command templates, delete protection, email/matrix bots | alexei.dolgolyov |
| [846d480](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/846d480) | feat: provider-strict configs, slot-based templates, broadcast targets, email bots, command templates | alexei.dolgolyov |
| [371ea70](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/371ea70) | feat: fix template preview links, default chat action, update default templates | alexei.dolgolyov |
| [1d445f3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1d445f3) | feat: entity relationship refactor — notification trackers, command system, chat actions | alexei.dolgolyov |
| [0dcca2f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0dcca2f) | chore: remove accidentally committed __pycache__ | alexei.dolgolyov |
| [03ec9b3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03ec9b3) | feat: telegram commands, app settings, bot polling, webhook handling, UI improvements | alexei.dolgolyov |
| [5015e37](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5015e37) | feat: test menu dropdown, split text/media messages, target settings, provider URL links | alexei.dolgolyov |
| [03c5c66](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03c5c66) | feat: UX & notification improvements — icons, events, chat names, link validation, templates | alexei.dolgolyov |
| [91e5cd5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/91e5cd5) | fix: comprehensive API/UI review — 26 bug fixes and improvements | alexei.dolgolyov |
| [9eec21a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9eec21a) | feat: port full CRUD API routes and frontend pages from Immich Watcher | alexei.dolgolyov |
| [c9cab93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c9cab93) | feat: port original frontend UI to Notify Bridge | alexei.dolgolyov |
| [e43c2ed](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e43c2ed) | fix: remove auto-redirect from API client on 401 | alexei.dolgolyov |
| [7d01ae6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7d01ae6) | fix: add auth guard to root layout with setup/login redirects | alexei.dolgolyov |
| [f9c41fa](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9c41fa) | fix: local fonts via @fontsource, favicon, autocomplete attrs | alexei.dolgolyov |
| [786fe5e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/786fe5e) | feat(notify-bridge): phase 9 - HAOS integration planning | alexei.dolgolyov |
| [08814e9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/08814e9) | feat(notify-bridge): phase 8 - integration and wiring | alexei.dolgolyov |
| [9dfd1b7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9dfd1b7) | feat(notify-bridge): phase 7 - frontend restructuring | alexei.dolgolyov |
| [7f99c89](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7f99c89) | feat(notify-bridge): phase 6 - database models and server API | alexei.dolgolyov |
| [16a41ef](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/16a41ef) | feat(notify-bridge): phase 5 - notification system | alexei.dolgolyov |
| [f36f070](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f36f070) | feat(notify-bridge): phase 4 - template system | alexei.dolgolyov |
| [cc02558](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cc02558) | feat(notify-bridge): phase 3 - Immich service provider | alexei.dolgolyov |
| [3ed0d8c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3ed0d8c) | feat(notify-bridge): phase 2 - core abstractions | alexei.dolgolyov |
| [b724447](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b724447) | feat(notify-bridge): phase 1 - project scaffolding | alexei.dolgolyov |
</details> </details>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.2.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+115 -1
View File
@@ -11,6 +11,37 @@ export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
return fallback; return fallback;
} }
/** Structured 409 blocked-by payload attached to ApiError.blockedBy. */
export interface BlockedByDetail {
message: string;
entity: string;
blocked_by: string[];
}
export class ApiError extends Error {
status: number;
blockedBy?: BlockedByDetail;
constructor(message: string, status: number, blockedBy?: BlockedByDetail) {
super(message);
this.name = 'ApiError';
this.status = status;
this.blockedBy = blockedBy;
}
}
/** Parse a server-issued datetime string as UTC (appends Z if no timezone info present). */
export function parseDate(dateStr: string): Date {
if (!dateStr) return new Date(NaN);
if (!/Z$|[+-]\d{2}:?\d{2}$/.test(dateStr)) return new Date(dateStr + 'Z');
return new Date(dateStr);
}
/** If the thrown error was a structured 409 from delete_protection, return its payload. */
export function getBlockedBy(err: unknown): BlockedByDetail | null {
if (err instanceof ApiError && err.blockedBy) return err.blockedBy;
return null;
}
function getToken(): string | null { function getToken(): string | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token'); return localStorage.getItem('access_token');
@@ -63,6 +94,9 @@ async function doRefreshAccessToken(): Promise<boolean> {
} }
const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_TIMEOUT_MS = 30_000;
// Longer cap for fetchAuth — it's used for multipart uploads (backup restore)
// and binary downloads where a 30s limit can cut off a legit slow upload.
const DEFAULT_FETCHAUTH_TIMEOUT_MS = 120_000;
export async function api<T = any>( export async function api<T = any>(
path: string, path: string,
@@ -106,7 +140,17 @@ export async function api<T = any>(
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })); const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`); // Structured blocked-by detail (from delete_protection.raise_if_used)
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
const bb: BlockedByDetail = {
message: err.detail.message || `HTTP ${res.status}`,
entity: err.detail.entity || '',
blocked_by: err.detail.blocked_by,
};
throw new ApiError(bb.message, res.status, bb);
}
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
throw new ApiError(msg, res.status);
} }
return res.json(); return res.json();
@@ -114,3 +158,73 @@ export async function api<T = any>(
clearTimeout(timeout); clearTimeout(timeout);
} }
} }
/**
* Auth-aware ``fetch`` wrapper for calls that can't go through ``api()`` —
* typically multipart/form-data uploads or binary downloads where we need the
* raw ``Response`` object rather than parsed JSON.
*
* - Injects the Bearer token automatically.
* - Does NOT set ``Content-Type`` (the caller's body — e.g. ``FormData`` —
* decides the encoding; browsers add the boundary).
* - Attempts a one-shot token refresh on 401, matching ``api()``.
* - Translates non-OK responses to ``ApiError`` so callers can use the same
* ``getBlockedBy`` / ``err.message`` handling pattern.
*/
export async function fetchAuth(
path: string,
options: RequestInit & { timeoutMs?: number } = {},
): Promise<Response> {
const token = getToken();
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
// Abort after timeout so uploads/downloads don't hang indefinitely if
// the backend stops responding. Callers can override per-request via
// options.timeoutMs or pass their own signal to opt out.
const { timeoutMs, ...fetchOptions } = options;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
timeoutMs ?? DEFAULT_FETCHAUTH_TIMEOUT_MS,
);
const signal = options.signal ?? controller.signal;
try {
let res = await fetch(url, { ...fetchOptions, headers, signal });
if (res.status === 401 && token) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${getToken()}`;
res = await fetch(url, { ...fetchOptions, headers, signal });
}
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') window.location.href = '/login';
throw new ApiError('Unauthorized', 401);
}
if (!res.ok) {
const err = await res.clone().json().catch(() => ({ detail: res.statusText }));
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
const bb: BlockedByDetail = {
message: err.detail.message || `HTTP ${res.status}`,
entity: err.detail.entity || '',
blocked_by: err.detail.blocked_by,
};
throw new ApiError(bb.message, res.status, bb);
}
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
throw new ApiError(msg, res.status);
}
return res;
} finally {
clearTimeout(timeout);
}
}
@@ -0,0 +1,74 @@
<script lang="ts">
import Modal from './Modal.svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import type { BlockedByDetail } from '$lib/api';
let { open = false, detail = null, onclose } = $props<{
open: boolean;
detail: BlockedByDetail | null;
onclose: () => void;
}>();
const blockedCount = $derived(detail?.blocked_by?.length ?? 0);
</script>
<Modal {open} title={t('common.cannotDelete')} onclose={onclose}>
{#if detail}
<div class="flex items-start gap-3 mb-4">
<div class="flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0"
style="background: var(--color-error-bg); color: var(--color-error-fg);">
<MdiIcon name="mdiLinkVariant" size={20} />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium mb-1 break-words">{detail.message}</p>
{#if detail.entity}
<p class="text-xs break-all" style="color: var(--color-muted-foreground);">{detail.entity}</p>
{/if}
</div>
</div>
<div class="flex items-center justify-between mb-2">
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('common.blockedByIntro')}</p>
{#if blockedCount > 0}
<span class="text-[0.65rem] font-mono px-1.5 py-0.5 rounded"
style="background: var(--color-muted); color: var(--color-muted-foreground);">
{blockedCount}
</span>
{/if}
</div>
{#if blockedCount > 0}
<ul class="space-y-1.5 max-h-64 overflow-y-auto pr-1 mb-5">
{#each detail.blocked_by as consumer}
<li class="flex items-start gap-2 text-sm px-3 py-2 rounded-md"
style="background: var(--color-muted); border: 1px solid var(--color-border);">
<span class="flex-shrink-0 mt-0.5" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiChevronRight" size={14} />
</span>
<span class="font-mono text-xs break-all min-w-0 flex-1">{consumer}</span>
</li>
{/each}
</ul>
{/if}
{/if}
<div class="flex justify-end">
<button onclick={onclose} class="blocked-by-close-btn">
{t('common.close')}
</button>
</div>
</Modal>
<style>
.blocked-by-close-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-foreground);
cursor: pointer;
transition: all 0.2s ease;
}
.blocked-by-close-btn:hover {
background: var(--color-muted);
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { parseDate } from '$lib/api';
import MdiIcon from './MdiIcon.svelte'; import MdiIcon from './MdiIcon.svelte';
interface DayData { interface DayData {
@@ -47,7 +48,7 @@
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0))); const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00'); const d = parseDate(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} }
+3
View File
@@ -89,6 +89,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
{ value: 'collection_renamed', icon: 'mdiRename', label: t('dashboard.filterRenamed'), desc: t('gridDesc.renamed') }, { value: 'collection_renamed', icon: 'mdiRename', label: t('dashboard.filterRenamed'), desc: t('gridDesc.renamed') },
{ value: 'collection_deleted', icon: 'mdiDeleteAlert', label: t('dashboard.filterDeleted'), desc: t('gridDesc.deleted') }, { value: 'collection_deleted', icon: 'mdiDeleteAlert', label: t('dashboard.filterDeleted'), desc: t('gridDesc.deleted') },
{ value: 'sharing_changed', icon: 'mdiShareVariant', label: t('dashboard.filterSharingChanged'), desc: t('gridDesc.sharingChanged') }, { value: 'sharing_changed', icon: 'mdiShareVariant', label: t('dashboard.filterSharingChanged'), desc: t('gridDesc.sharingChanged') },
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
]; ];
// --- Sort filter (dashboard) --- // --- Sort filter (dashboard) ---
+45 -2
View File
@@ -64,6 +64,8 @@
"activeTrackers": "Active Trackers", "activeTrackers": "Active Trackers",
"targets": "Targets", "targets": "Targets",
"recentEvents": "Events", "recentEvents": "Events",
"clearEvents": "Clear",
"confirmClearEvents": "Delete all event log entries? This cannot be undone.",
"chart": "Event chart", "chart": "Event chart",
"noEvents": "No events yet. Create a tracker to start monitoring.", "noEvents": "No events yet. Create a tracker to start monitoring.",
"loading": "Loading...", "loading": "Loading...",
@@ -76,6 +78,9 @@
"collectionRenamed": "collection renamed", "collectionRenamed": "collection renamed",
"collectionDeleted": "collection deleted", "collectionDeleted": "collection deleted",
"sharingChanged": "sharing changed", "sharingChanged": "sharing changed",
"actionSuccess": "action run",
"actionPartial": "action partial",
"actionFailed": "action failed",
"searchEvents": "Search events...", "searchEvents": "Search events...",
"allEvents": "All Events", "allEvents": "All Events",
"filterAssetsAdded": "Assets Added", "filterAssetsAdded": "Assets Added",
@@ -83,6 +88,9 @@
"filterRenamed": "Renamed", "filterRenamed": "Renamed",
"filterDeleted": "Deleted", "filterDeleted": "Deleted",
"filterSharingChanged": "Sharing Changed", "filterSharingChanged": "Sharing Changed",
"filterActionSuccess": "Action Success",
"filterActionPartial": "Action Partial",
"filterActionFailed": "Action Failed",
"allProviders": "All Providers", "allProviders": "All Providers",
"newestFirst": "Newest first", "newestFirst": "Newest first",
"oldestFirst": "Oldest first", "oldestFirst": "Oldest first",
@@ -365,6 +373,7 @@
"roleAdmin": "Admin", "roleAdmin": "Admin",
"create": "Create User", "create": "Create User",
"delete": "Delete", "delete": "Delete",
"edit": "Edit user",
"confirmDelete": "Delete this user?", "confirmDelete": "Delete this user?",
"joined": "joined", "joined": "joined",
"noUsers": "No users found" "noUsers": "No users found"
@@ -514,6 +523,9 @@
"memorySource": "Memory source", "memorySource": "Memory source",
"memorySourceAlbums": "Scan tracked albums", "memorySourceAlbums": "Scan tracked albums",
"memorySourceNative": "Immich native memories", "memorySourceNative": "Immich native memories",
"quietHours": "Quiet hours",
"quietHoursStart": "Start",
"quietHoursEnd": "End",
"test": "Test", "test": "Test",
"confirmDelete": "Delete this tracking config?", "confirmDelete": "Delete this tracking config?",
"sortNone": "None", "sortNone": "None",
@@ -661,6 +673,8 @@
"webhookSecretHint": "Secret token to verify webhook requests from Telegram", "webhookSecretHint": "Secret token to verify webhook requests from Telegram",
"cacheTtl": "Media Cache TTL (hours)", "cacheTtl": "Media Cache TTL (hours)",
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading", "cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
"timezone": "Timezone",
"timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.",
"locales": "Template Languages", "locales": "Template Languages",
"supportedLocales": "Supported Locales", "supportedLocales": "Supported Locales",
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)", "supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
@@ -671,6 +685,7 @@
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.", "scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
"memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.", "memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.",
"memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).", "memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).",
"quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:0007:00 are supported.",
"favoritesOnly": "Only include assets marked as favorites.", "favoritesOnly": "Only include assets marked as favorites.",
"maxAssets": "Maximum number of asset details to include in a single notification message.", "maxAssets": "Maximum number of asset details to include in a single notification message.",
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.", "periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
@@ -785,13 +800,21 @@
"disabled": "Disabled", "disabled": "Disabled",
"noListeners": "No listeners attached.", "noListeners": "No listeners attached.",
"selectBot": "Select bot...", "selectBot": "Select bot...",
"listenerType": "telegram_bot" "listenerType": "telegram_bot",
"editScope": "Edit album scope",
"scopeAll": "all albums",
"albumsShort": "albums",
"scopeTitle": "Album Scope for This Chat",
"scopeDescription": "Restrict which tracked albums this chat can query via commands. Leave on \"inherit\" to allow all albums from the tracker.",
"scopeInherit": "Inherit: allow all tracked albums",
"noCollections": "No albums available."
}, },
"snackbar": { "snackbar": {
"showDetails": "Show details", "showDetails": "Show details",
"hideDetails": "Hide details" "hideDetails": "Hide details"
}, },
"snack": { "snack": {
"eventsCleared": "{count} event(s) cleared",
"providerSaved": "Provider saved", "providerSaved": "Provider saved",
"providerDeleted": "Provider deleted", "providerDeleted": "Provider deleted",
"trackerCreated": "Tracker created", "trackerCreated": "Tracker created",
@@ -810,6 +833,7 @@
"botDeleted": "Bot deleted", "botDeleted": "Bot deleted",
"userCreated": "User created", "userCreated": "User created",
"userDeleted": "User deleted", "userDeleted": "User deleted",
"userUpdated": "User updated",
"passwordChanged": "Password changed", "passwordChanged": "Password changed",
"copied": "Copied to clipboard", "copied": "Copied to clipboard",
"genericError": "Something went wrong", "genericError": "Something went wrong",
@@ -827,6 +851,7 @@
"commandTrackerDisabled": "Command tracker disabled", "commandTrackerDisabled": "Command tracker disabled",
"listenerAdded": "Listener added", "listenerAdded": "Listener added",
"listenerRemoved": "Listener removed", "listenerRemoved": "Listener removed",
"listenerScopeSaved": "Scope updated",
"cmdTemplateSaved": "Command template saved", "cmdTemplateSaved": "Command template saved",
"cmdTemplateDeleted": "Command template deleted", "cmdTemplateDeleted": "Command template deleted",
"emailBotCreated": "Email bot created", "emailBotCreated": "Email bot created",
@@ -848,6 +873,8 @@
"description": "Description", "description": "Description",
"close": "Close", "close": "Close",
"confirm": "Confirm", "confirm": "Confirm",
"cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:",
"error": "Error", "error": "Error",
"success": "Success", "success": "Success",
"none": "None", "none": "None",
@@ -960,6 +987,9 @@
"renamed": "Album was renamed", "renamed": "Album was renamed",
"deleted": "Album was deleted", "deleted": "Album was deleted",
"sharingChanged": "Album sharing toggled", "sharingChanged": "Album sharing toggled",
"actionSuccess": "Scheduled action completed",
"actionPartial": "Scheduled action partially succeeded",
"actionFailed": "Scheduled action failed",
"newestFirst": "Most recent events on top", "newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top", "oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown", "chatActionNone": "No indicator shown",
@@ -1021,6 +1051,7 @@
"name": "Name", "name": "Name",
"schedule": "Schedule", "schedule": "Schedule",
"interval": "Interval", "interval": "Interval",
"cronMode": "Cron expression",
"seconds": "seconds", "seconds": "seconds",
"cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)", "cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)",
"enabled": "Enabled", "enabled": "Enabled",
@@ -1126,6 +1157,18 @@
"savedFiles": "Saved Backups", "savedFiles": "Saved Backups",
"noFiles": "No backup files yet.", "noFiles": "No backup files yet.",
"download": "Download", "download": "Download",
"fileDeleted": "Backup file deleted" "fileDeleted": "Backup file deleted",
"createManual": "Create backup",
"manualCreated": "Backup created",
"pendingTitle": "Restore pending — restart to apply",
"pendingBy": "Uploaded by {by}",
"pendingAt": "at {at}",
"pendingCancelled": "Pending restore cancelled",
"restorePrepared": "Restore prepared",
"restoreApplyPrompt": "Apply the restore now (the backend will restart) or later on the next natural restart?",
"applyLater": "Apply later",
"restartNow": "Restart now",
"restartingTitle": "Restarting backend…",
"restartingDescription": "The page will reload once the server is back online."
} }
} }
+45 -2
View File
@@ -64,6 +64,8 @@
"activeTrackers": "Активные трекеры", "activeTrackers": "Активные трекеры",
"targets": "Получатели", "targets": "Получатели",
"recentEvents": "События", "recentEvents": "События",
"clearEvents": "Очистить",
"confirmClearEvents": "Удалить все записи журнала событий? Это действие нельзя отменить.",
"chart": "График событий", "chart": "График событий",
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.", "noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
"loading": "Загрузка...", "loading": "Загрузка...",
@@ -76,6 +78,9 @@
"collectionRenamed": "альбом переименован", "collectionRenamed": "альбом переименован",
"collectionDeleted": "альбом удалён", "collectionDeleted": "альбом удалён",
"sharingChanged": "изменение доступа", "sharingChanged": "изменение доступа",
"actionSuccess": "действие выполнено",
"actionPartial": "действие частично",
"actionFailed": "действие провалено",
"searchEvents": "Поиск событий...", "searchEvents": "Поиск событий...",
"allEvents": "Все события", "allEvents": "Все события",
"filterAssetsAdded": "Добавление файлов", "filterAssetsAdded": "Добавление файлов",
@@ -83,6 +88,9 @@
"filterRenamed": "Переименование", "filterRenamed": "Переименование",
"filterDeleted": "Удаление", "filterDeleted": "Удаление",
"filterSharingChanged": "Изменение доступа", "filterSharingChanged": "Изменение доступа",
"filterActionSuccess": "Действие выполнено",
"filterActionPartial": "Действие частично",
"filterActionFailed": "Действие провалено",
"allProviders": "Все провайдеры", "allProviders": "Все провайдеры",
"newestFirst": "Сначала новые", "newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые", "oldestFirst": "Сначала старые",
@@ -365,6 +373,7 @@
"roleAdmin": "Администратор", "roleAdmin": "Администратор",
"create": "Создать", "create": "Создать",
"delete": "Удалить", "delete": "Удалить",
"edit": "Редактировать пользователя",
"confirmDelete": "Удалить этого пользователя?", "confirmDelete": "Удалить этого пользователя?",
"joined": "зарегистрирован", "joined": "зарегистрирован",
"noUsers": "Пользователи не найдены" "noUsers": "Пользователи не найдены"
@@ -514,6 +523,9 @@
"memorySource": "Источник воспоминаний", "memorySource": "Источник воспоминаний",
"memorySourceAlbums": "Сканировать альбомы", "memorySourceAlbums": "Сканировать альбомы",
"memorySourceNative": "Встроенные воспоминания Immich", "memorySourceNative": "Встроенные воспоминания Immich",
"quietHours": "Тихие часы",
"quietHoursStart": "Начало",
"quietHoursEnd": "Конец",
"test": "Тест", "test": "Тест",
"confirmDelete": "Удалить эту конфигурацию отслеживания?", "confirmDelete": "Удалить эту конфигурацию отслеживания?",
"sortNone": "Нет", "sortNone": "Нет",
@@ -661,6 +673,8 @@
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram", "webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
"cacheTtl": "TTL кэша медиа (часы)", "cacheTtl": "TTL кэша медиа (часы)",
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой", "cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
"timezone": "Часовой пояс",
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
"locales": "Языки шаблонов", "locales": "Языки шаблонов",
"supportedLocales": "Поддерживаемые локали", "supportedLocales": "Поддерживаемые локали",
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)", "supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
@@ -671,6 +685,7 @@
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.", "scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.", "memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).", "memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:0007:00.",
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.", "favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
"maxAssets": "Максимальное количество ассетов в одном уведомлении.", "maxAssets": "Максимальное количество ассетов в одном уведомлении.",
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.", "periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
@@ -785,13 +800,21 @@
"disabled": "Отключён", "disabled": "Отключён",
"noListeners": "Нет подключённых слушателей.", "noListeners": "Нет подключённых слушателей.",
"selectBot": "Выберите бота...", "selectBot": "Выберите бота...",
"listenerType": "telegram_bot" "listenerType": "telegram_bot",
"editScope": "Изменить область альбомов",
"scopeAll": "все альбомы",
"albumsShort": "альбомов",
"scopeTitle": "Область альбомов для этого чата",
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.",
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы",
"noCollections": "Нет доступных альбомов."
}, },
"snackbar": { "snackbar": {
"showDetails": "Показать детали", "showDetails": "Показать детали",
"hideDetails": "Скрыть детали" "hideDetails": "Скрыть детали"
}, },
"snack": { "snack": {
"eventsCleared": "Очищено событий: {count}",
"providerSaved": "Провайдер сохранён", "providerSaved": "Провайдер сохранён",
"providerDeleted": "Провайдер удалён", "providerDeleted": "Провайдер удалён",
"trackerCreated": "Трекер создан", "trackerCreated": "Трекер создан",
@@ -810,6 +833,7 @@
"botDeleted": "Бот удалён", "botDeleted": "Бот удалён",
"userCreated": "Пользователь создан", "userCreated": "Пользователь создан",
"userDeleted": "Пользователь удалён", "userDeleted": "Пользователь удалён",
"userUpdated": "Пользователь обновлён",
"passwordChanged": "Пароль изменён", "passwordChanged": "Пароль изменён",
"copied": "Скопировано", "copied": "Скопировано",
"genericError": "Что-то пошло не так", "genericError": "Что-то пошло не так",
@@ -827,6 +851,7 @@
"commandTrackerDisabled": "Трекер команд отключён", "commandTrackerDisabled": "Трекер команд отключён",
"listenerAdded": "Слушатель добавлен", "listenerAdded": "Слушатель добавлен",
"listenerRemoved": "Слушатель удалён", "listenerRemoved": "Слушатель удалён",
"listenerScopeSaved": "Область обновлена",
"cmdTemplateSaved": "Шаблон команд сохранён", "cmdTemplateSaved": "Шаблон команд сохранён",
"cmdTemplateDeleted": "Шаблон команд удалён", "cmdTemplateDeleted": "Шаблон команд удалён",
"emailBotCreated": "Email бот создан", "emailBotCreated": "Email бот создан",
@@ -848,6 +873,8 @@
"description": "Описание", "description": "Описание",
"close": "Закрыть", "close": "Закрыть",
"confirm": "Подтвердить", "confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:",
"error": "Ошибка", "error": "Ошибка",
"success": "Успешно", "success": "Успешно",
"none": "Нет", "none": "Нет",
@@ -960,6 +987,9 @@
"renamed": "Альбом переименован", "renamed": "Альбом переименован",
"deleted": "Альбом удалён", "deleted": "Альбом удалён",
"sharingChanged": "Изменён доступ к альбому", "sharingChanged": "Изменён доступ к альбому",
"actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено",
"newestFirst": "Сначала новые события", "newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события", "oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается", "chatActionNone": "Индикатор не показывается",
@@ -1021,6 +1051,7 @@
"name": "Название", "name": "Название",
"schedule": "Расписание", "schedule": "Расписание",
"interval": "Интервал", "interval": "Интервал",
"cronMode": "Cron выражение",
"seconds": "секунд", "seconds": "секунд",
"cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)", "cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)",
"enabled": "Включено", "enabled": "Включено",
@@ -1126,6 +1157,18 @@
"savedFiles": "Сохранённые бэкапы", "savedFiles": "Сохранённые бэкапы",
"noFiles": "Файлов бэкапа пока нет.", "noFiles": "Файлов бэкапа пока нет.",
"download": "Скачать", "download": "Скачать",
"fileDeleted": "Файл бэкапа удалён" "fileDeleted": "Файл бэкапа удалён",
"createManual": "Создать бэкап",
"manualCreated": "Бэкап создан",
"pendingTitle": "Восстановление ожидает — перезапустите для применения",
"pendingBy": "Загружено пользователем {by}",
"pendingAt": "в {at}",
"pendingCancelled": "Ожидающее восстановление отменено",
"restorePrepared": "Восстановление подготовлено",
"restoreApplyPrompt": "Применить восстановление сейчас (бэкенд перезапустится) или позже при следующем штатном перезапуске?",
"applyLater": "Применить позже",
"restartNow": "Перезапустить сейчас",
"restartingTitle": "Перезапуск бэкенда…",
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
} }
} }
+25 -1
View File
@@ -88,6 +88,14 @@ export const immichDescriptor: ProviderDescriptor = {
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' }, { key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' },
], ],
}, },
{
key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours',
enabledField: 'quiet_hours_enabled', enabledDefault: false,
fields: [
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'number', defaultValue: '22:00' },
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'number', defaultValue: '07:00' },
],
},
], ],
collectionMeta: { collectionMeta: {
@@ -105,7 +113,10 @@ export const immichDescriptor: ProviderDescriptor = {
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean } interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
const warnings: { id: string; name: string; issue: string }[] = []; const warnings: { id: string; name: string; issue: string }[] = [];
for (const albumId of newIds) { // Run shared-link checks in parallel with a concurrency cap so a large
// album set doesn't stall the save button for seconds.
const CONCURRENCY = 6;
async function checkOne(albumId: string): Promise<void> {
try { try {
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`); const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = links.find((l) => l.is_accessible && !l.is_expired); const validLink = links.find((l) => l.is_accessible && !l.is_expired);
@@ -123,6 +134,19 @@ export const immichDescriptor: ProviderDescriptor = {
} catch { /* shared-link check failed, proceed */ } } catch { /* shared-link check failed, proceed */ }
} }
const queue = [...newIds];
const workers: Promise<void>[] = [];
for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) {
workers.push((async () => {
while (queue.length > 0) {
const next = queue.shift();
if (next === undefined) return;
await checkOne(next);
}
})());
}
await Promise.all(workers);
if (warnings.length > 0) return { warnings, proceed: false }; if (warnings.length > 0) return { warnings, proceed: false };
return { proceed: true }; return { proceed: true };
}, },
+3
View File
@@ -192,6 +192,9 @@ export interface TrackingConfig {
memory_favorite_only: boolean; memory_favorite_only: boolean;
memory_asset_type: string; memory_asset_type: string;
memory_min_rating: number; memory_min_rating: number;
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
created_at: string; created_at: string;
} }
+2 -2
View File
@@ -384,7 +384,7 @@
</div> </div>
<!-- Global provider filter --> <!-- Global provider filter -->
{#if allProviders.length > 1} {#if allProviders.length >= 1}
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);"> <div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
{#if collapsed} {#if collapsed}
<button onclick={() => { <button onclick={() => {
@@ -564,7 +564,7 @@
onclick={() => mobileMoreOpen = false} role="presentation"></div> onclick={() => mobileMoreOpen = false} role="presentation"></div>
<div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;" <div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;"
transition:slide={{ duration: 200, easing: cubicOut }}> transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length > 1} {#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);"> <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} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
</div> </div>
+40 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte'; import { providersCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -11,6 +11,8 @@
import EventChart from '$lib/components/EventChart.svelte'; import EventChart from '$lib/components/EventChart.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte'; import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte'; import EntitySelect from '$lib/components/EntitySelect.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items'; import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers'; import { getDescriptor } from '$lib/providers';
@@ -56,6 +58,21 @@
let eventsLimit = $state(loadEventsPerPage()); let eventsLimit = $state(loadEventsPerPage());
let eventsOffset = $state(0); let eventsOffset = $state(0);
let eventsLoading = $state(false); let eventsLoading = $state(false);
let confirmClearEvents = $state(false);
async function clearEvents() {
try {
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
snackSuccess(t('snack.eventsCleared').replace('{count}', String(res.deleted)));
eventsOffset = 0;
await loadEvents();
await loadChart();
} catch (err: any) {
snackError(err.message || t('common.error'));
} finally {
confirmClearEvents = false;
}
}
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1); let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0); let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
@@ -191,7 +208,7 @@
] : []); ] : []);
function timeAgo(dateStr: string): string { function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime(); const diff = Date.now() - parseDate(dateStr).getTime();
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
if (mins < 1) return t('dashboard.justNow'); if (mins < 1) return t('dashboard.justNow');
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins)); if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
@@ -206,15 +223,20 @@
collection_renamed: 'dashboard.collectionRenamed', collection_renamed: 'dashboard.collectionRenamed',
collection_deleted: 'dashboard.collectionDeleted', collection_deleted: 'dashboard.collectionDeleted',
sharing_changed: 'dashboard.sharingChanged', sharing_changed: 'dashboard.sharingChanged',
action_success: 'dashboard.actionSuccess',
action_partial: 'dashboard.actionPartial',
action_failed: 'dashboard.actionFailed',
}; };
const eventIcons: Record<string, string> = { const eventIcons: Record<string, string> = {
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus', assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant', collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
}; };
const eventColors: Record<string, string> = { const eventColors: Record<string, string> = {
assets_added: '#059669', assets_removed: '#ef4444', assets_added: '#059669', assets_removed: '#ef4444',
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b', collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
}; };
</script> </script>
@@ -252,13 +274,23 @@
</div> </div>
<!-- Events section --> <!-- Events section -->
<h3 class="text-base font-semibold mb-3 flex items-center gap-2"> <div class="flex items-center justify-between mb-3">
<h3 class="text-base font-semibold flex items-center gap-2">
<MdiIcon name="mdiPulse" size={18} /> <MdiIcon name="mdiPulse" size={18} />
{t('dashboard.recentEvents')} {t('dashboard.recentEvents')}
{#if status.total_events > 0} {#if status.total_events > 0}
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span> <span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
{/if} {/if}
</h3> </h3>
{#if status.total_events > 0}
<button type="button" onclick={() => confirmClearEvents = true}
class="clear-events-btn flex items-center gap-1.5 px-2.5 py-1 text-xs border border-[var(--color-border)] rounded-md transition-colors"
title={t('dashboard.clearEvents')}>
<MdiIcon name="mdiTrashCanOutline" size={14} />
<span class="hidden sm:inline">{t('dashboard.clearEvents')}</span>
</button>
{/if}
</div>
<!-- Filters --> <!-- Filters -->
<div class="flex flex-wrap items-center gap-2 mb-4"> <div class="flex flex-wrap items-center gap-2 mb-4">
@@ -370,6 +402,9 @@
{/if} {/if}
{/if} {/if}
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
<style> <style>
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; } .stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); } .stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
@@ -385,4 +420,6 @@
.event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; } .event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); } .event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); } .event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
.clear-events-btn { color: var(--color-muted-foreground); background: transparent; }
.clear-events-btn:hover { background: color-mix(in srgb, var(--color-error-fg) 10%, transparent); border-color: color-mix(in srgb, var(--color-error-fg) 40%, var(--color-border)); color: var(--color-error-fg); }
</style> </style>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import type { ActionExecution } from '$lib/types'; import type { ActionExecution } from '$lib/types';
@@ -47,14 +47,14 @@
function formatDate(iso: string | null): string { function formatDate(iso: string | null): string {
if (!iso) return '-'; if (!iso) return '-';
try { try {
return new Date(iso).toLocaleString(); return parseDate(iso).toLocaleString();
} catch { return iso; } } catch { return iso; }
} }
function formatDuration(start: string, end: string | null): string { function formatDuration(start: string, end: string | null): string {
if (!end) return '-'; if (!end) return '-';
try { try {
const ms = new Date(end).getTime() - new Date(start).getTime(); const ms = parseDate(end).getTime() - parseDate(start).getTime();
if (ms < 1000) return `${ms}ms`; if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`; return `${(ms / 1000).toFixed(1)}s`;
} catch { return '-'; } } catch { return '-'; }
+10 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n'; import { t, getLocale } from '$lib/i18n';
import { emailBotsCache } from '$lib/stores/caches.svelte'; import { emailBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -58,12 +59,17 @@
finally { emailSubmitting = false; } finally { emailSubmitting = false; }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function removeEmail(id: number) { function removeEmail(id: number) {
confirmDeleteEmail = { confirmDeleteEmail = {
id, id,
onconfirm: async () => { onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); } try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); } catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDeleteEmail = null; } finally { confirmDeleteEmail = null; }
} }
}; };
@@ -173,3 +179,5 @@
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')} <ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} /> onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
+10 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n'; import { t, getLocale } from '$lib/i18n';
import { matrixBotsCache } from '$lib/stores/caches.svelte'; import { matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -56,12 +57,17 @@
finally { matrixSubmitting = false; } finally { matrixSubmitting = false; }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function removeMatrix(id: number) { function removeMatrix(id: number) {
confirmDeleteMatrix = { confirmDeleteMatrix = {
id, id,
onconfirm: async () => { onconfirm: async () => {
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); } try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); } catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDeleteMatrix = null; } finally { confirmDeleteMatrix = null; }
} }
}; };
@@ -155,3 +161,5 @@
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')} <ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} /> onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
+20 -9
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n'; import { t, getLocale } from '$lib/i18n';
import { telegramBotsCache } from '$lib/stores/caches.svelte'; import { telegramBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -64,24 +65,30 @@
finally { submitting = false; } finally { submitting = false; }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) { function remove(id: number) {
confirmDelete = { confirmDelete = {
id, id,
onconfirm: async () => { onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); } try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); } catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDelete = null; } finally { confirmDelete = null; }
} }
}; };
} }
function toggleSection(botId: number, section: string) { async function toggleSection(botId: number, section: string) {
if (expandedSection[botId] === section) { if (expandedSection[botId] === section) {
expandedSection = { ...expandedSection, [botId]: '' }; expandedSection = { ...expandedSection, [botId]: '' };
return; return;
} }
if (section === 'chats' && !chats[botId]) await loadChats(botId);
else if (section === 'listeners' && !botListenerStatus[botId]) await loadListenerStatus(botId);
expandedSection = { ...expandedSection, [botId]: section }; expandedSection = { ...expandedSection, [botId]: section };
if (section === 'chats') loadChats(botId);
} }
async function loadChats(botId: number) { async function loadChats(botId: number) {
@@ -338,12 +345,14 @@
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap"> <div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} /> <IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
<button onclick={() => toggleSection(bot.id, 'chats')} <button onclick={() => toggleSection(bot.id, 'chats')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap"> disabled={chatsLoading[bot.id]}
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'} class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap disabled:opacity-50">
{t('telegramBot.chats')} {chatsLoading[bot.id] ? '…' : expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
</button> </button>
<button onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }} <button onclick={() => toggleSection(bot.id, 'listeners')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap"> disabled={botListenerLoading[bot.id]}
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'} class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap disabled:opacity-50">
{t('commandTracker.listeners')} {botListenerLoading[bot.id] ? '…' : expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
</button> </button>
<IconButton icon="mdiSync" title={t('telegramBot.syncCommands')} onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]} /> <IconButton icon="mdiSync" title={t('telegramBot.syncCommands')} onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" /> <IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
@@ -518,3 +527,5 @@
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte'; import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -92,7 +93,10 @@
function openNew() { function openNew() {
form = defaultForm(); form = defaultForm();
// Auto-select first matching template for the default provider_type // Auto-select first provider type with commands
const types = Object.keys(allCapabilities).filter(t => (allCapabilities[t]?.commands?.length || 0) > 0);
if (types.length > 0) form.provider_type = types[0];
// Auto-select first matching template for the chosen provider_type
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type); const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
if (match) form.command_template_config_id = match.id; if (match) form.command_template_config_id = match.id;
editing = null; editing = null;
@@ -137,6 +141,7 @@
finally { submitting = false; } finally { submitting = false; }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(cfg: CommandConfig) { function remove(cfg: CommandConfig) {
confirmDelete = { confirmDelete = {
id: cfg.id, id: cfg.id,
@@ -145,7 +150,11 @@
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' }); await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
await load(); await load();
snackSuccess(t('snack.commandConfigDeleted')); snackSuccess(t('snack.commandConfigDeleted'));
} catch (err: any) { snackError(err.message); } } catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
snackError(err.message);
}
finally { confirmDelete = null; } finally { confirmDelete = null; }
} }
}; };
@@ -296,3 +305,5 @@
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize'; import { sanitizePreview } from '$lib/sanitize';
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte'; import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
@@ -189,6 +190,8 @@
function openNew() { function openNew() {
form = defaultForm(); form = defaultForm();
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
editing = null; editing = null;
showForm = true; showForm = true;
activeLocale = 'en'; activeLocale = 'en';
@@ -265,6 +268,7 @@
setTimeout(() => refreshAllPreviews(), 100); setTimeout(() => refreshAllPreviews(), 100);
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) { function remove(id: number) {
confirmDelete = { confirmDelete = {
id, id,
@@ -274,6 +278,8 @@
await load(); await load();
snackSuccess(t('snack.cmdTemplateDeleted')); snackSuccess(t('snack.cmdTemplateDeleted'));
} catch (err: any) { } catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; error = err.message;
snackError(err.message); snackError(err.message);
} finally { } finally {
@@ -458,6 +464,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal --> <!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}> <Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]} {#if showVarsFor && varsRef[showVarsFor]}
@@ -11,6 +11,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Modal from '$lib/components/Modal.svelte';
import IconButton from '$lib/components/IconButton.svelte'; import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte'; import CrossLink from '$lib/components/CrossLink.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte'; import EntitySelect from '$lib/components/EntitySelect.svelte';
@@ -83,7 +84,17 @@
finally { loaded = true; highlightFromUrl(); } finally { loaded = true; highlightFromUrl(); }
} }
function openNew() { form = defaultForm(); editing = null; showForm = true; } function openNew() {
form = defaultForm();
if (providers.length > 0) form.provider_id = providers[0].id;
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
if (ptype) {
const firstCfg = commandConfigs.find(c => c.provider_type === ptype);
if (firstCfg) form.command_config_id = firstCfg.id;
}
editing = null;
showForm = true;
}
function editTracker(trk: any) { function editTracker(trk: any) {
form = { form = {
name: trk.name, name: trk.name,
@@ -178,6 +189,35 @@
} catch (err: any) { snackError(err.message); } } catch (err: any) { snackError(err.message); }
} }
// Per-listener album scope editing
let scopeEditor = $state<{ trkId: number; listener: any; providerId: number; collections: any[]; selectedIds: string[]; inherit: boolean } | null>(null);
async function openScopeEditor(trkId: number, listener: any) {
const trk = allCmdTrackers.find((t: any) => t.id === trkId);
if (!trk) return;
let collections: any[] = [];
try { collections = await api(`/providers/${trk.provider_id}/collections`); } catch { /* ignore */ }
scopeEditor = {
trkId,
listener,
providerId: trk.provider_id,
collections,
selectedIds: [...(listener.allowed_album_ids || [])],
inherit: listener.allowed_album_ids === null || listener.allowed_album_ids === undefined,
};
}
async function saveScope() {
if (!scopeEditor) return;
const body = { allowed_album_ids: scopeEditor.inherit ? null : scopeEditor.selectedIds };
try {
await api(`/command-trackers/${scopeEditor.trkId}/listeners/${scopeEditor.listener.id}`, {
method: 'PATCH', body: JSON.stringify(body),
});
snackSuccess(t('snack.listenerScopeSaved'));
await loadListeners(scopeEditor.trkId);
scopeEditor = null;
} catch (err: any) { snackError(err.message); }
}
function providerName(id: number): string { function providerName(id: number): string {
return providers.find(p => p.id === id)?.name || '?'; return providers.find(p => p.id === id)?.name || '?';
} }
@@ -289,10 +329,18 @@
<div class="space-y-1"> <div class="space-y-1">
{#each listeners[trk.id] as listener} {#each listeners[trk.id] as listener}
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]"> <div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 min-w-0">
<MdiIcon name="mdiRobot" size={14} /> <MdiIcon name="mdiRobot" size={14} />
<CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} /> <CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-mono">{listener.listener_type}</span> <span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-mono">{listener.listener_type}</span>
<button type="button" onclick={() => openScopeEditor(trk.id, listener)}
class="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]"
title={t('commandTracker.editScope')}>
<MdiIcon name="mdiImageMultiple" size={12} />
{listener.allowed_album_ids === null || listener.allowed_album_ids === undefined
? t('commandTracker.scopeAll')
: `${(listener.allowed_album_ids || []).length} ${t('commandTracker.albumsShort')}`}
</button>
</div> </div>
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14} <IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
onclick={() => removeListener(trk.id, listener.id)} variant="danger" /> onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
@@ -321,3 +369,59 @@
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<!-- Per-listener album scope editor -->
<Modal open={scopeEditor !== null} title={t('commandTracker.scopeTitle')} onclose={() => scopeEditor = null}>
{#if scopeEditor}
<p class="text-xs text-[var(--color-muted-foreground)] mb-3">{t('commandTracker.scopeDescription')}</p>
<label class="flex items-center gap-2 text-sm mb-3">
<input type="checkbox" bind:checked={scopeEditor.inherit} />
{t('commandTracker.scopeInherit')}
</label>
{#if !scopeEditor.inherit}
{#if scopeEditor.collections.length > 0}
<div class="flex items-center justify-between mb-1.5 text-xs" style="color: var(--color-muted-foreground);">
<span>{scopeEditor.selectedIds.length} / {scopeEditor.collections.length}</span>
<div class="flex items-center gap-2">
<button type="button" class="underline hover:text-[var(--color-primary)]"
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
{t('backup.selectAll')}
</button>
<span aria-hidden="true">·</span>
<button type="button" class="underline hover:text-[var(--color-primary)]"
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
{t('backup.deselectAll')}
</button>
</div>
</div>
{/if}
<div class="space-y-1 max-h-72 overflow-y-auto border border-[var(--color-border)] rounded-md p-2">
{#if scopeEditor.collections.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] py-3 text-center">{t('commandTracker.noCollections')}</p>
{:else}
{#each scopeEditor.collections as col}
{@const cid = col.id}
<label class="flex items-center gap-2 text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer">
<input type="checkbox" checked={scopeEditor.selectedIds.includes(cid)}
onchange={(e) => {
if (!scopeEditor) return;
const target = e.target as HTMLInputElement;
scopeEditor.selectedIds = target.checked
? [...scopeEditor.selectedIds, cid]
: scopeEditor.selectedIds.filter((i) => i !== cid);
}} />
<span class="truncate min-w-0 flex-1" title={col.albumName || col.name || cid}>{col.albumName || col.name || cid}</span>
</label>
{/each}
{/if}
</div>
{/if}
<div class="flex gap-2 justify-end mt-4">
<button onclick={() => scopeEditor = null}
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('common.cancel')}
</button>
<Button size="sm" onclick={saveScope}>{t('common.save')}</Button>
</div>
{/if}
</Modal>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api, parseDate } from '$lib/api';
import { t, getLocale } from '$lib/i18n'; import { t, getLocale } from '$lib/i18n';
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte'; import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -136,10 +136,29 @@
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) { if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
_prevProviderId = form.provider_id; _prevProviderId = form.provider_id;
loadCollections(); loadCollections();
// Auto-select first available tracking/template config for this provider when creating
if (editing === null) {
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
if (ptype) {
if (!form.default_tracking_config_id) {
const first = trackingConfigs.find(c => c.provider_type === ptype);
if (first) form.default_tracking_config_id = first.id;
}
if (!form.default_template_config_id) {
const first = templateConfigs.find(c => c.provider_type === ptype);
if (first) form.default_template_config_id = first.id;
}
}
}
} }
}); });
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; } function openNew() {
form = defaultForm();
// Auto-select first provider if any
if (providers.length > 0) form.provider_id = providers[0].id;
editing = null; showForm = true; collections = []; previousCollectionIds = [];
}
async function edit(trk: Tracker) { async function edit(trk: Tracker) {
form = { form = {
@@ -256,7 +275,7 @@
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
if (!dateStr) return ''; if (!dateStr) return '';
try { try {
const d = new Date(dateStr); const d = parseDate(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch (e) { console.warn('Date format error:', e); return ''; } } catch (e) { console.warn('Date format error:', e); return ''; }
} }
@@ -339,8 +358,19 @@
if (ttTesting[key]) return; if (ttTesting[key]) return;
ttTesting = { ...ttTesting, [key]: testType }; ttTesting = { ...ttTesting, [key]: testType };
try { try {
await api(`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' }); // The endpoint returns 200 OK with ``{success: false, error: "..."}``
// on soft failures (missing template slot, no matching assets,
// provider unreachable, etc.), so checking for a thrown exception
// is not enough. Surface ``error`` as a snackError when present.
const res = await api<{ success?: boolean; error?: string; target?: string }>(
`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`,
{ method: 'POST' },
);
if (res && res.success === false) {
snackError(res.error || t('common.error'));
} else {
snackSuccess(t('snack.targetTestSent')); snackSuccess(t('snack.targetTestSent'));
}
} catch (err: any) { } catch (err: any) {
snackError(err.message); snackError(err.message);
} finally { } finally {
+10 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte'; import { providersCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -11,6 +11,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import IconButton from '$lib/components/IconButton.svelte'; import IconButton from '$lib/components/IconButton.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte'; import IconGridSelect from '$lib/components/IconGridSelect.svelte';
@@ -131,12 +132,17 @@
} }
function startDelete(provider: any) { confirmDelete = provider; } function startDelete(provider: any) { confirmDelete = provider; }
let blockedBy = $state<BlockedByDetail | null>(null);
async function doDelete() { async function doDelete() {
if (!confirmDelete) return; if (!confirmDelete) return;
const id = confirmDelete.id; const id = confirmDelete.id;
confirmDelete = null; confirmDelete = null;
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); } try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); } catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
} }
</script> </script>
@@ -280,6 +286,8 @@
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')} <ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
onconfirm={doDelete} oncancel={() => confirmDelete = null} /> onconfirm={doDelete} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style> <style>
.health-dot { .health-dot {
width: 10px; width: 10px;
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { api } from '$lib/api'; import { api, parseDate } from '$lib/api';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import type { WebhookPayloadLog } from '$lib/types'; import type { WebhookPayloadLog } from '$lib/types';
@@ -65,7 +65,7 @@
} }
function formatTime(iso: string): string { function formatTime(iso: string): string {
return new Date(iso).toLocaleString(); return parseDate(iso).toLocaleString();
} }
</script> </script>
@@ -19,6 +19,7 @@
telegram_webhook_secret: '', telegram_webhook_secret: '',
telegram_cache_ttl_hours: '48', telegram_cache_ttl_hours: '48',
supported_locales: 'en,ru', supported_locales: 'en,ru',
timezone: 'UTC',
}); });
onMount(async () => { onMount(async () => {
@@ -57,6 +58,11 @@
<input bind:value={settings.external_url} placeholder="https://notify.example.com" <input bind:value={settings.external_url} placeholder="https://notify.example.com"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" /> class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div> </div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
<input bind:value={settings.timezone} placeholder="UTC"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div>
</div> </div>
</Card> </Card>
+161 -21
View File
@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api, fetchAuth } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte'; import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
@@ -60,15 +59,23 @@
let backupFiles = $state<any[]>([]); let backupFiles = $state<any[]>([]);
let loadingFiles = $state(false); let loadingFiles = $state(false);
let confirmDeleteFile = $state(''); let confirmDeleteFile = $state('');
let creatingBackup = $state(false);
// --- Pending restore state ---
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
let postRestoreModalOpen = $state(false);
let restartingOverlay = $state(false);
onMount(async () => { onMount(async () => {
try { try {
const [settings, files] = await Promise.all([ const [settings, files, p] = await Promise.all([
api('/backup/scheduled'), api('/backup/scheduled'),
api('/backup/files'), api('/backup/files'),
api('/backup/pending-restore'),
]); ]);
scheduledSettings = settings; scheduledSettings = settings;
backupFiles = files; backupFiles = files;
pending = p;
} catch (err: any) { } catch (err: any) {
error = err.message; error = err.message;
snackError(err.message); snackError(err.message);
@@ -77,6 +84,53 @@
} }
}); });
async function cancelPending() {
try {
await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled'));
pending = null;
} catch (err: any) { snackError(err.message); }
}
async function applyAndRestart() {
try {
await api('/backup/apply-restart', { method: 'POST' });
restartingOverlay = true;
// Poll /health until the new instance is up
const startedAt = Date.now();
let attempts = 0;
const poll = async () => {
attempts += 1;
try {
const res = await fetch('/api/health');
if (res.ok && Date.now() - startedAt > 2000) {
window.location.reload();
return;
}
} catch { /* still down */ }
if (attempts < 120) setTimeout(poll, 1000);
};
setTimeout(poll, 1500);
} catch (err: any) {
restartingOverlay = false;
snackError(err.message);
}
}
async function createManualBackup() {
creatingBackup = true;
try {
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
snackSuccess(t('backup.manualCreated'));
await refreshFiles();
} catch (err: any) {
snackError(err.message);
} finally {
creatingBackup = false;
}
}
// --- Export --- // --- Export ---
async function doExport() { async function doExport() {
if (exportSecrets === 'include') { if (exportSecrets === 'include') {
@@ -120,16 +174,7 @@
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', importFile); formData.append('file', importFile);
const token = localStorage.getItem('access_token'); const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
const res = await fetch('/api/backup/validate', {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
validationResult = await res.json(); validationResult = await res.json();
} catch (err: any) { } catch (err: any) {
snackError(err.message); snackError(err.message);
@@ -151,18 +196,15 @@
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', importFile); formData.append('file', importFile);
const token = localStorage.getItem('access_token'); const res = await fetchAuth(`/backup/prepare-restore?conflict_mode=${importConflict}`, {
const res = await fetch(`/api/backup/import?conflict_mode=${importConflict}`, {
method: 'POST', method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData, body: formData,
}); });
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
importResult = await res.json(); importResult = await res.json();
snackSuccess(t('backup.importSuccess')); pending = importResult;
snackSuccess(t('backup.restorePrepared'));
postRestoreModalOpen = true;
importFile = null;
} catch (err: any) { } catch (err: any) {
snackError(err.message); snackError(err.message);
} finally { } finally {
@@ -256,6 +298,33 @@
<Loading /> <Loading />
{:else} {:else}
<ErrorBanner message={error} /> <ErrorBanner message={error} />
{#if pending?.pending}
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
<MdiIcon name="mdiClockAlert" size={20} />
</span>
<div class="flex-1 min-w-[12rem] text-sm">
<div class="font-medium">{t('backup.pendingTitle')}</div>
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
{#if pending.supervised}
<Button size="sm" onclick={applyAndRestart}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
<button onclick={cancelPending}
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('common.cancel')}
</button>
</div>
</div>
{/if}
<div class="space-y-6"> <div class="space-y-6">
<!-- Export Section --> <!-- Export Section -->
@@ -502,10 +571,15 @@
<MdiIcon name="mdiFolder" size={18} /> <MdiIcon name="mdiFolder" size={18} />
{t('backup.savedFiles')} {t('backup.savedFiles')}
</h3> </h3>
<div class="flex items-center gap-2">
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
</Button>
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}> <button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
<MdiIcon name="mdiRefresh" size={14} /> <MdiIcon name="mdiRefresh" size={14} />
</button> </button>
</div> </div>
</div>
{#if backupFiles.length === 0} {#if backupFiles.length === 0}
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('backup.noFiles')}</p> <p class="text-xs" style="color: var(--color-muted-foreground);">{t('backup.noFiles')}</p>
@@ -568,3 +642,69 @@
onconfirm={() => deleteFile(confirmDeleteFile)} onconfirm={() => deleteFile(confirmDeleteFile)}
oncancel={() => confirmDeleteFile = ''} oncancel={() => confirmDeleteFile = ''}
/> />
<!-- Post-restore modal: Apply now or later -->
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
{#if postRestoreModalOpen && pending?.pending}
<div class="post-restore-backdrop"
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
onclick={() => postRestoreModalOpen = false}
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
role="presentation">
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
onclick={(e) => e.stopPropagation()}>
<div class="flex items-start gap-3 mb-4">
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
<MdiIcon name="mdiClockAlert" size={22} />
</div>
<div class="min-w-0">
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
</div>
</div>
<div class="flex gap-2 justify-end flex-wrap">
<button onclick={() => postRestoreModalOpen = false}
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('backup.applyLater')}
</button>
{#if pending.supervised}
<Button size="sm" onclick={() => { postRestoreModalOpen = false; applyAndRestart(); }}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
</div>
</div>
</div>
{/if}
<!-- Restarting overlay -->
{#if restartingOverlay}
<div role="alert" aria-live="assertive"
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
<div class="text-center p-6" style="color: var(--color-foreground);">
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
<MdiIcon name="mdiRestart" size={40} />
</div>
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
</div>
</div>
{/if}
<style>
.restart-spinner {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
animation: restart-spin 1.2s linear infinite;
transform-origin: center center;
}
@keyframes restart-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
+12 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { api } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n'; import { t, getLocale } from '$lib/i18n';
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte'; import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -114,7 +115,7 @@
const defaultForm = () => ({ const defaultForm = () => ({
name: '', icon: '', bot_id: 0, bot_token: '', name: '', icon: '', bot_id: 0, bot_token: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50, max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing', disable_url_preview: true, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
// Discord/Slack shared settings // Discord/Slack shared settings
username: '', username: '',
// ntfy shared settings // ntfy shared settings
@@ -193,6 +194,10 @@
function openNew() { function openNew() {
form = defaultForm(); form = defaultForm();
formType = activeType || 'telegram'; formType = activeType || 'telegram';
// Auto-select first available bot of the chosen type
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
editing = null; editing = null;
showTelegramSettings = false; showTelegramSettings = false;
showForm = true; showForm = true;
@@ -289,12 +294,15 @@
} catch (err: any) { snackError(err.message); } } catch (err: any) { snackError(err.message); }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
async function remove(id: number) { async function remove(id: number) {
try { try {
await api(`/targets/${id}`, { method: 'DELETE' }); await api(`/targets/${id}`, { method: 'DELETE' });
await load(); await load();
snackSuccess(t('snack.targetDeleted')); snackSuccess(t('snack.targetDeleted'));
} catch (err: any) { } catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; error = err.message;
snackError(err.message); snackError(err.message);
} }
@@ -529,3 +537,5 @@
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }} onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
oncancel={() => confirmDeleteReceiver = null} oncancel={() => confirmDeleteReceiver = null}
/> />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize'; import { sanitizePreview } from '$lib/sanitize';
import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte'; import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
@@ -208,7 +209,12 @@
finally { loaded = true; highlightFromUrl(); } finally { loaded = true; highlightFromUrl(); }
} }
function openNew() { form = defaultForm(); editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; refreshDateFormatPreview(); } function openNew() {
form = defaultForm();
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
refreshDateFormatPreview();
}
function edit(c: TemplateConfig) { function edit(c: TemplateConfig) {
form = { form = {
provider_type: c.provider_type, provider_type: c.provider_type,
@@ -253,12 +259,17 @@
setTimeout(() => refreshAllPreviews(), 100); setTimeout(() => refreshAllPreviews(), 100);
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) { function remove(id: number) {
confirmDelete = { confirmDelete = {
id, id,
onconfirm: async () => { onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); } try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); } catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDelete = null; } finally { confirmDelete = null; }
} }
}; };
@@ -455,6 +466,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal --> <!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}> <Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]} {#if showVarsFor && varsRef[showVarsFor]}
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { trackingConfigsCache } from '$lib/stores/caches.svelte'; import { trackingConfigsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -72,12 +73,17 @@
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) { function remove(id: number) {
confirmDelete = { confirmDelete = {
id, id,
onconfirm: async () => { onconfirm: async () => {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); } try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); } catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDelete = null; } finally { confirmDelete = null; }
} }
}; };
@@ -175,9 +181,12 @@
{:else if field.type === 'grid-select' && field.gridItems} {:else if field.type === 'grid-select' && field.gridItems}
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact /> <IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
{:else} {:else}
<input type={field.key.includes('date') ? 'date' : field.key.includes('times') ? 'text' : 'number'} <input type={field.key.includes('date') ? 'date'
: field.key.startsWith('quiet_hours_') ? 'time'
: field.key.includes('times') ? 'text'
: 'number'}
bind:value={form[field.key]} min={field.min} max={field.max} bind:value={form[field.key]} min={field.min} max={field.max}
placeholder={field.key.includes('times') ? String(field.defaultValue ?? '') : ''} placeholder={field.key.includes('times') || field.key.startsWith('quiet_hours_') ? String(field.defaultValue ?? '') : ''}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if} {/if}
</div> </div>
@@ -257,6 +266,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style> <style>
.toggle-switch { .toggle-switch {
position: relative; position: relative;
+47 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte'; import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -31,6 +31,13 @@
let resetMsg = $state(''); let resetMsg = $state('');
let resetSuccess = $state(false); 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); onMount(load);
async function load() { async function load() {
try { users = await api('/users'); } try { users = await api('/users'); }
@@ -56,6 +63,20 @@
function openResetPassword(user: any) { function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false; 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) { async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false; e.preventDefault(); resetMsg = ''; resetSuccess = false;
try { try {
@@ -111,9 +132,10 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium">{user.username}</p> <p class="font-medium">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}</p> <p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id} {#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} /> <IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" /> <IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
@@ -144,5 +166,28 @@
</form> </form>
</Modal> </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')} <ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-core" name = "notify-bridge-core"
version = "0.1.0" version = "0.2.1"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates" description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -16,6 +16,13 @@ from .ssrf import UnsafeURLError, validate_outbound_url
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30) _HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
# Cap on how many asset downloads run concurrently inside
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
# max_asset_size``, which matters on small-RAM Docker hosts when a batch
# contains many large videos.
_PRELOAD_CONCURRENCY = 6
def _new_session() -> aiohttp.ClientSession: def _new_session() -> aiohttp.ClientSession:
"""Per-dispatch aiohttp session with a sane default timeout. """Per-dispatch aiohttp session with a sane default timeout.
@@ -38,6 +45,11 @@ from .receiver import (
) )
from .telegram.cache import TelegramFileCache from .telegram.cache import TelegramFileCache
from .telegram.client import TelegramClient from .telegram.client import TelegramClient
from .telegram.media import (
extract_asset_id_from_url,
is_asset_cache_key,
is_asset_id,
)
from .webhook.client import WebhookClient from .webhook.client import WebhookClient
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -146,6 +158,90 @@ class NotificationDispatcher:
return await send_method(target, default_message, event) return await send_method(target, default_message, event)
return {"success": False, "error": f"Unknown target type: {target.type}"} return {"success": False, "error": f"Unknown target type: {target.type}"}
async def _preload_asset_data(
self,
assets: list[dict[str, Any]],
media_assets: list[Any],
session: aiohttp.ClientSession,
max_size: int | None,
) -> None:
"""Download each non-cached asset's bytes once and attach to the entry.
Three benefits:
* ``TelegramClient`` sees ``entry["data"]`` and skips its own download,
so we don't fetch each URL twice.
* We know the exact upload size, which lets the oversize warning in
the rendered text compare against real bytes (for Immich videos,
the transcoded ``/video/playback``), not the original ``file_size``.
* Assets already in the Telegram file_id cache are skipped, and their
stored size (if any) is used to populate ``playback_size`` so
templates see consistent sizes for repeat sends without re-download.
Entries whose download fails or exceeds ``max_size`` are left without
``data``; ``TelegramClient`` will then fall back to its own download
path and apply the same checks no regression, just no preload win.
Concurrency is bounded by ``_PRELOAD_CONCURRENCY`` so peak memory
stays predictable: at most N assets worth of bytes held in RAM at
once, regardless of ``max_media_to_send``. Total wall-clock is
unchanged for small batches and only marginally slower for large
ones (most assets fit in a single RTT and SSL negotiation cost
dominates, so 6-way parallelism is sufficient).
"""
if not assets:
return
sem = asyncio.Semaphore(_PRELOAD_CONCURRENCY)
async def _fetch(entry: dict[str, Any], media: Any) -> None:
# Cache hit → skip download; populate playback_size from stored size.
cache, key = self._cache_for_entry(entry)
if cache and key:
cached = cache.get(key)
if cached and cached.get("file_id"):
stored_size = cached.get("size")
if stored_size is not None:
media.extra["playback_size"] = stored_size
return
url = entry["url"]
headers = entry.get("headers") or {}
async with sem:
try:
async with session.get(url, headers=headers) as resp:
if resp.status != 200:
return
data = await resp.read()
except aiohttp.ClientError:
return
if max_size is not None and len(data) > max_size:
return
entry["data"] = data
media.extra["playback_size"] = len(data)
await asyncio.gather(*(_fetch(e, m) for e, m in zip(assets, media_assets)))
def _cache_for_entry(
self, entry: dict[str, Any],
) -> tuple[TelegramFileCache | None, str | None]:
"""Resolve (cache, key) for an asset entry — mirrors TelegramClient logic.
Returns (None, None) if no cache is configured or no key can be derived.
"""
cache_key = entry.get("cache_key")
if cache_key:
cache = self._asset_cache if is_asset_cache_key(cache_key) else self._url_cache
return cache, cache_key
url = entry.get("url")
if url:
if is_asset_id(url):
return self._asset_cache, url
extracted = extract_asset_id_from_url(url)
if extracted:
return self._asset_cache, extracted
return self._url_cache, url
return None, None
async def _send_telegram( async def _send_telegram(
self, target: TargetConfig, default_message: str, event: ServiceEvent self, target: TargetConfig, default_message: str, event: ServiceEvent
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -172,6 +268,7 @@ class NotificationDispatcher:
external_url = (target.provider_external_url or "").rstrip("/") external_url = (target.provider_external_url or "").rstrip("/")
provider_urls = [u for u in (internal_url, external_url) if u] provider_urls = [u for u in (internal_url, external_url) if u]
assets = [] assets = []
media_assets: list[Any] = [] # aligned with `assets` for preload
for asset in event.added_assets[:max_media]: for asset in event.added_assets[:max_media]:
url = asset.preview_url or asset.thumbnail_url or asset.full_url url = asset.preview_url or asset.thumbnail_url or asset.full_url
if url: if url:
@@ -187,9 +284,16 @@ class NotificationDispatcher:
if asset.extra.get("cache_key"): if asset.extra.get("cache_key"):
asset_entry["cache_key"] = asset.extra["cache_key"] asset_entry["cache_key"] = asset.extra["cache_key"]
assets.append(asset_entry) assets.append(asset_entry)
media_assets.append(asset)
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
async with _new_session() as session: async with _new_session() as session:
# Preload all asset bytes once so (a) TelegramClient can skip its
# own download and (b) we know exact upload sizes in time for the
# oversize warning in the rendered text.
await self._preload_asset_data(assets, media_assets, session, max_size)
default_message = self._render_message(event, target, target.locale)
client = TelegramClient( client = TelegramClient(
session, bot_token, session, bot_token,
url_cache=self._url_cache, url_cache=self._url_cache,
@@ -84,10 +84,19 @@ class TelegramFileCache:
if age > self._ttl_seconds: if age > self._ttl_seconds:
return None return None
return {"file_id": entry.get("file_id"), "type": entry.get("type")} return {
"file_id": entry.get("file_id"),
"type": entry.get("type"),
"size": entry.get("size"), # bytes of what was uploaded; None for legacy entries
}
async def async_set( async def async_set(
self, key: str, file_id: str, media_type: str, thumbhash: str | None = None self,
key: str,
file_id: str,
media_type: str,
thumbhash: str | None = None,
size: int | None = None,
) -> None: ) -> None:
if self._data is None: if self._data is None:
self._data = {"files": {}} self._data = {"files": {}}
@@ -99,20 +108,34 @@ class TelegramFileCache:
} }
if thumbhash is not None: if thumbhash is not None:
entry["thumbhash"] = thumbhash entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry self._data["files"][key] = entry
await self._backend.save(self._data) await self._backend.save(self._data)
async def async_set_many( async def async_set_many(
self, entries: list[tuple[str, str, str, str | None]] self,
entries: list[tuple[str, str, str, str | None] | tuple[str, str, str, str | None, int | None]],
) -> None: ) -> None:
"""Bulk-store file_id cache entries.
Each entry is a tuple ``(key, file_id, media_type, thumbhash[, size])``.
The size element is optional for backward compatibility with callers
that don't yet track upload sizes.
"""
if not entries: if not entries:
return return
if self._data is None: if self._data is None:
self._data = {"files": {}} self._data = {"files": {}}
now_iso = datetime.now(timezone.utc).isoformat() now_iso = datetime.now(timezone.utc).isoformat()
for key, file_id, media_type, thumbhash in entries: for item in entries:
if len(item) == 5:
key, file_id, media_type, thumbhash, size = item
else:
key, file_id, media_type, thumbhash = item
size = None
entry: dict[str, Any] = { entry: dict[str, Any] = {
"file_id": file_id, "file_id": file_id,
"type": media_type, "type": media_type,
@@ -120,6 +143,8 @@ class TelegramFileCache:
} }
if thumbhash is not None: if thumbhash is not None:
entry["thumbhash"] = thumbhash entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry self._data["files"][key] = entry
await self._backend.save(self._data) await self._backend.save(self._data)
@@ -6,6 +6,7 @@ import asyncio
import json import json
import logging import logging
import mimetypes import mimetypes
from dataclasses import dataclass
from typing import Any, Callable from typing import Any, Callable
import aiohttp import aiohttp
@@ -29,6 +30,36 @@ _LOGGER = logging.getLogger(__name__)
NotificationResult = dict[str, Any] NotificationResult = dict[str, Any]
@dataclass(frozen=True)
class _MediaKind:
"""Describes one Telegram media kind (photo / video / document).
Used by the generic _send_from_cache / _upload_media helpers so the three
send paths don't have to duplicate endpoint, field-name, or response-shape
boilerplate.
"""
api_method: str # "sendPhoto" / "sendVideo" / "sendDocument"
form_field: str # "photo" / "video" / "document"
cache_type: str # same string stored in cache entries
default_filename: str # "photo.jpg" / "video.mp4" / "file"
default_content_type: str
def file_id_from_result(self, result: dict[str, Any]) -> str | None:
obj = result.get(self.form_field)
if isinstance(obj, list) and obj:
# sendPhoto returns a list of resolutions; the largest is last.
last = obj[-1]
return last.get("file_id") if isinstance(last, dict) else None
if isinstance(obj, dict):
return obj.get("file_id")
return None
_PHOTO_KIND = _MediaKind("sendPhoto", "photo", "photo", "photo.jpg", "image/jpeg")
_VIDEO_KIND = _MediaKind("sendVideo", "video", "video", "video.mp4", "video/mp4")
_DOCUMENT_KIND = _MediaKind("sendDocument", "document", "document", "file", "application/octet-stream")
class TelegramClient: class TelegramClient:
"""Async Telegram Bot API client for sending notifications with media.""" """Async Telegram Bot API client for sending notifications with media."""
@@ -76,6 +107,94 @@ class TelegramClient:
is_asset = is_asset_cache_key(key) is_asset = is_asset_cache_key(key)
return self._asset_cache if is_asset else self._url_cache return self._asset_cache if is_asset else self._url_cache
async def _fetch_bytes(
self,
url: str,
headers: dict[str, str] | None,
preloaded: bytes | None,
) -> tuple[bytes | None, str | None]:
"""Return ``(data, error_msg)``. Uses ``preloaded`` bytes if provided."""
if preloaded is not None:
return preloaded, None
try:
async with self._session.get(self._resolve_url(url), headers=headers or {}) as resp:
if resp.status != 200:
return None, f"HTTP {resp.status}"
return await resp.read(), None
except aiohttp.ClientError as err:
return None, str(err)
async def _send_from_cache(
self,
kind: _MediaKind,
chat_id: str,
file_id: str,
caption: str | None,
reply_to_message_id: int | None,
parse_mode: str,
) -> NotificationResult | None:
"""POST a file_id reference. Return None on transient error so the
caller can fall through to a fresh upload."""
payload: dict[str, Any] = {"chat_id": chat_id, kind.form_field: file_id, "parse_mode": parse_mode}
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {
"success": True,
"message_id": result.get("result", {}).get("message_id"),
"cached": True,
}
except aiohttp.ClientError:
pass
return None
async def _upload_media(
self,
kind: _MediaKind,
chat_id: str,
data: bytes,
filename: str,
content_type: str,
caption: str | None,
reply_to_message_id: int | None,
parse_mode: str,
cache: TelegramFileCache | None,
cache_key: str | None,
thumbhash: str | None,
) -> NotificationResult:
"""Multipart-upload ``data`` to Telegram and cache the returned file_id."""
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field(kind.form_field, data, filename=filename, content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
try:
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
res = result.get("result", {})
file_id = kind.file_id_from_result(res)
if file_id and cache and cache_key:
await cache.async_set(
cache_key, file_id, kind.cache_type,
thumbhash=thumbhash, size=len(data),
)
return {"success": True, "message_id": res.get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def send_notification( async def send_notification(
self, self,
chat_id: str, chat_id: str,
@@ -107,6 +226,7 @@ class TelegramClient:
parse_mode, max_asset_data_size, send_large_photos_as_documents, parse_mode, max_asset_data_size, send_large_photos_as_documents,
assets[0].get("content_type"), assets[0].get("cache_key"), assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"), download_headers=assets[0].get("headers"),
preloaded_data=assets[0].get("data"),
) )
if len(assets) == 1 and assets[0].get("type") == "video": if len(assets) == 1 and assets[0].get("type") == "video":
return await self._send_video( return await self._send_video(
@@ -114,11 +234,14 @@ class TelegramClient:
parse_mode, max_asset_data_size, parse_mode, max_asset_data_size,
assets[0].get("content_type"), assets[0].get("cache_key"), assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"), download_headers=assets[0].get("headers"),
preloaded_data=assets[0].get("data"),
) )
if len(assets) == 1 and assets[0].get("type", "document") == "document": if len(assets) == 1 and assets[0].get("type", "document") == "document":
url = assets[0].get("url") url = assets[0].get("url")
if not url: if not url:
return {"success": False, "error": "Missing 'url' for document"} return {"success": False, "error": "Missing 'url' for document"}
data = assets[0].get("data")
if data is None:
try: try:
download_url = self._resolve_url(url) download_url = self._resolve_url(url)
dl_headers = assets[0].get("headers") or {} dl_headers = assets[0].get("headers") or {}
@@ -126,6 +249,8 @@ class TelegramClient:
if resp.status != 200: if resp.status != 200:
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"} return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
data = await resp.read() data = await resp.read()
except aiohttp.ClientError as err:
return {"success": False, "error": f"Failed to download media: {err}"}
if max_asset_data_size is not None and len(data) > max_asset_data_size: if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": f"Media size exceeds limit"} return {"success": False, "error": f"Media size exceeds limit"}
filename = url.split("/")[-1].split("?")[0] or "file" filename = url.split("/")[-1].split("?")[0] or "file"
@@ -134,8 +259,6 @@ class TelegramClient:
parse_mode, url, assets[0].get("content_type"), parse_mode, url, assets[0].get("content_type"),
assets[0].get("cache_key"), assets[0].get("cache_key"),
) )
except aiohttp.ClientError as err:
return {"success": False, "error": f"Failed to download media: {err}"}
return await self._send_media_group( return await self._send_media_group(
chat_id, assets, caption, reply_to_message_id, max_group_size, chat_id, assets, caption, reply_to_message_id, max_group_size,
@@ -177,6 +300,16 @@ class TelegramClient:
# Retry without parse_mode on parse errors # Retry without parse_mode on parse errors
desc = str(result.get("description", "")) desc = str(result.get("description", ""))
if "parse" in desc.lower(): if "parse" in desc.lower():
# Log loudly: a parse failure means the template author (or
# an asset field) is producing malformed HTML. Silent
# fallback hides bugs and makes XSS-via-unescaped-field
# harder to spot. Do not log the full payload — it may
# contain secrets.
_LOGGER.warning(
"Telegram rejected parse_mode=%s (%r); retrying as plain text. "
"Check template output for unescaped characters.",
payload.get("parse_mode"), desc,
)
payload.pop("parse_mode", None) payload.pop("parse_mode", None)
async with self._session.post(telegram_url, json=payload) as retry_resp: async with self._session.post(telegram_url, json=payload) as retry_resp:
retry_result = await retry_resp.json() retry_result = await retry_resp.json()
@@ -211,37 +344,24 @@ class TelegramClient:
max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False, max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False,
content_type: str | None = None, cache_key: str | None = None, content_type: str | None = None, cache_key: str | None = None,
download_headers: dict[str, str] | None = None, download_headers: dict[str, str] | None = None,
preloaded_data: bytes | None = None,
) -> NotificationResult: ) -> NotificationResult:
if not content_type:
content_type = "image/jpeg"
if not url: if not url:
return {"success": False, "error": "Missing 'url' for photo"} return {"success": False, "error": "Missing 'url' for photo"}
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key) cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
# Check cache
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
if cached and cached.get("file_id"): if cached and cached.get("file_id"):
payload = {"chat_id": chat_id, "photo": cached["file_id"], "parse_mode": parse_mode} cached_result = await self._send_from_cache(
if caption: _PHOTO_KIND, chat_id, cached["file_id"],
payload["caption"] = caption caption, reply_to_message_id, parse_mode,
if reply_to_message_id: )
payload["reply_parameters"] = {"message_id": reply_to_message_id} if cached_result is not None:
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto" return cached_result
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
except aiohttp.ClientError:
pass
try: data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
download_url = self._resolve_url(url) if data is None:
async with self._session.get(download_url, headers=download_headers or {}) as resp: return {"success": False, "error": f"Failed to download photo: {err}"}
if resp.status != 200:
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
data = await resp.read()
if max_asset_data_size is not None and len(data) > max_asset_data_size: if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": "Photo exceeds size limit", "skipped": True} return {"success": False, "error": "Photo exceeds size limit", "skipped": True}
@@ -249,95 +369,60 @@ class TelegramClient:
exceeds_limits, reason, _, _ = check_photo_limits(data) exceeds_limits, reason, _, _ = check_photo_limits(data)
if exceeds_limits: if exceeds_limits:
if send_large_photos_as_documents: if send_large_photos_as_documents:
return await self._send_document(chat_id, data, "photo.jpg", caption, reply_to_message_id, parse_mode, url, None, cache_key) return await self._send_document(
chat_id, data, "photo.jpg", caption, reply_to_message_id,
parse_mode, url, None, cache_key,
)
return {"success": False, "error": f"Photo {reason}", "skipped": True} return {"success": False, "error": f"Photo {reason}", "skipped": True}
form = FormData() return await self._upload_media(
form.add_field("chat_id", chat_id) _PHOTO_KIND, chat_id, data,
form.add_field("photo", data, filename="photo.jpg", content_type=content_type) _PHOTO_KIND.default_filename,
form.add_field("parse_mode", parse_mode) content_type or _PHOTO_KIND.default_content_type,
if caption: caption, reply_to_message_id, parse_mode,
form.add_field("caption", caption) cache, key, thumbhash,
if reply_to_message_id: )
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
photos = result.get("result", {}).get("photo", [])
if photos and effective_cache and effective_cache_key:
file_id = photos[-1].get("file_id")
if file_id:
await effective_cache.async_set(effective_cache_key, file_id, "photo", thumbhash=effective_thumbhash)
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def _send_video( async def _send_video(
self, chat_id: str, url: str | None, caption: str | None = None, self, chat_id: str, url: str | None, caption: str | None = None,
reply_to_message_id: int | None = None, parse_mode: str = "HTML", reply_to_message_id: int | None = None, parse_mode: str = "HTML",
max_asset_data_size: int | None = None, content_type: str | None = None, max_asset_data_size: int | None = None, content_type: str | None = None,
cache_key: str | None = None, download_headers: dict[str, str] | None = None, cache_key: str | None = None, download_headers: dict[str, str] | None = None,
preloaded_data: bytes | None = None,
) -> NotificationResult: ) -> NotificationResult:
if not content_type:
content_type = "video/mp4"
if not url: if not url:
return {"success": False, "error": "Missing 'url' for video"} return {"success": False, "error": "Missing 'url' for video"}
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key) cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
if cached and cached.get("file_id"): if cached and cached.get("file_id"):
payload = {"chat_id": chat_id, "video": cached["file_id"], "parse_mode": parse_mode} cached_result = await self._send_from_cache(
if caption: _VIDEO_KIND, chat_id, cached["file_id"],
payload["caption"] = caption caption, reply_to_message_id, parse_mode,
if reply_to_message_id: )
payload["reply_parameters"] = {"message_id": reply_to_message_id} if cached_result is not None:
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo" return cached_result
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
except aiohttp.ClientError:
pass
try: data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
download_url = self._resolve_url(url) if data is None:
async with self._session.get(download_url, headers=download_headers or {}) as resp: return {"success": False, "error": f"Failed to download video: {err}"}
if resp.status != 200:
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
data = await resp.read()
if max_asset_data_size is not None and len(data) > max_asset_data_size: if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": "Video exceeds size limit", "skipped": True} return {"success": False, "error": "Video exceeds size limit", "skipped": True}
if len(data) > TELEGRAM_MAX_VIDEO_SIZE: if len(data) > TELEGRAM_MAX_VIDEO_SIZE:
return {"success": False, "error": f"Video exceeds Telegram's {TELEGRAM_MAX_VIDEO_SIZE // (1024*1024)} MB limit", "skipped": True} return {
"success": False,
"error": f"Video exceeds Telegram's {TELEGRAM_MAX_VIDEO_SIZE // (1024*1024)} MB limit",
"skipped": True,
}
form = FormData() return await self._upload_media(
form.add_field("chat_id", chat_id) _VIDEO_KIND, chat_id, data,
form.add_field("video", data, filename="video.mp4", content_type=content_type) _VIDEO_KIND.default_filename,
form.add_field("parse_mode", parse_mode) content_type or _VIDEO_KIND.default_content_type,
if caption: caption, reply_to_message_id, parse_mode,
form.add_field("caption", caption) cache, key, thumbhash,
if reply_to_message_id: )
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
video = result.get("result", {}).get("video", {})
if video and effective_cache and effective_cache_key:
file_id = video.get("file_id")
if file_id:
await effective_cache.async_set(effective_cache_key, file_id, "video", thumbhash=effective_thumbhash)
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def _send_document( async def _send_document(
self, chat_id: str, data: bytes, filename: str = "file", self, chat_id: str, data: bytes, filename: str = "file",
@@ -348,50 +433,24 @@ class TelegramClient:
if not content_type: if not content_type:
content_type, _ = mimetypes.guess_type(filename) content_type, _ = mimetypes.guess_type(filename)
if not content_type: if not content_type:
content_type = "application/octet-stream" content_type = _DOCUMENT_KIND.default_content_type
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(source_url, cache_key) cache, key, thumbhash = self._get_cache_and_key(source_url, cache_key)
if cache and key:
cached = cache.get(key, thumbhash=thumbhash)
if cached and cached.get("file_id") and cached.get("type") == _DOCUMENT_KIND.cache_type:
cached_result = await self._send_from_cache(
_DOCUMENT_KIND, chat_id, cached["file_id"],
caption, reply_to_message_id, parse_mode,
)
if cached_result is not None:
return cached_result
if effective_cache and effective_cache_key: return await self._upload_media(
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) _DOCUMENT_KIND, chat_id, data, filename, content_type,
if cached and cached.get("file_id") and cached.get("type") == "document": caption, reply_to_message_id, parse_mode,
payload = {"chat_id": chat_id, "document": cached["file_id"], "parse_mode": parse_mode} cache, key, thumbhash,
if caption: )
payload["caption"] = caption
if reply_to_message_id:
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
except aiohttp.ClientError:
pass
try:
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("document", data, filename=filename, content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
if effective_cache_key and effective_cache:
document = result.get("result", {}).get("document", {})
file_id = document.get("file_id")
if file_id:
await effective_cache.async_set(effective_cache_key, file_id, "document", thumbhash=effective_thumbhash)
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def _send_media_group( async def _send_media_group(
self, chat_id: str, assets: list[dict[str, str]], self, chat_id: str, assets: list[dict[str, str]],
@@ -411,9 +470,9 @@ class TelegramClient:
chunk_caption = caption if chunk_idx == 0 else None chunk_caption = caption if chunk_idx == 0 else None
chunk_reply = reply_to_message_id if chunk_idx == 0 else None chunk_reply = reply_to_message_id if chunk_idx == 0 else None
if item.get("type") == "photo": if item.get("type") == "photo":
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers")) result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"), preloaded_data=item.get("data"))
elif item.get("type") == "video": elif item.get("type") == "video":
result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers")) result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"), preloaded_data=item.get("data"))
else: else:
continue continue
if not result.get("success"): if not result.get("success"):
@@ -433,7 +492,8 @@ class TelegramClient:
# Track cache info per media_json entry (in order) so we can map # Track cache info per media_json entry (in order) so we can map
# Telegram response items back to cache keys for newly uploaded items. # Telegram response items back to cache keys for newly uploaded items.
# None = already cached (no need to store), tuple = needs caching. # None = already cached (no need to store), tuple = needs caching.
media_cache_info: list[tuple[str, str, str | None] | None] = [] # Tuple is (cache_key, media_type, thumbhash, uploaded_size).
media_cache_info: list[tuple[str, str, str | None, int] | None] = []
# Resolve cache hits and collect download tasks in parallel # Resolve cache hits and collect download tasks in parallel
async def _fetch_asset(idx: int, item: dict) -> tuple[int, dict | None, bytes | None]: async def _fetch_asset(idx: int, item: dict) -> tuple[int, dict | None, bytes | None]:
@@ -454,6 +514,20 @@ class TelegramClient:
if cached and cached.get("file_id"): if cached and cached.get("file_id"):
return idx, cached, None return idx, cached, None
# Use preloaded bytes if the dispatcher already fetched them
preloaded = item.get("data")
if preloaded is not None:
data = preloaded
if max_asset_data_size and len(data) > max_asset_data_size:
return idx, None, None
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
return idx, None, None
if media_type == "photo":
exceeds, _, _, _ = check_photo_limits(data)
if exceeds:
return idx, None, None
return idx, None, data
try: try:
download_url = self._resolve_url(url) download_url = self._resolve_url(url)
dl_headers = item.get("headers") or {} dl_headers = item.get("headers") or {}
@@ -500,7 +574,7 @@ class TelegramClient:
ck_is_asset = is_asset_cache_key(ck) ck_is_asset = is_asset_cache_key(ck)
bare_ck = asset_id_from_cache_key(ck) if ck_is_asset else ck bare_ck = asset_id_from_cache_key(ck) if ck_is_asset else ck
th = self._thumbhash_resolver(bare_ck) if ck_is_asset and self._thumbhash_resolver else None th = self._thumbhash_resolver(bare_ck) if ck_is_asset and self._thumbhash_resolver else None
media_cache_info.append((ck, media_type, th)) media_cache_info.append((ck, media_type, th, len(data)))
else: else:
continue continue
@@ -523,14 +597,14 @@ class TelegramClient:
all_message_ids.extend(msg.get("message_id") for msg in result_msgs) all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
# Cache file_ids from response — map by position # Cache file_ids from response — map by position
cache_entries: list[tuple[str, str, str, str | None]] = [] cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
for i, msg in enumerate(result_msgs): for i, msg in enumerate(result_msgs):
if i >= len(media_cache_info): if i >= len(media_cache_info):
break break
info = media_cache_info[i] info = media_cache_info[i]
if info is None: if info is None:
continue # was a cache hit, skip continue # was a cache hit, skip
ck, mt, th = info ck, mt, th, sz = info
file_id = None file_id = None
if msg.get("photo"): if msg.get("photo"):
file_id = msg["photo"][-1].get("file_id") file_id = msg["photo"][-1].get("file_id")
@@ -539,7 +613,7 @@ class TelegramClient:
elif msg.get("document"): elif msg.get("document"):
file_id = msg["document"].get("file_id") file_id = msg["document"].get("file_id")
if file_id: if file_id:
cache_entries.append((ck, file_id, mt, th)) cache_entries.append((ck, file_id, mt, th, sz))
if cache_entries: if cache_entries:
# All entries in a chunk share the same cache backend # All entries in a chunk share the same cache backend
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0])) eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
@@ -568,6 +642,18 @@ class TelegramClient:
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
return {"success": False, "error": str(err)} return {"success": False, "error": str(err)}
async def get_chat(self, chat_id: str) -> dict[str, Any]:
"""Call getChat to fetch up-to-date chat metadata (title, username, type, etc.)."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getChat"
try:
async with self._session.post(url, json={"chat_id": chat_id}) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True, "result": data.get("result", {})}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def get_webhook_info(self) -> dict[str, Any]: async def get_webhook_info(self) -> dict[str, Any]:
"""Call getWebhookInfo to check current webhook status.""" """Call getWebhookInfo to check current webhook status."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getWebhookInfo" url = f"{TELEGRAM_API_BASE_URL}{self._token}/getWebhookInfo"
@@ -132,8 +132,10 @@ class ImmichActionExecutor(ActionExecutor):
target_album_ids = [single] target_album_ids = [single]
try: try:
# Step 1: Gather candidate assets from criteria # Step 1: Gather candidate assets from criteria. Asset type is
candidate_ids = await self._gather_candidates(criteria) # kept alongside the id so we can pick the first *photo* (not a
# video) as an album thumbnail when one is missing.
candidate_ids, types_by_id = await self._gather_candidates(criteria)
if not candidate_ids: if not candidate_ids:
return RuleResult( return RuleResult(
@@ -146,6 +148,7 @@ class ImmichActionExecutor(ActionExecutor):
) )
# If no target albums and create_if_missing, create one # If no target albums and create_if_missing, create one
album_created_now: set[str] = set()
if not target_album_ids and create_if_missing and create_album_name: if not target_album_ids and create_if_missing and create_album_name:
if dry_run: if dry_run:
_LOGGER.info("[DRY RUN] Would create album '%s'", create_album_name) _LOGGER.info("[DRY RUN] Would create album '%s'", create_album_name)
@@ -153,6 +156,8 @@ class ImmichActionExecutor(ActionExecutor):
else: else:
created = await self._client.create_album(create_album_name) created = await self._client.create_album(create_album_name)
target_album_ids = [created.get("id", "")] target_album_ids = [created.get("id", "")]
if target_album_ids[0]:
album_created_now.add(target_album_ids[0])
_LOGGER.info("Created album '%s' with id %s", create_album_name, target_album_ids[0]) _LOGGER.info("Created album '%s' with id %s", create_album_name, target_album_ids[0])
if not target_album_ids: if not target_album_ids:
@@ -169,6 +174,7 @@ class ImmichActionExecutor(ActionExecutor):
for album_id in target_album_ids: for album_id in target_album_ids:
album_asset_ids: set[str] = set() album_asset_ids: set[str] = set()
needs_thumbnail = album_id in album_created_now
if album_id and album_id != "__dry_run_new__": if album_id and album_id != "__dry_run_new__":
album = await self._client.get_album(album_id) album = await self._client.get_album(album_id)
@@ -176,27 +182,56 @@ class ImmichActionExecutor(ActionExecutor):
if not dry_run: if not dry_run:
created = await self._client.create_album(create_album_name) created = await self._client.create_album(create_album_name)
album_id = created.get("id", album_id) album_id = created.get("id", album_id)
album_created_now.add(album_id)
needs_thumbnail = True
_LOGGER.info("Created album '%s' with id %s", create_album_name, album_id) _LOGGER.info("Created album '%s' with id %s", create_album_name, album_id)
elif album is None: elif album is None:
album_details.append({"album_id": album_id, "error": "not found"}) album_details.append({"album_id": album_id, "error": "not found"})
continue continue
elif album is not None: elif album is not None:
album_asset_ids = set(album.asset_ids) album_asset_ids = set(album.asset_ids)
if not album.thumbnail_asset_id:
needs_thumbnail = True
new_asset_ids = [aid for aid in candidate_ids if aid not in album_asset_ids] new_asset_ids = [aid for aid in candidate_ids if aid not in album_asset_ids]
skipped = len(candidate_ids) - len(new_asset_ids) skipped = len(candidate_ids) - len(new_asset_ids)
thumbnail_set_id: str | None = None
if new_asset_ids and not dry_run and album_id: if new_asset_ids and not dry_run and album_id:
for i in range(0, len(new_asset_ids), 500): for i in range(0, len(new_asset_ids), 500):
batch = new_asset_ids[i : i + 500] batch = new_asset_ids[i : i + 500]
await self._client.add_assets_to_album(album_id, batch) await self._client.add_assets_to_album(album_id, batch)
_LOGGER.info("Added %d assets to album %s", len(new_asset_ids), album_id) _LOGGER.info("Added %d assets to album %s", len(new_asset_ids), album_id)
# Best-effort: give newly-created/empty-thumbnail albums a
# cover. Prefer the first image; fall back to the first
# added asset of any type if none are images (Immich renders
# a video poster, which still looks fine). Failures here
# must not fail the rule — the add already succeeded.
if needs_thumbnail:
pick = next(
(aid for aid in new_asset_ids if (types_by_id.get(aid) or "").lower() == "image"),
None,
) or new_asset_ids[0]
try:
await self._client.set_album_thumbnail(album_id, pick)
thumbnail_set_id = pick
_LOGGER.info("Set thumbnail of album %s to %s", album_id, pick)
except ImmichApiError as err:
_LOGGER.warning(
"Could not set thumbnail for album %s: %s", album_id, err
)
elif dry_run and new_asset_ids: elif dry_run and new_asset_ids:
_LOGGER.info("[DRY RUN] Would add %d assets to album %s", len(new_asset_ids), album_id) _LOGGER.info("[DRY RUN] Would add %d assets to album %s", len(new_asset_ids), album_id)
if needs_thumbnail:
_LOGGER.info("[DRY RUN] Would set album %s thumbnail to first added asset", album_id)
total_affected += len(new_asset_ids) total_affected += len(new_asset_ids)
total_skipped += skipped total_skipped += skipped
album_details.append({"album_id": album_id, "added": len(new_asset_ids), "skipped": skipped}) detail = {"album_id": album_id, "added": len(new_asset_ids), "skipped": skipped}
if thumbnail_set_id:
detail["thumbnail_set_to"] = thumbnail_set_id
album_details.append(detail)
return RuleResult( return RuleResult(
rule_name=rule_name, rule_name=rule_name,
@@ -228,10 +263,16 @@ class ImmichActionExecutor(ActionExecutor):
async def _gather_candidates( async def _gather_candidates(
self, criteria: dict[str, Any] self, criteria: dict[str, Any]
) -> list[str]: ) -> tuple[list[str], dict[str, str]]:
"""Gather asset IDs matching the criteria (union of all sources).""" """Gather asset IDs matching the criteria (union of all sources).
Returns ``(ordered_ids, types_by_id)`` so callers that need asset
type e.g. picking a photo for an album thumbnail don't have to
re-fetch each asset.
"""
seen: set[str] = set() seen: set[str] = set()
result: list[str] = [] result: list[str] = []
types_by_id: dict[str, str] = {}
# Source 1: Person assets # Source 1: Person assets
person_ids = criteria.get("person_ids", []) person_ids = criteria.get("person_ids", [])
@@ -243,6 +284,7 @@ class ImmichActionExecutor(ActionExecutor):
if self._matches_filters(asset, criteria): if self._matches_filters(asset, criteria):
seen.add(aid) seen.add(aid)
result.append(aid) result.append(aid)
types_by_id[aid] = asset.get("type", "") or ""
# Source 2: Smart search # Source 2: Smart search
query = criteria.get("query", "") query = criteria.get("query", "")
@@ -254,6 +296,7 @@ class ImmichActionExecutor(ActionExecutor):
if self._matches_filters(asset, criteria): if self._matches_filters(asset, criteria):
seen.add(aid) seen.add(aid)
result.append(aid) result.append(aid)
types_by_id[aid] = asset.get("type", "") or ""
# Exclude assets belonging to excluded persons # Exclude assets belonging to excluded persons
exclude_person_ids = criteria.get("exclude_person_ids", []) exclude_person_ids = criteria.get("exclude_person_ids", [])
@@ -266,8 +309,12 @@ class ImmichActionExecutor(ActionExecutor):
if aid: if aid:
excluded_asset_ids.add(aid) excluded_asset_ids.add(aid)
result = [aid for aid in result if aid not in excluded_asset_ids] result = [aid for aid in result if aid not in excluded_asset_ids]
for aid in list(types_by_id):
if aid not in excluded_asset_ids:
continue
types_by_id.pop(aid, None)
return result return result, types_by_id
def _matches_filters( def _matches_filters(
self, asset: dict[str, Any], criteria: dict[str, Any] self, asset: dict[str, Any], criteria: dict[str, Any]
@@ -243,6 +243,16 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
except (ValueError, AttributeError): except (ValueError, AttributeError):
created_at = datetime.now(timezone.utc) created_at = datetime.now(timezone.utc)
# preview_url is what the notification dispatcher feeds to Telegram as the
# actual media bytes — for videos it must be the transcoded playback (mp4),
# not the JPEG thumbnail, or Telegram receives a JPEG labeled as video/mp4.
if asset.type == ASSET_TYPE_VIDEO:
preview_url = f"{external_url}/api/assets/{asset.id}/video/playback"
full_url = f"{external_url}/api/assets/{asset.id}/original"
else:
preview_url = f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview"
full_url = f"{external_url}/api/assets/{asset.id}/original"
return MediaAsset( return MediaAsset(
id=asset.id, id=asset.id,
type=media_type, type=media_type,
@@ -252,8 +262,8 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
description=asset.description or None, description=asset.description or None,
tags=list(asset.people), tags=list(asset.people),
thumbnail_url=f"{external_url}/api/assets/{asset.id}/thumbnail", thumbnail_url=f"{external_url}/api/assets/{asset.id}/thumbnail",
preview_url=f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview", preview_url=preview_url,
full_url=f"{external_url}/api/assets/{asset.id}/original", full_url=full_url,
extra={ extra={
"owner_id": asset.owner_id, "owner_id": asset.owner_id,
"is_favorite": asset.is_favorite, "is_favorite": asset.is_favorite,
@@ -264,7 +274,11 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
"state": asset.state, "state": asset.state,
"country": asset.country, "country": asset.country,
"thumbhash": asset.thumbhash, "thumbhash": asset.thumbhash,
# file_size = original asset bytes (from exifInfo.fileSizeInByte).
# playback_size = bytes we will actually upload (videos: transcoded
# /video/playback). Populated lazily at dispatch time via HEAD.
"file_size": asset.file_size, "file_size": asset.file_size,
"playback_size": None,
}, },
) )
@@ -307,6 +321,12 @@ def collect_scheduled_assets(
asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url) asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url)
collections_extra: list[dict[str, Any]] = [] collections_extra: list[dict[str, Any]] = []
# limit=0 is the periodic-summary test path — the caller only needs
# per-album stats (name/url/counts), not a sample of assets. Skip the
# expensive ``filter_assets`` + sampling loop entirely; on a 50k-asset
# album the serial scan-then-discard pattern wasted seconds per test.
stats_only = limit <= 0
for album_id, album in albums.items(): for album_id, album in albums.items():
links = shared_links.get(album_id, []) links = shared_links.get(album_id, [])
album_public_url = get_public_url(external_url, links) or "" album_public_url = get_public_url(external_url, links) or ""
@@ -322,6 +342,9 @@ def collect_scheduled_assets(
"owner": album.owner, "owner": album.owner,
}) })
if stats_only:
continue
filtered = filter_assets( filtered = filter_assets(
list(album.assets.values()), list(album.assets.values()),
favorite_only=favorite_only, favorite_only=favorite_only,
@@ -334,6 +357,9 @@ def collect_scheduled_assets(
asset_album_map[asset.id] = (album_id, album_public_url) asset_album_map[asset.id] = (album_id, album_public_url)
all_eligible.append(asset) all_eligible.append(asset)
if stats_only:
return [], collections_extra
# Random sample # Random sample
if len(all_eligible) > limit: if len(all_eligible) > limit:
selected = random.sample(all_eligible, limit) selected = random.sample(all_eligible, limit)
@@ -3,14 +3,47 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from typing import Any from typing import Any
import aiohttp import aiohttp
from ...notifications.ssrf import UnsafeURLError, validate_outbound_url
from .models import ImmichAlbumData, SharedLinkInfo from .models import ImmichAlbumData, SharedLinkInfo
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Cap user-controlled Immich search parameters so a low-privileged command
# listener (e.g. an Immich ``/search`` command) cannot DoS the upstream.
MAX_SEARCH_QUERY_LEN = 256
MAX_SEARCH_PERSON_IDS = 50
# User-facing error bodies — Immich responses may leak internal paths,
# hostnames, or headers injected by intermediary proxies. These helpers keep
# only a short, scrubbed summary; full bodies are logged server-side only.
_REDACTED_BODY_MAX = 120
_SECRET_PATTERN = re.compile(
r"(?i)(bearer\s+\S+|x-api-key[:=]\s*\S+|authorization[:=]\s*\S+|cookie[:=]\s*\S+|"
r"password[:=]?\s*\S+|token[:=]?\s*[A-Za-z0-9._\-]+)"
)
def _redact_body(text: str) -> str:
"""Return a short, credential-scrubbed snippet safe to surface to UI callers.
Immich error responses are admin-configurable (via reverse proxies, custom
error pages) and may echo request headers or environment leak. Stripping
anything that looks like a credential + capping length keeps us from
persisting secrets into ``ActionExecution.error`` / ``EventLog.details``
(both of which are returned through the dashboard API).
"""
if not text:
return ""
cleaned = _SECRET_PATTERN.sub("[redacted]", text)
if len(cleaned) > _REDACTED_BODY_MAX:
return cleaned[:_REDACTED_BODY_MAX] + "..."
return cleaned
class ImmichClient: class ImmichClient:
"""Async client for the Immich API.""" """Async client for the Immich API."""
@@ -25,6 +58,18 @@ class ImmichClient:
self._url = url.rstrip("/") self._url = url.rstrip("/")
self._api_key = api_key self._api_key = api_key
self._external_domain: str | None = None self._external_domain: str | None = None
# SSRF guard — admin-set Immich URLs are loaded from provider config
# which can be mutated via PATCH /api/providers or imported via
# prepare-restore, so we revalidate at construction time rather than
# trusting DB state. ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` bypasses
# for dev against localhost Immich.
if self._url:
try:
validate_outbound_url(self._url)
except UnsafeURLError as err:
raise UnsafeURLError(
f"Refusing to build ImmichClient for unsafe URL {self._url!r}: {err}"
) from err
@property @property
def url(self) -> str: def url(self) -> str:
@@ -36,6 +81,15 @@ class ImmichClient:
@external_domain.setter @external_domain.setter
def external_domain(self, value: str | None) -> None: def external_domain(self, value: str | None) -> None:
# Mirror the constructor's SSRF guard — external_domain is used to
# build URLs that leak into rendered notifications, but any code path
# that eventually fetches this URL would otherwise bypass the check.
if value:
try:
validate_outbound_url(value)
except UnsafeURLError as err:
_LOGGER.warning("Ignoring unsafe external_domain %r: %s", value, err)
return
self._external_domain = value self._external_domain = value
@property @property
@@ -235,10 +289,14 @@ class ImmichClient:
query: str, query: str,
album_ids: list[str] | None = None, album_ids: list[str] | None = None,
limit: int = 10, limit: int = 10,
page: int = 1,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"query": query, "page": 1, "size": limit} # Cap user-controlled inputs — a low-privileged Telegram listener can
# craft arbitrarily long queries to DoS the upstream Immich.
query = (query or "")[:MAX_SEARCH_QUERY_LEN]
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids: if album_ids:
payload["albumIds"] = album_ids payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
try: try:
async with self._session.post( async with self._session.post(
f"{self._url}/api/search/smart", f"{self._url}/api/search/smart",
@@ -258,10 +316,12 @@ class ImmichClient:
query: str, query: str,
album_ids: list[str] | None = None, album_ids: list[str] | None = None,
limit: int = 10, limit: int = 10,
page: int = 1,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"originalFileName": query, "page": 1, "size": limit} query = (query or "")[:MAX_SEARCH_QUERY_LEN]
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids: if album_ids:
payload["albumIds"] = album_ids payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
try: try:
async with self._session.post( async with self._session.post(
f"{self._url}/api/search/metadata", f"{self._url}/api/search/metadata",
@@ -279,14 +339,28 @@ class ImmichClient:
async def search_by_person( async def search_by_person(
self, person_id: str, limit: int = 10 self, person_id: str, limit: int = 10
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Fetch up to ``limit`` assets tagged with ``person_id``.
Uses ``POST /api/search/metadata`` with ``personIds`` the public
``GET /api/people/{id}/assets`` endpoint was removed from Immich
around v1.106 and now silently 404s, which is why this method used
to return an empty list on current servers.
"""
payload: dict[str, Any] = {
"personIds": [person_id][:MAX_SEARCH_PERSON_IDS],
"page": 1,
"size": max(1, min(limit, 100)),
}
try: try:
async with self._session.get( async with self._session.post(
f"{self._url}/api/people/{person_id}/assets", f"{self._url}/api/search/metadata",
headers=self._headers, headers=self._json_headers,
json=payload,
) as response: ) as response:
if response.status == 200: if response.status == 200:
data = await response.json() data = await response.json()
return data[:limit] if isinstance(data, list) else [] items = data.get("assets", {}).get("items", [])
return items[:limit]
except aiohttp.ClientError: except aiohttp.ClientError:
pass pass
return [] return []
@@ -329,7 +403,15 @@ class ImmichClient:
async def add_assets_to_album( async def add_assets_to_album(
self, album_id: str, asset_ids: list[str] self, album_id: str, asset_ids: list[str]
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Add assets to an album. Returns API response with success/error arrays.""" """Add assets to an album. Returns API response with success/error arrays.
Immich returns 200 with a per-asset array even when some IDs fail
individually (already in album, not found, etc). Partial failures
are data, not errors surface them as the normal return value.
Non-2xx responses include Immich's error body in the raised message
so callers and logs see the real reason (bad UUIDs, stale album,
permission, etc.) instead of just the HTTP status code.
"""
payload = {"ids": asset_ids} payload = {"ids": asset_ids}
try: try:
async with self._session.put( async with self._session.put(
@@ -337,14 +419,63 @@ class ImmichClient:
headers=self._json_headers, headers=self._json_headers,
json=payload, json=payload,
) as response: ) as response:
if response.status == 200: body_text = await response.text()
return await response.json() if response.status in (200, 201):
try:
parsed = await response.json(content_type=None)
except Exception: # noqa: BLE001 — malformed body, still 200
return {"raw": body_text}
# Per-asset array is the typical shape; wrap for consistency.
if isinstance(parsed, list):
return {"results": parsed}
if isinstance(parsed, dict):
return parsed
return {"raw": body_text}
# Log full body server-side (for operators), surface only a
# redacted snippet to the caller — this string ends up in
# ActionExecution.error / EventLog.details which are returned
# through the dashboard API.
_LOGGER.warning(
"add_assets_to_album failed: HTTP %s body=%s",
response.status, body_text[:512],
)
raise ImmichApiError( raise ImmichApiError(
f"Failed to add assets to album {album_id}: HTTP {response.status}" f"Failed to add assets to album {album_id}: "
f"HTTP {response.status} {_redact_body(body_text)}"
) )
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise ImmichApiError(f"Error adding assets to album: {err}") from err raise ImmichApiError(f"Error adding assets to album: {err}") from err
async def set_album_thumbnail(
self, album_id: str, asset_id: str
) -> None:
"""Set an album's cover/thumbnail to the given asset.
Uses ``PATCH /api/albums/{id}`` with ``albumThumbnailAssetId``.
Raises ``ImmichApiError`` on non-2xx so callers can treat it as
best-effort and log.
"""
payload = {"albumThumbnailAssetId": asset_id}
try:
async with self._session.patch(
f"{self._url}/api/albums/{album_id}",
headers=self._json_headers,
json=payload,
) as response:
if response.status in (200, 201, 204):
return
body_text = await response.text()
_LOGGER.warning(
"set_album_thumbnail failed: HTTP %s body=%s",
response.status, body_text[:512],
)
raise ImmichApiError(
f"Failed to set album thumbnail for {album_id}: "
f"HTTP {response.status} {_redact_body(body_text)}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error setting album thumbnail: {err}") from err
async def remove_assets_from_album( async def remove_assets_from_album(
self, album_id: str, asset_ids: list[str] self, album_id: str, asset_ids: list[str]
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -386,22 +517,49 @@ class ImmichClient:
raise ImmichApiError(f"Error creating album: {err}") from err raise ImmichApiError(f"Error creating album: {err}") from err
async def get_person_assets_all(self, person_id: str) -> list[dict[str, Any]]: async def get_person_assets_all(self, person_id: str) -> list[dict[str, Any]]:
"""Fetch ALL assets for a person (no limit).""" """Fetch ALL assets tagged with a person (paginated, no soft cap).
Uses ``POST /api/search/metadata`` with ``personIds``. The legacy
``GET /api/people/{id}/assets`` endpoint was removed from Immich
around v1.106 and returns 404 on current servers switching to
the search endpoint is the only way to get person-filtered assets
from modern Immich.
"""
all_items: list[dict[str, Any]] = []
page = 1
page_size = 100
max_pages = 1000 # hard cap to avoid runaway loops if server misbehaves
while page <= max_pages:
payload: dict[str, Any] = {
"personIds": [person_id],
"page": page,
"size": page_size,
}
try: try:
async with self._session.get( async with self._session.post(
f"{self._url}/api/people/{person_id}/assets", f"{self._url}/api/search/metadata",
headers=self._headers, headers=self._json_headers,
json=payload,
) as response: ) as response:
if response.status == 200: if response.status == 200:
data = await response.json() data = await response.json()
return data if isinstance(data, list) else [] items = data.get("assets", {}).get("items", [])
if not items:
break
all_items.extend(items)
if len(items) < page_size:
break
page += 1
continue
if response.status == 404: if response.status == 404:
return [] # Person doesn't exist — return empty rather than raising
return all_items
raise ImmichApiError( raise ImmichApiError(
f"Failed to fetch person {person_id} assets: HTTP {response.status}" f"Failed to fetch person {person_id} assets: HTTP {response.status}"
) )
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise ImmichApiError(f"Error fetching person assets: {err}") from err raise ImmichApiError(f"Error fetching person assets: {err}") from err
return all_items
async def search_smart_all( async def search_smart_all(
self, query: str, limit: int = 1000 self, query: str, limit: int = 1000
@@ -2,16 +2,67 @@
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from notify_bridge_core.models.events import ServiceEvent from notify_bridge_core.models.events import ServiceEvent
_LOGGER = logging.getLogger(__name__)
# Per-target maximum video size (bytes). None = no limit. # Per-target maximum video size (bytes). None = no limit.
_MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = { _MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = {
"telegram": 50 * 1024 * 1024, # 50 MB — Telegram Bot API hard limit "telegram": 50 * 1024 * 1024, # 50 MB — Telegram Bot API hard limit
} }
# Keys that must NEVER flow into the Jinja2 template context, even if a
# provider stuffs them into ``event.extra`` (webhooks, Immich metadata, etc.).
# Templates that could reach a Telegram/Discord/etc. chat would otherwise
# expose operator credentials if a template author simply did ``{{ api_key }}``.
# Case-insensitive substring match — any ``extra`` key containing one of these
# tokens is dropped before the merge.
_SENSITIVE_EXTRA_TOKENS: tuple[str, ...] = (
"api_key",
"apikey",
"token",
"secret",
"password",
"passwd",
"hashed_",
"authorization",
"cookie",
"session_id",
"bearer",
"private_key",
"access_key",
)
def _is_sensitive_key(key: str) -> bool:
lowered = str(key).lower()
return any(tok in lowered for tok in _SENSITIVE_EXTRA_TOKENS)
def _safe_merge_extras(ctx: dict[str, Any], extras: dict[str, Any]) -> None:
"""Merge provider ``extras`` into ``ctx``, dropping sensitive keys.
Dropped keys are logged once per event (DEBUG) so operators can spot
leaking providers without flooding the log.
"""
if not extras:
return
dropped: list[str] = []
for key, value in extras.items():
if _is_sensitive_key(key):
dropped.append(key)
continue
ctx[key] = value
if dropped:
_LOGGER.debug(
"Dropped %d sensitive key(s) from template context: %s",
len(dropped), ", ".join(sorted(dropped)),
)
def build_template_context( def build_template_context(
event: ServiceEvent, event: ServiceEvent,
@@ -61,9 +112,12 @@ def build_template_context(
"preview_url": asset.preview_url or "", "preview_url": asset.preview_url or "",
"full_url": asset.full_url or "", "full_url": asset.full_url or "",
} }
# Flatten extras into asset dict for template access # Flatten extras into asset dict for template access — same
asset_dict.update(asset.extra) # sensitive-key filtering applied as the top-level merge.
_safe_merge_extras(asset_dict, asset.extra)
asset_dict.setdefault("oversized", False) asset_dict.setdefault("oversized", False)
asset_dict.setdefault("file_size", None)
asset_dict.setdefault("playback_size", None)
assets.append(asset_dict) assets.append(asset_dict)
# Enrich assets with per-asset public URLs if album has a public share link # Enrich assets with per-asset public URLs if album has a public share link
@@ -87,12 +141,16 @@ def build_template_context(
ctx["max_video_size"] = max_video_bytes # bytes or None ctx["max_video_size"] = max_video_bytes # bytes or None
ctx["max_video_size_mb"] = max_video_bytes // (1024 * 1024) if max_video_bytes else None ctx["max_video_size_mb"] = max_video_bytes // (1024 * 1024) if max_video_bytes else None
# Oversize check uses playback_size (bytes we actually upload). file_size
# (original asset size) is informational only — for providers that transcode
# before sending (e.g. Immich /video/playback), original can be much larger
# than what reaches Telegram, so it would false-positive the warning.
has_oversized = False has_oversized = False
if max_video_bytes: if max_video_bytes:
for a in assets: for a in assets:
if a.get("type") == "VIDEO": if a.get("type") == "VIDEO":
fs = a.get("file_size") size = a.get("playback_size")
oversized = fs is not None and fs > max_video_bytes oversized = size is not None and size > max_video_bytes
a["oversized"] = oversized a["oversized"] = oversized
if oversized: if oversized:
has_oversized = True has_oversized = True
@@ -132,8 +190,11 @@ def build_template_context(
if len(locations) == 1 and "" not in locations: if len(locations) == 1 and "" not in locations:
ctx["common_location"] = locations.pop() ctx["common_location"] = locations.pop()
# Provider-specific extras merged at top level # Provider-specific extras merged at top level. Sensitive keys (tokens,
ctx.update(event.extra) # secrets, auth headers) are dropped — see ``_SENSITIVE_EXTRA_TOKENS``.
# Without this, a template author could exfiltrate provider credentials
# via ``{{ api_key }}`` in an outgoing notification body.
_safe_merge_extras(ctx, event.extra)
# Ensure URL variables always exist (avoid Jinja2 undefined errors) # Ensure URL variables always exist (avoid Jinja2 undefined errors)
ctx.setdefault("public_url", "") ctx.setdefault("public_url", "")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-server" name = "notify-bridge-server"
version = "0.1.0" version = "0.2.1"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database" description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -22,6 +22,7 @@ _SETTING_KEYS = {
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET", "telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
"telegram_cache_ttl_hours": None, # no env fallback, default 48 "telegram_cache_ttl_hours": None, # no env fallback, default 48
"supported_locales": None, # comma-separated locale codes "supported_locales": None, # comma-separated locale codes
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
} }
_DEFAULTS = { _DEFAULTS = {
@@ -29,6 +30,7 @@ _DEFAULTS = {
"telegram_webhook_secret": "", "telegram_webhook_secret": "",
"telegram_cache_ttl_hours": "48", "telegram_cache_ttl_hours": "48",
"supported_locales": "en,ru", "supported_locales": "en,ru",
"timezone": "UTC",
} }
@@ -50,6 +52,7 @@ class SettingsUpdate(BaseModel):
telegram_webhook_secret: str | None = None telegram_webhook_secret: str | None = None
telegram_cache_ttl_hours: str | None = None telegram_cache_ttl_hours: str | None = None
supported_locales: str | None = None supported_locales: str | None = None
timezone: str | None = None
@router.get("") @router.get("")
@@ -1,10 +1,15 @@
"""Configuration backup/restore API (admin only).""" """Configuration backup/restore API (admin only)."""
import asyncio
import hashlib
import json import json
import logging import logging
import os
import signal
from datetime import datetime, timezone from datetime import datetime, timezone
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile, File, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -16,10 +21,29 @@ from ..services.backup_schema import (
ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode, ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode,
) )
from ..services.backup_service import ( from ..services.backup_service import (
cleanup_old_backups, export_backup, import_backup, list_backup_files, cleanup_old_backups, export_backup, export_backup_to_file, import_backup,
validate_backup, list_backup_files, validate_backup,
) )
# Pending-restore marker keys (single source of truth consumed at startup)
PENDING_RESTORE_PATH_KEY = "pending_restore_path"
PENDING_RESTORE_CONFLICT_KEY = "pending_restore_conflict_mode"
PENDING_RESTORE_UPLOADED_AT_KEY = "pending_restore_uploaded_at"
PENDING_RESTORE_UPLOADED_BY_KEY = "pending_restore_uploaded_by"
# SHA256 of the staged pending_restore.json, written atomically with the file.
# The startup hook refuses to apply if the on-disk file's hash does not match —
# defends against anyone dropping a tampered file into data/ between prepare
# and restart.
PENDING_RESTORE_SHA256_KEY = "pending_restore_sha256"
def _pending_restore_path():
return app_config.data_dir / "pending_restore.json"
def _applied_restores_dir():
return app_config.data_dir / "applied_restores"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/backup", tags=["backup"]) router = APIRouter(prefix="/api/backup", tags=["backup"])
@@ -27,6 +51,69 @@ router = APIRouter(prefix="/api/backup", tags=["backup"])
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
async def _read_upload_bounded(file: UploadFile, max_bytes: int = MAX_UPLOAD_SIZE) -> bytes:
"""Read an UploadFile into memory, failing fast if it exceeds ``max_bytes``.
Rejects on ``content_length`` header up-front when available; always
stream-reads with a running byte counter so we never allocate more than
the limit even when the header is missing or lies.
"""
# Fast path: reject on header before we allocate anything.
cl = file.headers.get("content-length") if hasattr(file, "headers") else None
if cl:
try:
if int(cl) > max_bytes:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
except ValueError:
pass
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(64 * 1024)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
chunks.append(chunk)
return b"".join(chunks)
def _check_same_origin(request: Request) -> None:
"""Reject cross-origin admin-write POSTs (CSRF defense).
Bearer tokens in ``localStorage`` plus cookie-less CORS mean a malicious
page cannot technically submit our Authorization header from a victim's
session, BUT browser extensions and misconfigured CORS policies routinely
break this assumption. For endpoints whose blast radius is restart/RCE-
equivalent (restore apply), we additionally require the request to come
from our own origin.
"""
host = request.headers.get("host", "").lower()
if not host:
raise HTTPException(status_code=400, detail="Missing Host header")
def _host_of(u: str | None) -> str:
if not u:
return ""
try:
return (urlparse(u).netloc or "").lower()
except Exception: # noqa: BLE001
return ""
origin_host = _host_of(request.headers.get("origin"))
referer_host = _host_of(request.headers.get("referer"))
# At least one of Origin/Referer must be present and match Host.
# Legitimate browser requests to this endpoint always ship Origin.
same = (origin_host and origin_host == host) or (referer_host and referer_host == host)
if not same:
raise HTTPException(
status_code=403,
detail="Cross-origin request rejected",
)
def _backup_dir(): def _backup_dir():
return app_config.data_dir / "backups" return app_config.data_dir / "backups"
@@ -87,9 +174,7 @@ async def validate_config(
user: User = Depends(require_admin), user: User = Depends(require_admin),
): ):
"""Validate a backup file without importing.""" """Validate a backup file without importing."""
content = await file.read() content = await _read_upload_bounded(file)
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
try: try:
raw = json.loads(content) raw = json.loads(content)
@@ -112,9 +197,7 @@ async def import_config(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""Import configuration from a backup file.""" """Import configuration from a backup file."""
content = await file.read() content = await _read_upload_bounded(file)
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
try: try:
raw = json.loads(content) raw = json.loads(content)
@@ -131,6 +214,202 @@ async def import_config(
return result.model_dump() return result.model_dump()
# ---------------------------------------------------------------------------
# Pending restore (prepare → apply on next restart)
# ---------------------------------------------------------------------------
async def _set_app_setting(session: AsyncSession, key: str, value: str) -> None:
row = await session.get(AppSetting, key)
if row:
row.value = value
else:
row = AppSetting(key=key, value=value)
session.add(row)
async def _clear_pending_restore_markers(session: AsyncSession) -> None:
for key in (
PENDING_RESTORE_PATH_KEY,
PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY,
PENDING_RESTORE_SHA256_KEY,
):
row = await session.get(AppSetting, key)
if row:
await session.delete(row)
@router.post("/prepare-restore")
async def prepare_restore(
file: UploadFile = File(...),
conflict_mode: ConflictMode = Query(default=ConflictMode.SKIP),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Stage a backup for restore on next backend restart.
Validates the uploaded file, writes it to ``data/pending_restore.json``,
and persists marker settings so startup will apply it atomically.
"""
content = await _read_upload_bounded(file)
try:
raw = json.loads(content)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
validation = validate_backup(raw)
if not validation.valid:
raise HTTPException(
status_code=400,
detail=f"Invalid backup: {'; '.join(validation.errors)}",
)
pending_path = _pending_restore_path()
pending_path.parent.mkdir(parents=True, exist_ok=True)
# Atomic write: write to tmp then rename, so a crash mid-write never
# leaves a truncated pending_restore.json that would break startup apply.
payload = json.dumps(raw).encode("utf-8")
digest = hashlib.sha256(payload).hexdigest()
tmp_path = pending_path.with_suffix(pending_path.suffix + ".tmp")
tmp_path.write_bytes(payload)
os.replace(tmp_path, pending_path)
# Best-effort tighten perms so a non-root local user cannot swap the file
# for one they control between prepare and restart. On Windows this is a
# no-op; on POSIX we restrict to owner-only rw.
try:
os.chmod(pending_path, 0o600)
except OSError:
pass
now_iso = datetime.now(timezone.utc).isoformat()
await _set_app_setting(session, PENDING_RESTORE_PATH_KEY, str(pending_path))
await _set_app_setting(session, PENDING_RESTORE_CONFLICT_KEY, conflict_mode.value)
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_AT_KEY, now_iso)
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_BY_KEY, user.username)
await _set_app_setting(session, PENDING_RESTORE_SHA256_KEY, digest)
await session.commit()
return {
"pending": True,
"uploaded_at": now_iso,
"uploaded_by": user.username,
"conflict_mode": conflict_mode.value,
"validation": validation.model_dump(),
"supervised": _is_supervised(),
}
@router.get("/pending-restore")
async def get_pending_restore(
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Return current pending-restore state, or null if none."""
path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
if not path_row or not path_row.value:
return {"pending": False, "supervised": _is_supervised()}
conflict_row = await session.get(AppSetting, PENDING_RESTORE_CONFLICT_KEY)
uploaded_at_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_AT_KEY)
uploaded_by_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_BY_KEY)
return {
"pending": True,
"uploaded_at": uploaded_at_row.value if uploaded_at_row else None,
"uploaded_by": uploaded_by_row.value if uploaded_by_row else None,
"conflict_mode": (conflict_row.value if conflict_row else ConflictMode.SKIP.value),
"supervised": _is_supervised(),
}
@router.delete("/pending-restore")
async def cancel_pending_restore(
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Cancel a pending restore."""
pending_path = _pending_restore_path()
if pending_path.exists():
pending_path.unlink()
await _clear_pending_restore_markers(session)
await session.commit()
return {"cancelled": True}
def _is_supervised() -> bool:
"""Heuristic: is this process managed by something that will respawn it?
Priority order:
1. Explicit operator override: ``NOTIFY_BRIDGE_SUPERVISED`` env var or
the ``supervised`` AppSetting (values: ``true``/``false``/``auto``).
``auto`` (or unset) falls through to the detection heuristic.
2. Heuristic: look at common container/service-manager env vars.
Used by the frontend to decide whether to offer "Restart now" a bad
guess here is a foot-gun (process exits, stays dead), so err on the side
of false when unsure.
"""
override = os.environ.get("NOTIFY_BRIDGE_SUPERVISED", "").strip().lower()
if override in ("true", "1", "yes", "on"):
return True
if override in ("false", "0", "no", "off"):
return False
for var in ("CONTAINER", "DOCKER_CONTAINER", "KUBERNETES_SERVICE_HOST",
"INVOCATION_ID", "PM2_HOME"):
if os.environ.get(var):
return True
if os.path.exists("/.dockerenv"):
return True
return False
@router.post("/apply-restart")
async def apply_and_restart(
request: Request,
background_tasks: BackgroundTasks,
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Trigger a graceful exit so the supervisor respawns and applies the pending restore.
Only allowed when a pending restore is staged AND the process is supervised.
Requires same-origin Origin/Referer this endpoint's blast radius is a
full config replace + restart, so an admin token alone (vulnerable to
XSS-driven CSRF) is not enough.
"""
_check_same_origin(request)
path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
if not path_row or not path_row.value:
raise HTTPException(status_code=409, detail="No pending restore to apply")
if not _is_supervised():
raise HTTPException(
status_code=409,
detail=(
"This process is not supervised. Restart the backend manually to apply "
"the pending restore, or use the Cancel button."
),
)
async def _shutdown_soon() -> None:
# Small delay so the HTTP response flushes before the signal fires.
await asyncio.sleep(0.5)
_LOGGER.warning("Admin triggered restart to apply pending restore")
# SIGTERM lets uvicorn run its normal graceful shutdown:
# drain in-flight requests, fire the lifespan shutdown hooks
# (close_http_session, scheduler.shutdown), then exit. The
# supervisor respawns, and startup applies the pending restore.
try:
os.kill(os.getpid(), signal.SIGTERM)
except Exception: # noqa: BLE001 — last-resort fallback on platforms that reject SIGTERM
_LOGGER.exception("SIGTERM delivery failed; falling back to os._exit")
os._exit(0)
background_tasks.add_task(_shutdown_soon)
return {"restart_requested": True}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Scheduled backup settings # Scheduled backup settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -205,6 +484,37 @@ async def get_backup_files(
return list_backup_files(_backup_dir()) return list_backup_files(_backup_dir())
@router.post("/files")
async def create_manual_backup(
secrets_mode: SecretsMode = Query(default=SecretsMode.EXCLUDE),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Create a backup file in the backups directory (manual checkpoint).
Produces the same JSON format as scheduled backups, saved under
``data/backups/backup-<timestamp>.json``. Retention is managed by the
existing scheduled-backup settings (``backup_retention_count``).
"""
backup_dir = _backup_dir()
filepath = await export_backup_to_file(session, user.id, backup_dir, secrets_mode)
# Apply the same retention as scheduled backups if configured.
retention_row = await session.get(AppSetting, "backup_retention_count")
if retention_row and retention_row.value:
try:
retention = int(retention_row.value)
if retention > 0:
cleanup_old_backups(backup_dir, keep=retention)
except ValueError:
pass
stat = filepath.stat()
return {
"filename": filepath.name,
"size": stat.st_size,
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
}
@router.get("/files/{filename}") @router.get("/files/{filename}")
async def download_backup_file( async def download_backup_file(
filename: str, filename: str,
@@ -42,6 +42,11 @@ class CommandTrackerUpdate(BaseModel):
class ListenerCreate(BaseModel): class ListenerCreate(BaseModel):
listener_type: str listener_type: str
listener_id: int listener_id: int
allowed_album_ids: list[str] | None = None
class ListenerUpdate(BaseModel):
allowed_album_ids: list[str] | None = None
# --- Command Tracker CRUD --- # --- Command Tracker CRUD ---
@@ -299,6 +304,7 @@ async def add_listener(
command_tracker_id=tracker_id, command_tracker_id=tracker_id,
listener_type=body.listener_type, listener_type=body.listener_type,
listener_id=body.listener_id, listener_id=body.listener_id,
allowed_album_ids=body.allowed_album_ids,
) )
session.add(listener) session.add(listener)
await session.commit() await session.commit()
@@ -316,6 +322,30 @@ async def add_listener(
return await _listener_response(session, listener) return await _listener_response(session, listener)
@router.patch("/{tracker_id}/listeners/{listener_id}")
async def update_listener(
tracker_id: int,
listener_id: int,
body: ListenerUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a listener's per-chat settings (currently just allowed_album_ids)."""
await _get_user_tracker(session, tracker_id, user.id)
listener = await session.get(CommandTrackerListener, listener_id)
if not listener or listener.command_tracker_id != tracker_id:
raise HTTPException(status_code=404, detail="Listener not found")
# Empty list means "no albums" which is rarely useful; treat as null (inherit).
if body.allowed_album_ids is not None and len(body.allowed_album_ids) == 0:
listener.allowed_album_ids = None
else:
listener.allowed_album_ids = body.allowed_album_ids
session.add(listener)
await session.commit()
await session.refresh(listener)
return await _listener_response(session, listener)
@router.delete("/{tracker_id}/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tracker_id}/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_listener( async def remove_listener(
tracker_id: int, tracker_id: int,
@@ -394,6 +424,7 @@ async def _listener_response(session: AsyncSession, l: CommandTrackerListener) -
"command_tracker_id": l.command_tracker_id, "command_tracker_id": l.command_tracker_id,
"listener_type": l.listener_type, "listener_type": l.listener_type,
"listener_id": l.listener_id, "listener_id": l.listener_id,
"allowed_album_ids": l.allowed_album_ids,
"name": name, "name": name,
"created_at": l.created_at.isoformat(), "created_at": l.created_at.isoformat(),
} }
@@ -19,10 +19,22 @@ from ..database.models import (
def raise_if_used(consumers: list[str], entity_name: str) -> None: def raise_if_used(consumers: list[str], entity_name: str) -> None:
"""Raise 409 Conflict if the entity has consumers.""" """Raise 409 Conflict if the entity has consumers.
Produces a human-readable summary string (kept as the primary ``detail``)
plus a structured ``blocked_by`` list so the frontend can render a
clickable warning modal.
"""
if consumers: if consumers:
detail = f"Cannot delete {entity_name}: used by {len(consumers)} consumer(s). " + "; ".join(consumers) summary = f"Cannot delete {entity_name}: used by {len(consumers)} consumer(s)."
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": summary,
"entity": entity_name,
"blocked_by": consumers,
},
)
async def check_service_provider(session: AsyncSession, provider_id: int) -> list[str]: async def check_service_provider(session: AsyncSession, provider_id: int) -> list[str]:
@@ -3,12 +3,14 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import delete as sa_delete
from sqlmodel import func, select from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user from ..auth.dependencies import get_current_user
from ..database.engine import get_session from ..database.engine import get_session
from ..database.models import ( from ..database.models import (
Action,
CommandConfig, CommandConfig,
CommandTemplateConfig, CommandTemplateConfig,
CommandTracker, CommandTracker,
@@ -54,12 +56,10 @@ async def get_status(
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id) select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
)).one() )).one()
# Build events query with filters # Build events query with filters. EventLog.user_id is the owner column;
events_query = ( # action events (event_type starts with "action_") have tracker_id NULL but
select(EventLog) # user_id set, so we filter by user_id directly.
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id) events_query = select(EventLog).where(EventLog.user_id == user.id)
.where(NotificationTracker.user_id == user.id)
)
if event_type: if event_type:
events_query = events_query.where(EventLog.event_type == event_type) events_query = events_query.where(EventLog.event_type == event_type)
@@ -69,6 +69,7 @@ async def get_status(
events_query = events_query.where( events_query = events_query.where(
EventLog.collection_name.contains(search) EventLog.collection_name.contains(search)
| EventLog.tracker_name.contains(search) | EventLog.tracker_name.contains(search)
| EventLog.action_name.contains(search)
| EventLog.provider_name.contains(search) | EventLog.provider_name.contains(search)
) )
@@ -84,6 +85,65 @@ async def get_status(
events_query = events_query.offset(offset).limit(limit) events_query = events_query.offset(offset).limit(limit)
recent_events = await session.exec(events_query) recent_events = await session.exec(events_query)
event_rows = recent_events.all()
# Resolve live tracker names from FK (fall back to stored snapshot when deleted)
tracker_ids = {e.tracker_id for e in event_rows if e.tracker_id is not None}
tracker_name_map: dict[int, str] = {}
if tracker_ids:
tracker_rows = (await session.exec(
select(NotificationTracker.id, NotificationTracker.name).where(
NotificationTracker.id.in_(tracker_ids)
)
)).all()
tracker_name_map = {tid: tname for tid, tname in tracker_rows}
# Resolve live provider names similarly
provider_ids = {e.provider_id for e in event_rows if e.provider_id is not None}
provider_name_map: dict[int, str] = {}
if provider_ids:
provider_rows = (await session.exec(
select(ServiceProvider.id, ServiceProvider.name).where(
ServiceProvider.id.in_(provider_ids)
)
)).all()
provider_name_map = {pid: pname for pid, pname in provider_rows}
# Resolve live action names so renames are reflected; fall back to snapshot.
action_ids = {e.action_id for e in event_rows if e.action_id is not None}
action_name_map: dict[int, str] = {}
if action_ids:
action_rows = (await session.exec(
select(Action.id, Action.name).where(Action.id.in_(action_ids))
)).all()
action_name_map = {aid: aname for aid, aname in action_rows}
def _display_tracker_name(e: EventLog) -> str:
if e.tracker_id is not None and e.tracker_id in tracker_name_map:
return tracker_name_map[e.tracker_id]
return f"(deleted) {e.tracker_name}" if e.tracker_name else "(deleted)"
def _display_provider_name(e: EventLog) -> str:
if e.provider_id is not None and e.provider_id in provider_name_map:
return provider_name_map[e.provider_id]
return e.provider_name or ""
def _display_action_name(e: EventLog) -> str:
if e.action_id is not None and e.action_id in action_name_map:
return action_name_map[e.action_id]
if e.action_name:
return f"(deleted) {e.action_name}"
return ""
def _display_subject(e: EventLog) -> str:
"""The primary label shown on the event row.
For action events the ``collection_name`` stores the action name;
use the live-resolved action name when available so renames show.
"""
if e.action_id is not None or (e.event_type or "").startswith("action_"):
return _display_action_name(e) or e.collection_name
return e.collection_name
return { return {
"providers": providers_count, "providers": providers_count,
@@ -94,19 +154,43 @@ async def get_status(
{ {
"id": e.id, "id": e.id,
"event_type": e.event_type, "event_type": e.event_type,
"collection_name": e.collection_name, "collection_name": _display_subject(e),
"tracker_name": e.tracker_name or "", "tracker_name": _display_tracker_name(e),
"provider_name": e.provider_name or "", "action_id": e.action_id,
"action_name": _display_action_name(e),
"provider_name": _display_provider_name(e),
"provider_id": e.provider_id, "provider_id": e.provider_id,
"assets_count": e.assets_count or 0, "assets_count": e.assets_count or 0,
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""), "created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
"details": e.details or {}, "details": e.details or {},
} }
for e in recent_events.all() for e in event_rows
], ],
} }
@router.delete("/events")
async def clear_events(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
older_than_days: int | None = Query(None, ge=0),
):
"""Delete all event log entries for the current user.
Optionally keep events newer than `older_than_days` days.
"""
stmt = sa_delete(EventLog).where(EventLog.user_id == user.id)
if older_than_days is not None:
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
stmt = stmt.where(EventLog.created_at < cutoff)
# Use session.execute() for DELETE (consistent with other endpoints and
# avoids sqlmodel wrapping a CursorResult that may drop rowcount).
result = await session.execute(stmt)
await session.commit()
return {"deleted": result.rowcount or 0}
@router.get("/counts") @router.get("/counts")
async def get_nav_counts( async def get_nav_counts(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
@@ -192,8 +276,7 @@ async def get_event_chart(
EventLog.event_type, EventLog.event_type,
func.count().label("total"), func.count().label("total"),
) )
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id) .where(EventLog.user_id == user.id, EventLog.created_at >= cutoff)
.where(NotificationTracker.user_id == user.id, EventLog.created_at >= cutoff)
) )
if event_type: if event_type:
@@ -204,6 +287,7 @@ async def get_event_chart(
query = query.where( query = query.where(
EventLog.collection_name.contains(search) EventLog.collection_name.contains(search)
| EventLog.tracker_name.contains(search) | EventLog.tracker_name.contains(search)
| EventLog.action_name.contains(search)
| EventLog.provider_name.contains(search) | EventLog.provider_name.contains(search)
) )
@@ -162,8 +162,9 @@ async def get_template_variables(
"city": "City name", "city": "City name",
"state": "State/region name", "state": "State/region name",
"country": "Country name", "country": "Country name",
"file_size": "File size in bytes (null if unknown)", "file_size": "Original asset size in bytes (null if unknown)",
"oversized": "Whether video exceeds the target's size limit (boolean, videos only)", "playback_size": "Size in bytes of the media we actually upload — for Immich videos this is the transcoded /video/playback (null for photos or when unknown)",
"oversized": "Whether the asset's playback_size exceeds the target's size limit (boolean, videos only)",
"public_url": "Per-asset public share URL (empty if no album link)", "public_url": "Per-asset public share URL (empty if no album link)",
"url": "Public viewer URL (if shared)", "url": "Public viewer URL (if shared)",
"download_url": "Direct download URL (if shared)", "download_url": "Direct download URL (if shared)",
@@ -54,6 +54,9 @@ class TrackingConfigCreate(BaseModel):
memory_favorite_only: bool = False memory_favorite_only: bool = False
memory_asset_type: str = "all" memory_asset_type: str = "all"
memory_min_rating: int = 0 memory_min_rating: int = 0
quiet_hours_enabled: bool = False
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
class TrackingConfigUpdate(BaseModel): class TrackingConfigUpdate(BaseModel):
@@ -93,6 +96,9 @@ class TrackingConfigUpdate(BaseModel):
memory_favorite_only: bool | None = None memory_favorite_only: bool | None = None
memory_asset_type: str | None = None memory_asset_type: str | None = None
memory_min_rating: int | None = None memory_min_rating: int | None = None
quiet_hours_enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
@router.get("") @router.get("")
@@ -4,6 +4,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -69,6 +70,81 @@ async def create_user(
return {"id": user.id, "username": user.username, "role": user.role} return {"id": user.id, "username": user.username, "role": user.role}
@router.patch("/{user_id}")
async def update_user(
user_id: int,
body: UserUpdate,
admin: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Update username and/or role for a user (admin only)."""
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Track whether the identity that JWTs encode has changed. Any such change
# must bump ``token_version`` so already-issued tokens are rejected — a
# user demoted admin→user must not keep admin in their cached JWT until
# expiry, and a rename should invalidate prior sessions too.
identity_changed = False
if body.username is not None and body.username != user.username:
new_username = body.username.strip()
if not new_username:
raise HTTPException(status_code=400, detail="Username cannot be empty")
dup = await session.exec(select(User).where(User.username == new_username))
if dup.first():
raise HTTPException(status_code=409, detail="Username already exists")
user.username = new_username
identity_changed = True
if body.role is not None and body.role != user.role:
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Invalid role")
# Prevent demoting the last admin. Done via a COUNT to avoid loading
# every admin row; more importantly, re-checked *after* the role
# change is staged (TOCTOU guard — two concurrent demotes can each
# see admin_count=2 and both proceed, dropping to 0).
if user.role == "admin" and body.role != "admin":
admin_count = (await session.exec(
select(func.count(User.id)).where(User.role == "admin")
)).one()
if isinstance(admin_count, tuple):
admin_count = admin_count[0]
if (admin_count or 0) <= 1:
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
user.role = body.role
identity_changed = True
if identity_changed:
user.token_version = (user.token_version or 1) + 1
session.add(user)
try:
await session.commit()
except Exception:
await session.rollback()
raise
# Final defense against admin-count race: if we just demoted the last admin
# due to a concurrent demote landing between our check and commit, undo.
if body.role is not None and body.role != "admin":
admin_count_after = (await session.exec(
select(func.count(User.id)).where(User.role == "admin")
)).one()
if isinstance(admin_count_after, tuple):
admin_count_after = admin_count_after[0]
if (admin_count_after or 0) < 1:
# Roll the user back to admin and re-commit.
user.role = "admin"
session.add(user)
await session.commit()
raise HTTPException(status_code=409, detail="Refused: would remove the last admin")
await session.refresh(user)
return {"id": user.id, "username": user.username, "role": user.role}
class ResetPasswordRequest(BaseModel): class ResetPasswordRequest(BaseModel):
new_password: str new_password: str
@@ -87,6 +163,9 @@ async def reset_user_password(
if len(body.new_password) < 8: if len(body.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters") raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
user.hashed_password = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode() user.hashed_password = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode()
# Invalidate all prior JWTs issued for this user — matches the self-serve
# password-change path in auth/routes.py.
user.token_version = (user.token_version or 1) + 1
session.add(user) session.add(user)
await session.commit() await session.commit()
return {"success": True} return {"success": True}
@@ -27,7 +27,11 @@ from ..database.models import (
ServiceProvider, ServiceProvider,
WebhookPayloadLog, WebhookPayloadLog,
) )
from ..services.dispatch_helpers import event_allowed_by_config, load_link_data from ..services.dispatch_helpers import (
event_allowed_by_config,
get_app_timezone,
load_link_data,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -144,9 +148,12 @@ async def _dispatch_webhook_event(
if not link_data: if not link_data:
continue continue
app_tz = await get_app_timezone(session)
# Log event # Log event
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys} extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
session.add(EventLog( session.add(EventLog(
user_id=tracker.user_id,
tracker_id=tracker.id, tracker_id=tracker.id,
tracker_name=tracker.name, tracker_name=tracker.name,
provider_id=provider_id, provider_id=provider_id,
@@ -163,7 +170,7 @@ async def _dispatch_webhook_event(
# Dispatch to targets # Dispatch to targets
dispatcher = NotificationDispatcher() dispatcher = NotificationDispatcher()
target_configs = _build_target_configs(event, link_data, provider_config) target_configs = _build_target_configs(event, link_data, provider_config, app_tz)
if target_configs: if target_configs:
results = await dispatcher.dispatch(event, target_configs) results = await dispatcher.dispatch(event, target_configs)
for r in results: for r in results:
@@ -512,12 +519,13 @@ def _build_target_configs(
event: ServiceEvent, event: ServiceEvent,
link_data: list[dict[str, Any]], link_data: list[dict[str, Any]],
provider_config: dict[str, Any], provider_config: dict[str, Any],
app_tz: str = "UTC",
) -> list[TargetConfig]: ) -> list[TargetConfig]:
"""Build TargetConfig objects for dispatch, applying tracking config filters.""" """Build TargetConfig objects for dispatch, applying tracking config filters."""
target_configs: list[TargetConfig] = [] target_configs: list[TargetConfig] = []
for ld in link_data: for ld in link_data:
tc = ld["tracking_config"] tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc): if tc and not event_allowed_by_config(event, tc, app_tz):
continue continue
tmpl = ld["template_config"] tmpl = ld["template_config"]
@@ -6,7 +6,10 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from ..database.models import CommandConfig, CommandTracker, ServiceProvider, TelegramBot from ..database.models import (
CommandConfig, CommandTracker, CommandTrackerListener,
ServiceProvider, TelegramBot,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -51,6 +54,9 @@ class ProviderCommandHandler(ABC):
bot: TelegramBot, bot: TelegramBot,
tracker: CommandTracker, tracker: CommandTracker,
config: CommandConfig, config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
"""Handle a provider-specific command for a single tracker. """Handle a provider-specific command for a single tracker.
@@ -77,6 +77,9 @@ class GiteaCommandHandler(ProviderCommandHandler):
bot: TelegramBot, bot: TelegramBot,
tracker: CommandTracker, tracker: CommandTracker,
config: CommandConfig, config: CommandConfig,
*,
listener: Any = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd) fn = _TEXT_COMMANDS.get(cmd)
if fn is None: if fn is None:
@@ -95,7 +95,7 @@ def _render_cmd_template(
async def _resolve_command_context( async def _resolve_command_context(
bot: TelegramBot, bot: TelegramBot,
) -> tuple[ ) -> tuple[
list[tuple[CommandTracker, CommandConfig, ServiceProvider]], list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]],
dict[int, dict[str, dict[str, str]]], dict[int, dict[str, dict[str, str]]],
]: ]:
"""Resolve all enabled command trackers, configs, and providers for a bot. """Resolve all enabled command trackers, configs, and providers for a bot.
@@ -148,7 +148,7 @@ async def _resolve_command_context(
else: else:
providers_by_id = {} providers_by_id = {}
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]] = [] tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]] = []
for listener in listeners: for listener in listeners:
tracker = trackers_by_id.get(listener.command_tracker_id) tracker = trackers_by_id.get(listener.command_tracker_id)
if not tracker or not tracker.enabled: if not tracker or not tracker.enabled:
@@ -159,12 +159,12 @@ async def _resolve_command_context(
provider = providers_by_id.get(tracker.provider_id) provider = providers_by_id.get(tracker.provider_id)
if not provider: if not provider:
continue continue
tuples.append((tracker, config, provider)) tuples.append((tracker, config, provider, listener))
# Load command template slots per config (not merged) # Load command template slots per config (not merged)
templates_by_config_id: dict[int, dict[str, dict[str, str]]] = {} templates_by_config_id: dict[int, dict[str, dict[str, str]]] = {}
seen_config_ids: set[int] = set() seen_config_ids: set[int] = set()
for _, config, _ in tuples: for _, config, _, _ in tuples:
cfg_id = config.command_template_config_id cfg_id = config.command_template_config_id
if cfg_id and cfg_id not in seen_config_ids: if cfg_id and cfg_id not in seen_config_ids:
seen_config_ids.add(cfg_id) seen_config_ids.add(cfg_id)
@@ -204,7 +204,7 @@ def _merge_all_templates(
def _merge_enabled_commands( def _merge_enabled_commands(
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]], ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]],
) -> tuple[list[str], dict[str, Any]]: ) -> tuple[list[str], dict[str, Any]]:
"""Merge enabled_commands (union) and rate_limits from all configs. """Merge enabled_commands (union) and rate_limits from all configs.
@@ -215,7 +215,7 @@ def _merge_enabled_commands(
enabled: set[str] = set() enabled: set[str] = set()
merged_limits: dict[str, int] = {} merged_limits: dict[str, int] = {}
for _, config, _ in ctx: for _, config, _, _ in ctx:
enabled.update(config.enabled_commands or []) enabled.update(config.enabled_commands or [])
for category, cooldown in (config.rate_limits or {}).items(): for category, cooldown in (config.rate_limits or {}).items():
if category not in merged_limits: if category not in merged_limits:
@@ -278,8 +278,16 @@ async def handle_command(
# Provider-specific dispatch — per-tracker # Provider-specific dispatch — per-tracker
from .dispatch import get_handler from .dispatch import get_handler
# For paginated commands (/search, /find) a trailing integer means page,
# not count. Preserve count_override meaning for all other commands.
paginated_cmds = {"search", "find"}
page = 1
if cmd in paginated_cmds and count_override:
page = max(1, count_override)
count_override = None
responses: list[CommandResponse] = [] responses: list[CommandResponse] = []
for tracker, config, provider in ctx_tuples: for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND: if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
_LOGGER.warning( _LOGGER.warning(
"Truncated command responses at %d for bot %d cmd /%s", "Truncated command responses at %d for bot %d cmd /%s",
@@ -298,6 +306,7 @@ async def handle_command(
result = await handler.handle( result = await handler.handle(
cmd, args, count, locale, response_mode, cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config, provider, tracker_templates, bot, tracker, config,
listener=listener, page=page,
) )
if result is not None: if result is not None:
responses.append(result) responses.append(result)
@@ -6,7 +6,7 @@ import logging
from typing import Any from typing import Any
from ...database.models import ( from ...database.models import (
CommandConfig, CommandTracker, CommandConfig, CommandTracker, CommandTrackerListener,
ServiceProvider, TelegramBot, ServiceProvider, TelegramBot,
) )
from ...services import make_immich_provider from ...services import make_immich_provider
@@ -78,6 +78,9 @@ class ImmichCommandHandler(ProviderCommandHandler):
bot: TelegramBot, bot: TelegramBot,
tracker: CommandTracker, tracker: CommandTracker,
config: CommandConfig, config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
if cmd == "status": if cmd == "status":
ctx = await _cmd_status(provider, locale) ctx = await _cmd_status(provider, locale)
@@ -96,6 +99,7 @@ class ImmichCommandHandler(ProviderCommandHandler):
return await _cmd_immich( return await _cmd_immich(
cmd, args, count, locale, response_mode, cmd, args, count, locale, response_mode,
provider, cmd_templates, provider, cmd_templates,
listener=listener, page=page,
) )
return None return None
@@ -104,13 +108,25 @@ async def _cmd_immich(
cmd: str, args: str, count: int, locale: str, cmd: str, args: str, count: int, locale: str,
response_mode: str, provider: ServiceProvider, response_mode: str, provider: ServiceProvider,
cmd_templates: dict[str, dict[str, str]], cmd_templates: dict[str, dict[str, str]],
*,
listener: CommandTrackerListener | None = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
"""Handle commands that need Immich API access and may return media.""" """Handle commands that need Immich API access and may return media."""
notification_trackers = await get_trackers_for_provider(provider.id) notification_trackers = await get_trackers_for_provider(provider.id)
all_album_ids: list[str] = [] all_album_ids: list[str] = []
seen: set[str] = set()
for t in notification_trackers: for t in notification_trackers:
all_album_ids.extend(t.collection_ids or []) for aid in (t.collection_ids or []):
if aid not in seen:
seen.add(aid)
all_album_ids.append(aid)
# Per-chat album scope: intersect with listener.allowed_album_ids when set.
if listener is not None and listener.allowed_album_ids is not None:
allowed = set(listener.allowed_album_ids)
all_album_ids = [aid for aid in all_album_ids if aid in allowed]
ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/") ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/")
@@ -135,9 +151,9 @@ async def _cmd_immich(
result: str | dict[str, Any] | None = None result: str | dict[str, Any] | None = None
if cmd == "search": if cmd == "search":
result = await cmd_search(client, args, all_album_ids, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls) result = await cmd_search(client, args, all_album_ids, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls, page=page)
elif cmd == "find": elif cmd == "find":
result = await cmd_find(client, args, all_album_ids, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls) result = await cmd_find(client, args, all_album_ids, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls, page=page)
elif cmd == "person": elif cmd == "person":
result = await cmd_person(client, args, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls) result = await cmd_person(client, args, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls)
elif cmd == "place": elif cmd == "place":
@@ -25,11 +25,12 @@ async def cmd_search(
locale: str, response_mode: str, locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]], cmd_templates: dict[str, dict[str, str]],
asset_public_urls: dict[str, str] | None = None, asset_public_urls: dict[str, str] | None = None,
page: int = 1,
) -> str | dict[str, Any]: ) -> str | dict[str, Any]:
"""Handle /search command.""" """Handle /search command."""
if not args: if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""}) return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""})
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count) assets = await client.search_smart(args, album_ids=all_album_ids, limit=count, page=page)
_enrich_assets(assets, asset_public_urls or {}) _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates) return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates)
@@ -39,11 +40,12 @@ async def cmd_find(
locale: str, response_mode: str, locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]], cmd_templates: dict[str, dict[str, str]],
asset_public_urls: dict[str, str] | None = None, asset_public_urls: dict[str, str] | None = None,
page: int = 1,
) -> str | dict[str, Any]: ) -> str | dict[str, Any]:
"""Handle /find command.""" """Handle /find command."""
if not args: if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""}) return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""})
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count) assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count, page=page)
_enrich_assets(assets, asset_public_urls or {}) _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates) return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates)
@@ -52,6 +52,9 @@ class NutCommandHandler(ProviderCommandHandler):
bot: TelegramBot, bot: TelegramBot,
tracker: CommandTracker, tracker: CommandTracker,
config: CommandConfig, config: CommandConfig,
*,
listener: Any = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd) fn = _TEXT_COMMANDS.get(cmd)
if fn is None: if fn is None:
@@ -69,6 +69,9 @@ class PlankaCommandHandler(ProviderCommandHandler):
bot: TelegramBot, bot: TelegramBot,
tracker: CommandTracker, tracker: CommandTracker,
config: CommandConfig, config: CommandConfig,
*,
listener: Any = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd) fn = _TEXT_COMMANDS.get(cmd)
if fn is None: if fn is None:
@@ -84,11 +84,41 @@ async def migrate_schema(engine: AsyncEngine) -> None:
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"), ("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"), ("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"), ("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
("user_id", "ALTER TABLE event_log ADD COLUMN user_id INTEGER"),
("action_id", "ALTER TABLE event_log ADD COLUMN action_id INTEGER"),
("action_name", "ALTER TABLE event_log ADD COLUMN action_name TEXT DEFAULT ''"),
]: ]:
if not await _has_column(conn, "event_log", col): if not await _has_column(conn, "event_log", col):
await conn.execute(text(sql)) await conn.execute(text(sql))
logger.info("Added %s column to event_log table", col) logger.info("Added %s column to event_log table", col)
# Explicit indexes on the dashboard-query columns. SQLModel's
# ``index=True`` is emitted by ``create_all`` on *new* installs,
# but ALTER TABLE ADD COLUMN doesn't create them on upgrades —
# so the first boot after upgrade would leave these unindexed
# and status.py ``WHERE user_id=...`` would table-scan. The
# indexes are redundant-but-safe once create_all also runs.
for idx_name, col in [
("ix_event_log_user_id", "user_id"),
("ix_event_log_action_id", "action_id"),
("ix_event_log_provider_id", "provider_id"),
]:
await conn.execute(
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON event_log ({col})")
)
# Backfill user_id from notification_tracker for legacy rows.
# Safe to run repeatedly: only touches rows where user_id is still NULL.
await conn.execute(text("""
UPDATE event_log
SET user_id = (
SELECT user_id FROM notification_tracker
WHERE notification_tracker.id = event_log.notification_tracker_id
)
WHERE event_log.user_id IS NULL
AND event_log.notification_tracker_id IS NOT NULL
"""))
# Add commands_config to telegram_bot if missing # Add commands_config to telegram_bot if missing
if await _has_table(conn, "telegram_bot"): if await _has_table(conn, "telegram_bot"):
if not await _has_column(conn, "telegram_bot", "commands_config"): if not await _has_column(conn, "telegram_bot", "commands_config"):
@@ -129,6 +159,14 @@ async def migrate_schema(engine: AsyncEngine) -> None:
) )
logger.info("Added command_template_config_id column to command_config table") logger.info("Added command_template_config_id column to command_config table")
# Add allowed_album_ids (per-chat album scope) to command_tracker_listener
if await _has_table(conn, "command_tracker_listener"):
if not await _has_column(conn, "command_tracker_listener", "allowed_album_ids"):
await conn.execute(
text("ALTER TABLE command_tracker_listener ADD COLUMN allowed_album_ids TEXT")
)
logger.info("Added allowed_album_ids column to command_tracker_listener table")
# Add date_only_format to template_config if missing # Add date_only_format to template_config if missing
if await _has_table(conn, "template_config"): if await _has_table(conn, "template_config"):
if not await _has_column(conn, "template_config", "date_only_format"): if not await _has_column(conn, "template_config", "date_only_format"):
@@ -227,6 +265,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
) )
logger.info("Added track_webhook_received column to tracking_config table") logger.info("Added track_webhook_received column to tracking_config table")
# Add quiet hours to tracking_config if missing.
# Start/end are nullable HH:MM strings; quiet_hours_enabled gates them.
if await _has_table(conn, "tracking_config"):
if not await _has_column(conn, "tracking_config", "quiet_hours_enabled"):
await conn.execute(
text("ALTER TABLE tracking_config ADD COLUMN quiet_hours_enabled INTEGER DEFAULT 0")
)
logger.info("Added quiet_hours_enabled column to tracking_config table")
for col_name in ("quiet_hours_start", "quiet_hours_end"):
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} TEXT")
)
logger.info("Added %s column to tracking_config table", col_name)
# Drop legacy template content columns from template_config # Drop legacy template content columns from template_config
# (template content moved to template_slot child rows) # (template content moved to template_slot child rows)
if await _has_table(conn, "template_config"): if await _has_table(conn, "template_config"):
@@ -204,6 +204,13 @@ class TrackingConfig(SQLModel, table=True):
memory_asset_type: str = Field(default="all") memory_asset_type: str = Field(default="all")
memory_min_rating: int = Field(default=0) memory_min_rating: int = Field(default=0)
# Quiet hours — HH:MM strings interpreted in the app-level timezone
# (AppSetting "timezone"). Gated by quiet_hours_enabled so an empty window
# still represents "explicitly disabled" vs "not yet configured".
quiet_hours_enabled: bool = Field(default=False)
quiet_hours_start: str | None = Field(default=None)
quiet_hours_end: str | None = Field(default=None)
created_at: datetime = Field(default_factory=_utcnow) created_at: datetime = Field(default_factory=_utcnow)
@@ -467,6 +474,11 @@ class CommandTrackerListener(SQLModel, table=True):
) )
listener_type: str # e.g. "telegram_bot" listener_type: str # e.g. "telegram_bot"
listener_id: int listener_id: int
# Optional per-chat album scope. None = inherit from tracker (use all).
# When set, only these album/collection ids are queryable from this chat.
allowed_album_ids: list[str] | None = Field(
default=None, sa_column=Column(JSON, nullable=True),
)
created_at: datetime = Field(default_factory=_utcnow) created_at: datetime = Field(default_factory=_utcnow)
@@ -476,6 +488,10 @@ class EventLog(SQLModel, table=True):
__tablename__ = "event_log" __tablename__ = "event_log"
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
# Owner. Indexed for the dashboard events query. Nullable only because
# historical rows (pre-user_id column) may have no owner; new rows always
# set this directly.
user_id: int | None = Field(default=None, foreign_key="user.id", index=True)
# Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id # Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id
tracker_id: int | None = Field( tracker_id: int | None = Field(
default=None, default=None,
@@ -484,6 +500,13 @@ class EventLog(SQLModel, table=True):
sa_column_kwargs={"name": "notification_tracker_id"}, sa_column_kwargs={"name": "notification_tracker_id"},
) )
tracker_name: str = Field(default="") tracker_name: str = Field(default="")
# Links an event back to an Action when the event was emitted by the
# action runner (``event_type`` starts with ``action_``). Null for
# notification-tracker events.
action_id: int | None = Field(
default=None, foreign_key="action.id", index=True,
)
action_name: str = Field(default="")
provider_id: int | None = Field(default=None, index=True) provider_id: int | None = Field(default=None, index=True)
provider_name: str = Field(default="") provider_name: str = Field(default="")
event_type: str = Field(index=True) event_type: str = Field(index=True)
@@ -110,6 +110,10 @@ async def _seed_provider_command_template(
await session.flush() await session.flush()
else: else:
config = configs[0] config = configs[0]
if config.name != name or config.description != description:
config.name = name
config.description = description
session.add(config)
for locale in ("en", "ru"): for locale in ("en", "ru"):
slots = load_default_command_templates(locale, provider_type=provider_type) slots = load_default_command_templates(locale, provider_type=provider_type)
@@ -166,7 +170,7 @@ async def _seed_default_command_templates() -> None:
engine = get_engine() engine = get_engine()
async with AsyncSession(engine) as session: async with AsyncSession(engine) as session:
await _seed_provider_command_template( await _seed_provider_command_template(
session, "immich", "Default Commands", "Default Immich command templates", session, "immich", "Default Immich Commands", "Default Immich command templates",
) )
await _seed_provider_command_template( await _seed_provider_command_template(
session, "gitea", "Default Gitea Commands", "Default Gitea command templates", session, "gitea", "Default Gitea Commands", "Default Gitea command templates",
@@ -242,7 +246,7 @@ async def _seed_default_tracking_configs() -> None:
"provider_type": "immich", "provider_type": "immich",
"name": "Default Immich", "name": "Default Immich",
"track_assets_added": True, "track_assets_added": True,
"track_assets_removed": True, "track_assets_removed": False,
"track_collection_renamed": True, "track_collection_renamed": True,
"track_collection_deleted": True, "track_collection_deleted": True,
"track_sharing_changed": False, "track_sharing_changed": False,
@@ -251,7 +255,7 @@ async def _seed_default_tracking_configs() -> None:
"provider_type": "google_photos", "provider_type": "google_photos",
"name": "Default Google Photos", "name": "Default Google Photos",
"track_assets_added": True, "track_assets_added": True,
"track_assets_removed": True, "track_assets_removed": False,
"track_collection_renamed": True, "track_collection_renamed": True,
"track_collection_deleted": True, "track_collection_deleted": True,
"track_sharing_changed": False, "track_sharing_changed": False,
@@ -66,6 +66,9 @@ async def lifespan(app: FastAPI):
await migrate_user_token_version(engine) await migrate_user_token_version(engine)
from .database.seeds import seed_all from .database.seeds import seed_all
await seed_all() await seed_all()
# Apply any pending restore staged via /api/backup/prepare-restore
from .services.pending_restore import apply_pending_restore_if_any
await apply_pending_restore_if_any()
# Configure webhook secret from DB setting (falls back to env var) # Configure webhook secret from DB setting (falls back to env var)
from sqlmodel.ext.asyncio.session import AsyncSession as _AS from sqlmodel.ext.asyncio.session import AsyncSession as _AS
from .api.app_settings import get_setting as _get_setting from .api.app_settings import get_setting as _get_setting
@@ -16,6 +16,7 @@ from ..database.models import (
Action, Action,
ActionExecution, ActionExecution,
ActionRule, ActionRule,
EventLog,
ServiceProvider, ServiceProvider,
) )
@@ -115,13 +116,42 @@ async def run_action(
execution.error = action_result.error or "" execution.error = action_result.error or ""
session.add(execution) session.add(execution)
# Update action last_run metadata (skip for dry runs) # Update action last_run metadata + emit a dashboard EventLog row
# (skip both for dry runs — dashboards should not count previews).
if not is_dry_run: if not is_dry_run:
action = await session.get(Action, action_id) action = await session.get(Action, action_id)
if action: if action:
action.last_run_at = datetime.now(timezone.utc) action.last_run_at = datetime.now(timezone.utc)
action.last_run_status = execution.status if execution else "" action.last_run_status = execution.status if execution else ""
session.add(action) session.add(action)
provider = await session.get(ServiceProvider, action.provider_id)
status_str = execution.status if execution else "success"
event_type = f"action_{status_str}" # action_success|partial|failed
session.add(EventLog(
user_id=action.user_id,
tracker_id=None,
tracker_name="",
action_id=action.id,
action_name=action.name,
provider_id=provider.id if provider else None,
provider_name=(provider.name if provider else "") or "",
event_type=event_type,
collection_id=str(action.id),
# ``collection_name`` is what the dashboard row shows as the
# event subject; use the action name so the row is readable
# without a separate action_name renderer.
collection_name=action.name,
assets_count=action_result.total_items_affected,
details={
"action_type": action.action_type,
"trigger": trigger,
"rules_processed": action_result.rules_processed,
"rules_succeeded": action_result.rules_succeeded,
"rules_failed": action_result.rules_failed,
"error": action_result.error or "",
"execution_id": execution_id,
},
))
await session.commit() await session.commit()
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, time, timezone from datetime import datetime, time, timezone
from typing import Any from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -29,12 +30,32 @@ from ..database.models import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def in_quiet_hours(start: str | None, end: str | None) -> bool: def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
"""Check if the current UTC time is within the quiet hours window.""" """Resolve an IANA tz string to a ZoneInfo, falling back to UTC on any error."""
if not tz_name:
return ZoneInfo("UTC")
try:
return ZoneInfo(tz_name)
except (ZoneInfoNotFoundError, ValueError):
_LOGGER.warning("Unknown timezone %r; falling back to UTC", tz_name)
return ZoneInfo("UTC")
def in_quiet_hours(
start: str | None,
end: str | None,
tz_name: str | None = "UTC",
) -> bool:
"""Check if the current time (in the given timezone) is within the quiet window.
HH:MM strings are interpreted in the supplied timezone. If either bound is
missing, quiet hours are disabled.
"""
if not start or not end: if not start or not end:
return False return False
try: try:
now = datetime.now(timezone.utc).time() tz = _resolve_zoneinfo(tz_name)
now = datetime.now(timezone.utc).astimezone(tz).time()
t_start = time.fromisoformat(start) t_start = time.fromisoformat(start)
t_end = time.fromisoformat(end) t_end = time.fromisoformat(end)
if t_start <= t_end: if t_start <= t_end:
@@ -46,8 +67,25 @@ def in_quiet_hours(start: str | None, end: str | None) -> bool:
return False return False
def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool: async def get_app_timezone(session: AsyncSession) -> str:
"""Check if an event type is allowed by the tracking config's flags.""" """Load the app-level timezone from AppSetting (falls back to UTC)."""
from ..api.app_settings import get_setting
value = await get_setting(session, "timezone")
return value or "UTC"
def event_allowed_by_config(
event: ServiceEvent,
tc: TrackingConfig,
tz_name: str | None = "UTC",
) -> bool:
"""Check if an event is allowed by the tracking config's flags + quiet hours."""
# Quiet hours gate every event type when enabled.
if tc.quiet_hours_enabled and in_quiet_hours(
tc.quiet_hours_start, tc.quiet_hours_end, tz_name
):
return False
event_type = event.event_type.value event_type = event.event_type.value
flag_map = { flag_map = {
# Immich events # Immich events
@@ -1,5 +1,6 @@
"""Notification sender — unified send logic for all paths (dispatch + test).""" """Notification sender — unified send logic for all paths (dispatch + test)."""
import asyncio
import logging import logging
from typing import Any from typing import Any
@@ -11,6 +12,10 @@ from ..database.models import NotificationTarget, TargetReceiver
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Cap on concurrent per-receiver test sends. Keeps us under Telegram's per-bot
# rate limit (~30 msg/s) while still saving ~N×RTT on multi-chat broadcasts.
_TEST_SEND_CONCURRENCY = 5
_TEST_MESSAGES: dict[str, dict[str, str]] = { _TEST_MESSAGES: dict[str, dict[str, str]] = {
"en": { "en": {
"telegram": "\u2705 Test message from <b>Notify Bridge</b>", "telegram": "\u2705 Test message from <b>Notify Bridge</b>",
@@ -298,13 +303,92 @@ async def send_to_receiver(target: NotificationTarget, receiver_config: dict, me
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict: async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
"""Send a simple test message. For broadcast targets, fans out to all children.""" """Send a simple test message. For broadcast targets, fans out to all children.
For Telegram targets, per-receiver locale (TargetReceiver.locale or
TelegramChat.language_override/language_code) is resolved individually so
each chat receives the message in its own configured language.
"""
if target.type == "broadcast": if target.type == "broadcast":
return await _send_broadcast_test(target, locale) return await _send_broadcast_test(target, locale)
if target.type == "telegram":
return await _send_telegram_test_per_receiver(target, default_locale=locale)
message = _get_test_message(locale, target.type) message = _get_test_message(locale, target.type)
return await send_to_target(target, message) return await send_to_target(target, message)
async def _send_telegram_test_per_receiver(
target: NotificationTarget, default_locale: str = "en",
) -> dict:
"""Send a test message to each Telegram receiver in its own resolved locale."""
from notify_bridge_core.notifications.telegram.client import TelegramClient
from ..database.models import TargetReceiver, TelegramChat
from .http_session import get_http_session
bot_token = target.config.get("bot_token")
bot_id = target.config.get("bot_id")
disable_preview = target.config.get("disable_url_preview", False)
if not bot_token:
return {"success": False, "error": "Missing bot_token"}
engine = get_engine()
async with AsyncSession(engine) as session:
recv_rows = (await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.enabled == True,
)
)).all()
if not recv_rows:
return {"success": False, "error": "No receivers configured"}
# Resolve per-receiver locale
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
chat_locale_map: dict[str, str] = {}
if bot_id and chat_ids:
chat_rows = (await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id.in_(chat_ids),
)
)).all()
for chat in chat_rows:
override = (
getattr(chat, "language_override", "") or
getattr(chat, "language_code", "") or ""
)
if override:
chat_locale_map[chat.chat_id] = override[:2].lower()
http = await get_http_session()
client = TelegramClient(http, bot_token)
# Parallelize per-receiver sends with a small semaphore — broadcast to
# N chats now takes ~ceil(N / concurrency) × RTT instead of N × RTT,
# matching the dispatcher's bounded-concurrency pattern. Capped below
# Telegram's rate limit so we don't trigger 429s on large fleets.
sem = asyncio.Semaphore(_TEST_SEND_CONCURRENCY)
async def _send_one(r: TargetReceiver) -> dict | None:
chat_id = str(r.config.get("chat_id", ""))
if not chat_id:
return None
explicit = getattr(r, "locale", "") or ""
locale = explicit or chat_locale_map.get(chat_id) or default_locale
message = _get_test_message(locale[:2].lower(), "telegram")
async with sem:
return await client.send_message(
chat_id=chat_id,
text=message,
disable_web_page_preview=bool(disable_preview),
)
raw = await asyncio.gather(*(_send_one(r) for r in recv_rows))
results = [r for r in raw if r is not None]
return _aggregate(results)
async def _send_broadcast_test(target: NotificationTarget, locale: str) -> dict: async def _send_broadcast_test(target: NotificationTarget, locale: str) -> dict:
"""Send test notifications to all child targets of a broadcast target.""" """Send test notifications to all child targets of a broadcast target."""
child_ids = target.config.get("child_target_ids", []) child_ids = target.config.get("child_target_ids", [])
@@ -0,0 +1,230 @@
"""Startup hook that applies a pending restore prepared via the backup API.
When an admin uploads a backup via /api/backup/prepare-restore, the file is
staged at data/pending_restore.json and marker rows are written to AppSetting.
This module is invoked during app startup (after migrations + seeds) to
atomically apply that pending restore if present before the server begins
serving requests.
If the apply fails, the pending file is kept so the operator can inspect it
and markers are updated to record the last error. On success, the staged file
is archived under data/applied_restores/<timestamp>.json and markers are
cleared.
Integrity checks on startup:
- The on-disk file's SHA256 must match ``PENDING_RESTORE_SHA256_KEY``
(written atomically with the staged file). Protects against tampering
between prepare and restart.
- The pending path must resolve *inside* ``app_config.data_dir``. Protects
against a rogue AppSetting pointing at an arbitrary file.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import shutil
from datetime import datetime, timezone
from sqlmodel.ext.asyncio.session import AsyncSession
from ..api.backup import (
PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_PATH_KEY,
PENDING_RESTORE_SHA256_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY,
_applied_restores_dir,
_pending_restore_path,
)
from ..config import settings as app_config
from ..database.engine import get_engine
from ..database.models import AppSetting
from .backup_schema import BackupFile, ConflictMode
from .backup_service import import_backup
_LOGGER = logging.getLogger(__name__)
PENDING_RESTORE_LAST_ERROR_KEY = "pending_restore_last_error"
PENDING_RESTORE_LAST_APPLIED_KEY = "pending_restore_last_applied"
async def apply_pending_restore_if_any() -> None:
"""Apply a staged restore if one exists. Idempotent and safe to call at startup."""
engine = get_engine()
async with AsyncSession(engine) as session:
path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
if not path_row or not path_row.value:
return
pending_path = _pending_restore_path()
# Defensive: ensure the hard-coded path still lives inside data_dir.
# If future refactors let this be read from AppSetting, this check
# blocks arbitrary-file reads.
try:
resolved = pending_path.resolve()
data_root = app_config.data_dir.resolve()
resolved.relative_to(data_root)
except (ValueError, OSError):
_LOGGER.error(
"Pending-restore path %s is outside data_dir %s — refusing to apply",
pending_path, app_config.data_dir,
)
await _record_error(session, "Pending path outside data_dir")
await session.commit()
return
if not pending_path.exists():
_LOGGER.warning(
"Pending-restore marker present but file missing at %s — clearing marker",
pending_path,
)
await _clear_markers(session)
await session.commit()
return
conflict_row = await session.get(AppSetting, PENDING_RESTORE_CONFLICT_KEY)
conflict_mode = ConflictMode(conflict_row.value) if conflict_row and conflict_row.value else ConflictMode.SKIP
uploaded_by_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_BY_KEY)
uploaded_by = uploaded_by_row.value if uploaded_by_row else "admin"
sha_row = await session.get(AppSetting, PENDING_RESTORE_SHA256_KEY)
expected_sha = (sha_row.value or "").strip().lower() if sha_row else ""
try:
raw_bytes = await asyncio.to_thread(pending_path.read_bytes)
except OSError as err:
_LOGGER.exception("Pending-restore file unreadable")
await _record_error(session, f"Unreadable backup: {err}")
await session.commit()
return
# Integrity: reject unless hash matches what prepare-restore stored.
# An attacker with write access to data/ (swapped file, bind-mount
# abuse) does not also have write access to the DB.
if not expected_sha:
_LOGGER.error("Pending-restore marker has no SHA256; refusing to apply")
await _record_error(session, "Missing integrity marker")
await session.commit()
return
actual_sha = hashlib.sha256(raw_bytes).hexdigest()
if actual_sha != expected_sha:
_LOGGER.error(
"Pending-restore SHA256 mismatch (expected %s, got %s) — refusing to apply",
expected_sha, actual_sha,
)
await _record_error(
session,
"Integrity check failed: on-disk backup SHA256 does not match the hash "
"recorded at prepare time. File may have been tampered with; cancel and "
"re-upload.",
)
await session.commit()
return
try:
raw = json.loads(raw_bytes.decode("utf-8"))
backup = BackupFile.model_validate(raw)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Pending-restore file unreadable")
await _record_error(session, f"Unreadable backup: {err}")
await session.commit()
return
# Resolve the target user: first admin (restore is cross-user).
# The backup carries its own user_id per-record, so this is mostly
# used for provenance.
from sqlmodel import select
from ..database.models import User
admin_row = (await session.exec(select(User).where(User.role == "admin"))).first()
if not admin_row:
_LOGGER.error("No admin user found; refusing to apply pending restore")
await _record_error(session, "No admin user available to own the restore")
await session.commit()
return
try:
result = await import_backup(session, admin_row.id, backup, conflict_mode)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Pending-restore apply failed")
# Discard any partial inserts the importer made before raising —
# committing partial state would let a crafted failing backup
# selectively mutate entities. The error-record commit below
# happens on a *fresh* session.
await session.rollback()
async with AsyncSession(engine) as fresh:
await _record_error(fresh, str(err))
await fresh.commit()
return
# Archive the file
archive_dir = _applied_restores_dir()
archive_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
archived_name = f"applied-{ts}.json"
try:
shutil.move(str(pending_path), str(archive_dir / archived_name))
except Exception as err: # noqa: BLE001
_LOGGER.warning("Could not archive applied restore file: %s", err)
# Still consider the apply a success; just best-effort cleanup
try:
pending_path.unlink()
except Exception:
pass
await _clear_markers(session)
applied_summary = {
"applied_at": datetime.now(timezone.utc).isoformat(),
"uploaded_by": uploaded_by,
"archived_file": archived_name,
"stats": result.model_dump() if hasattr(result, "model_dump") else {},
}
await _set_setting(
session,
PENDING_RESTORE_LAST_APPLIED_KEY,
json.dumps(applied_summary, default=str),
)
# Clear any prior error marker.
err_row = await session.get(AppSetting, PENDING_RESTORE_LAST_ERROR_KEY)
if err_row:
await session.delete(err_row)
await session.commit()
_LOGGER.info(
"Applied pending restore (uploaded by %s): %s",
uploaded_by, applied_summary["stats"],
)
async def _clear_markers(session: AsyncSession) -> None:
for key in (
PENDING_RESTORE_PATH_KEY,
PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY,
PENDING_RESTORE_SHA256_KEY,
):
row = await session.get(AppSetting, key)
if row:
await session.delete(row)
async def _record_error(session: AsyncSession, message: str) -> None:
await _set_setting(
session,
PENDING_RESTORE_LAST_ERROR_KEY,
json.dumps({
"at": datetime.now(timezone.utc).isoformat(),
"message": message[:2048],
}),
)
async def _set_setting(session: AsyncSession, key: str, value: str) -> None:
row = await session.get(AppSetting, key)
if row:
row.value = value
else:
row = AppSetting(key=key, value=value)
session.add(row)
@@ -29,7 +29,8 @@ _SAMPLE_ASSET = {
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890", "public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"download_url": "https://immich.example.com/api/assets/abc123/original", "download_url": "https://immich.example.com/api/assets/abc123/original",
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail", "photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
"file_size": 3_500_000, # 3.5 MB "file_size": 3_500_000, # 3.5 MB — original asset bytes
"playback_size": None, # photos are sent as-is, no transcoded variant
"oversized": False, "oversized": False,
} }
@@ -43,7 +44,8 @@ _SAMPLE_VIDEO_ASSET = {
"photo_url": None, "photo_url": None,
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef", "public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
"playback_url": "https://immich.example.com/api/assets/def456/video", "playback_url": "https://immich.example.com/api/assets/def456/video",
"file_size": 75_000_000, # 75 MB — exceeds Telegram's 50 MB limit "file_size": 180_000_000, # 180 MB — original HEVC
"playback_size": 62_000_000, # 62 MB transcoded — exceeds Telegram's 50 MB limit
"oversized": True, "oversized": True,
} }
@@ -34,6 +34,9 @@ async def start_scheduler() -> None:
# Schedule daily cleanup of old event log entries # Schedule daily cleanup of old event log entries
_schedule_event_cleanup() _schedule_event_cleanup()
# Schedule periodic Telegram chat title refresh
_schedule_telegram_chat_sync()
# Start debounced command auto-sync scheduler # Start debounced command auto-sync scheduler
from .command_sync import start_sync_scheduler from .command_sync import start_sync_scheduler
start_sync_scheduler() start_sync_scheduler()
@@ -60,6 +63,150 @@ def _schedule_event_cleanup() -> None:
_LOGGER.info("Scheduled daily event log cleanup at 03:00 UTC") _LOGGER.info("Scheduled daily event log cleanup at 03:00 UTC")
# Chat-title refresh tuning.
# Sweep runs daily as a fallback — we additionally refresh opportunistically
# on every incoming webhook/long-poll update (``save_chat_from_webhook``), so
# the sweep only catches chats that haven't sent anything recently.
_CHAT_SYNC_INTERVAL_HOURS = 24
_CHAT_SYNC_INITIAL_DELAY_SECONDS = 60
_CHAT_SYNC_CONCURRENCY = 10
def _schedule_telegram_chat_sync() -> None:
"""Schedule periodic refresh of Telegram chat titles via getChat."""
from apscheduler.triggers.interval import IntervalTrigger
scheduler = get_scheduler()
job_id = "refresh_telegram_chat_titles"
if scheduler.get_job(job_id):
return
scheduler.add_job(
_refresh_telegram_chat_titles,
IntervalTrigger(hours=_CHAT_SYNC_INTERVAL_HOURS),
id=job_id,
replace_existing=True,
max_instances=1,
next_run_time=None,
)
# Fire once shortly after startup so stale names refresh without waiting a day.
from datetime import datetime, timedelta, timezone
scheduler.add_job(
_refresh_telegram_chat_titles,
"date",
run_date=datetime.now(timezone.utc) + timedelta(seconds=_CHAT_SYNC_INITIAL_DELAY_SECONDS),
id="refresh_telegram_chat_titles_once",
replace_existing=True,
max_instances=1,
)
_LOGGER.info(
"Scheduled Telegram chat title refresh every %sh (concurrency %s)",
_CHAT_SYNC_INTERVAL_HOURS, _CHAT_SYNC_CONCURRENCY,
)
async def _refresh_telegram_chat_titles() -> None:
"""Refresh TelegramChat.title/username via getChat for all known chats.
Runs requests in bounded parallel (``_CHAT_SYNC_CONCURRENCY``) so a fleet
of 50 chats finishes in ~5 round-trips instead of 50. Telegram's
``getChat`` rate limit is well above 10 concurrent per bot, and the cap is
global across bots so we never flood the shared HTTP session.
"""
import asyncio
from collections import defaultdict
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.notifications.telegram.client import TelegramClient
from ..database.engine import get_engine
from ..database.models import TelegramBot, TelegramChat
from .http_session import get_http_session
engine = get_engine()
async with AsyncSession(engine) as session:
bots = (await session.exec(select(TelegramBot))).all()
bot_tokens = {b.id: b.token for b in bots if b.token}
if not bot_tokens:
return
chats = (await session.exec(select(TelegramChat))).all()
by_bot: dict[int, list[TelegramChat]] = defaultdict(list)
for chat in chats:
if chat.bot_id in bot_tokens:
by_bot[chat.bot_id].append(chat)
if not by_bot:
return
http = await get_http_session()
clients_by_bot = {
bot_id: TelegramClient(http, token) for bot_id, token in bot_tokens.items()
}
sem = asyncio.Semaphore(_CHAT_SYNC_CONCURRENCY)
async def _fetch(bot_id: int, chat: TelegramChat) -> tuple[int, dict | None, str | None]:
"""Return (chat_row_id, info_dict_or_None, error_message_or_None)."""
async with sem:
try:
res = await clients_by_bot[bot_id].get_chat(chat.chat_id)
except Exception as err: # noqa: BLE001
return chat.id, None, str(err)
if not res.get("success"):
return chat.id, None, res.get("error") or "unknown"
return chat.id, (res.get("result") or {}), None
tasks = [
_fetch(bot_id, chat)
for bot_id, bot_chats in by_bot.items()
for chat in bot_chats
]
results = await asyncio.gather(*tasks)
refreshed = 0
errors = 0
# Bucket results first, then fetch all rows in one IN-query instead of
# per-row ``session.get`` — otherwise a 50-chat fleet issues 50 extra
# SELECTs before commit.
successes: dict[int, dict] = {}
for chat_id, info, err in results:
if err is not None or info is None:
errors += 1
if err:
_LOGGER.debug("getChat failed for chat row %s: %s", chat_id, err)
continue
if chat_id is not None:
successes[chat_id] = info
async with AsyncSession(engine) as session:
if successes:
rows = (await session.exec(
select(TelegramChat).where(TelegramChat.id.in_(list(successes.keys())))
)).all()
for merged in rows:
info = successes.get(merged.id)
if not info:
continue
title = info.get("title") or (
(info.get("first_name", "") + " " + info.get("last_name", "")).strip()
)
changed = False
if title and merged.title != title:
merged.title = title
changed = True
new_username = info.get("username")
if new_username is not None and merged.username != new_username:
merged.username = new_username
changed = True
if changed:
session.add(merged)
refreshed += 1
await session.commit()
_LOGGER.info(
"Telegram chat title refresh: %s updated, %s errors", refreshed, errors
)
async def _cleanup_old_events() -> None: async def _cleanup_old_events() -> None:
"""Delete EventLog entries older than 90 days.""" """Delete EventLog entries older than 90 days."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -28,6 +28,16 @@ _LOGGER = logging.getLogger(__name__)
# Track last update_id per bot to use as offset # Track last update_id per bot to use as offset
_last_update_id: dict[int, int] = {} _last_update_id: dict[int, int] = {}
# Throttle auto-reclaim attempts so we don't hammer deleteWebhook when a
# stubborn external instance keeps re-setting the webhook. (bot_id → unix ts)
_last_webhook_reclaim_at: dict[int, float] = {}
_WEBHOOK_RECLAIM_COOLDOWN_SECONDS = 60.0
# Phrase Telegram uses in the 409 response description for the
# "webhook is active" conflict. Matched case-insensitively so we don't
# depend on exact wording.
_WEBHOOK_CONFLICT_PHRASE = "webhook is active"
async def _get_bot_ids_with_active_listeners() -> set[int]: async def _get_bot_ids_with_active_listeners() -> set[int]:
"""Return bot IDs that have at least one active command tracker listener. """Return bot IDs that have at least one active command tracker listener.
@@ -141,6 +151,64 @@ def unschedule_bot_polling(bot_id: int) -> None:
_LOGGER.info("Stopped polling for bot %d", bot_id) _LOGGER.info("Stopped polling for bot %d", bot_id)
async def _handle_webhook_conflict(bot_id: int, bot_token: str, description: str) -> None:
"""Reclaim a bot stuck behind an active webhook set by another instance.
Telegram's ``getUpdates`` returns 409 ``Conflict: can't use getUpdates
method while webhook is active`` whenever a webhook is currently
registered for the bot. Since this bot row has ``update_mode="polling"``
in our DB (that's the only reason we're polling it), the user's intent
is polling, so we drop the webhook and resume. Throttled to once per
minute per bot so a rival instance constantly re-registering the
webhook doesn't trigger a reclaim storm.
"""
import time
now = time.time()
last = _last_webhook_reclaim_at.get(bot_id, 0.0)
if now - last < _WEBHOOK_RECLAIM_COOLDOWN_SECONDS:
# Already logged recently; stay quiet until cooldown expires so the
# user gets one clear warning line per minute, not one every 3s.
return
_last_webhook_reclaim_at[bot_id] = now
from .http_session import get_http_session
http = await get_http_session()
client = TelegramClient(http, bot_token)
# Surface which URL stole the bot so the user can tell where it came from.
conflicting_url = ""
try:
info = await client.get_webhook_info()
if info.get("success"):
conflicting_url = info.get("result", {}).get("url", "") or ""
except Exception as err: # noqa: BLE001
_LOGGER.debug("getWebhookInfo during conflict recovery failed: %s", err)
_LOGGER.warning(
"Bot %d: webhook is active (url=%r) but this instance is in polling "
"mode — calling deleteWebhook to reclaim. Telegram said: %s",
bot_id, conflicting_url, description,
)
try:
del_result = await client.delete_webhook()
if del_result.get("success"):
_LOGGER.warning(
"Bot %d: webhook cleared; polling will resume on next tick",
bot_id,
)
# Reset offset so we don't skip updates that accumulated during the
# conflict window (Telegram held them until a client acknowledged).
_last_update_id.pop(bot_id, None)
else:
_LOGGER.error(
"Bot %d: deleteWebhook failed: %s",
bot_id, del_result.get("error"),
)
except Exception as err: # noqa: BLE001
_LOGGER.error("Bot %d: deleteWebhook raised: %s", bot_id, err)
async def _poll_bot(bot_id: int) -> None: async def _poll_bot(bot_id: int) -> None:
"""Fetch updates from Telegram and process them.""" """Fetch updates from Telegram and process them."""
engine = get_engine() engine = get_engine()
@@ -167,6 +235,15 @@ async def _poll_bot(bot_id: int) -> None:
offset=offset + 1 if offset else None, limit=50, offset=offset + 1 if offset else None, limit=50,
) )
if not result.get("success"): if not result.get("success"):
err_text = str(result.get("error") or "")
# Detect the webhook-is-active conflict: another instance (or a
# stale registration) owns this bot's delivery, so getUpdates
# returns 409 and we get zero updates forever. Reclaim it —
# but only for bots the user explicitly set to polling mode.
if _WEBHOOK_CONFLICT_PHRASE in err_text.lower():
await _handle_webhook_conflict(bot_id, bot_token, err_text)
else:
_LOGGER.debug("Polling error for bot %d: %s", bot_id, err_text)
return return
updates = result.get("result", []) updates = result.get("result", [])
except Exception as e: except Exception as e:
@@ -55,17 +55,21 @@ async def dispatch_test_notification(
provider_config = dict(provider.config) provider_config = dict(provider.config)
collection_ids = list(tracker.collection_ids or []) collection_ids = list(tracker.collection_ids or [])
# Load tracking config # Resolve tracking config: per-link override, else the tracker's default.
# The real watcher applies this fallback in ``load_link_data`` — tests
# must use the same logic or the user's per-tracker defaults look broken.
tracking_config_id = tt.tracking_config_id or tracker.default_tracking_config_id
tracking_config = None tracking_config = None
if tt.tracking_config_id: if tracking_config_id:
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id) tracking_config = await session.get(TrackingConfig, tracking_config_id)
# Load template slots keyed by EventType.SCHEDULED_MESSAGE.value # Same fallback for template config.
template_config_id = tt.template_config_id or tracker.default_template_config_id
template_config = None template_config = None
template_slots: dict[str, dict[str, str]] | None = None template_slots: dict[str, dict[str, str]] | None = None
slot_name = _TEST_TYPE_SLOT_MAP.get(test_type, test_type) slot_name = _TEST_TYPE_SLOT_MAP.get(test_type, test_type)
if tt.template_config_id: if template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id) template_config = await session.get(TemplateConfig, template_config_id)
if template_config: if template_config:
slot_result = await session.exec( slot_result = await session.exec(
select(TemplateSlot).where( select(TemplateSlot).where(
@@ -79,7 +83,8 @@ async def dispatch_test_notification(
if locale_map: if locale_map:
template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map} template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map}
# Resolve target config + receivers (same as watcher) # Resolve target config + receivers (same as watcher — this already sets
# each receiver.locale from TargetReceiver.locale or TelegramChat override)
resolved = await _resolve_target(session, target) resolved = await _resolve_target(session, target)
target_cfg = TargetConfig( target_cfg = TargetConfig(
@@ -95,7 +100,28 @@ async def dispatch_test_notification(
receivers=resolved["receivers"], receivers=resolved["receivers"],
) )
if not template_slots:
if not template_config_id:
return {
"success": False,
"error": (
"This tracker has no Template Config linked (neither on the "
"tracker's default nor on this target link). Assign one in the "
"tracker settings and make sure it defines a "
f"'{slot_name}' slot."
),
}
return {
"success": False,
"error": (
f"No '{slot_name}' template defined in the linked Template Config "
f"'{template_config.name if template_config else template_config_id}' "
f"(locale: {locale}). Add the slot under Template Configs."
),
}
# Fetch assets and build event # Fetch assets and build event
try:
event = await _build_event( event = await _build_event(
provider_type=provider.type, provider_type=provider.type,
provider_config=provider_config, provider_config=provider_config,
@@ -106,10 +132,26 @@ async def dispatch_test_notification(
test_type=test_type, test_type=test_type,
tracking_config=tracking_config, tracking_config=tracking_config,
) )
except Exception as err: # noqa: BLE001
_LOGGER.exception("Test dispatch event build failed")
return {"success": False, "error": f"Provider connection failed: {err}"}
if event is None: if event is None:
return {"success": False, "error": "No data returned from provider"} return {
"success": False,
"error": (
"Provider returned no data. Check that the provider is reachable, "
"credentials are valid, and the tracker has collections configured."
),
}
# Periodic summary only needs album stats (extra.albums), not assets — skip the asset check.
if not event.added_assets and test_type in ("scheduled", "memory"): if not event.added_assets and test_type in ("scheduled", "memory"):
return {"success": False, "error": "No matching assets found" + (" for today" if test_type == "memory" else "")} return {
"success": False,
"error": (
"No matching assets found. Verify the tracker's albums contain assets "
"that pass the tracking config filters (favorites only, rating, asset type)."
) + (" for today" if test_type == "memory" else ""),
}
# Dispatch through the real NotificationDispatcher # Dispatch through the real NotificationDispatcher
url_cache, asset_cache = await _get_telegram_caches() url_cache, asset_cache = await _get_telegram_caches()
@@ -136,6 +178,13 @@ async def _build_event(
from datetime import datetime, timezone from datetime import datetime, timezone
if provider_type == "immich": if provider_type == "immich":
if test_type == "periodic":
return await _build_immich_periodic_event(
provider_config=provider_config,
provider_name=provider_name,
tracker_name=tracker_name,
collection_ids=collection_ids,
)
return await _build_immich_event( return await _build_immich_event(
provider_config=provider_config, provider_config=provider_config,
provider_name=provider_name, provider_name=provider_name,
@@ -201,14 +250,23 @@ async def _build_immich_event(
collection_ids, limit, asset_type, favorite_only, min_rating, collection_ids, limit, asset_type, favorite_only, min_rating,
) )
# Album-based path: use shared collect_scheduled_assets # Album-based path: use shared collect_scheduled_assets.
# Fetch albums + shared links in parallel — on a 20-album tracker the old
# serial ``await`` loop took ~2 × 20 × RTT, now it's one round-trip.
import asyncio as _asyncio
album_tasks = [immich.client.get_album(aid) for aid in collection_ids]
link_tasks = [immich.client.get_shared_links(aid) for aid in collection_ids]
album_results, link_results = await _asyncio.gather(
_asyncio.gather(*album_tasks, return_exceptions=True),
_asyncio.gather(*link_tasks, return_exceptions=True),
)
albums: dict[str, ImmichAlbumData] = {} albums: dict[str, ImmichAlbumData] = {}
shared_links: dict[str, list[SharedLinkInfo]] = {} shared_links: dict[str, list[SharedLinkInfo]] = {}
for album_id in collection_ids: for album_id, album, links in zip(collection_ids, album_results, link_results):
album = await immich.client.get_album(album_id) if isinstance(album, Exception) or album is None:
if album: continue
albums[album_id] = album albums[album_id] = album
shared_links[album_id] = await immich.client.get_shared_links(album_id) shared_links[album_id] = links if not isinstance(links, Exception) else []
assets, collections_extra = collect_scheduled_assets( assets, collections_extra = collect_scheduled_assets(
albums, shared_links, ext_domain, albums, shared_links, ext_domain,
@@ -237,6 +295,84 @@ async def _build_immich_event(
) )
async def _build_immich_periodic_event(
*,
provider_config: dict,
provider_name: str,
tracker_name: str,
collection_ids: list[str],
) -> ServiceEvent | None:
"""Build a periodic-summary event (album stats only, no assets).
Reuses the same shared core utility (`collect_scheduled_assets`) that
scheduled/memory tests use, invoked with limit=0 so we get the full
``collections_extra`` block (album name/url/counts/...) without selecting
any individual assets which is exactly what the
``periodic_summary_message`` template renders.
"""
from datetime import datetime, timezone
from notify_bridge_core.providers.immich import ImmichServiceProvider
from notify_bridge_core.providers.immich.asset_utils import collect_scheduled_assets
from notify_bridge_core.providers.immich.models import ImmichAlbumData, SharedLinkInfo
from .http_session import get_http_session
http_session = await get_http_session()
immich = ImmichServiceProvider(
http_session,
provider_config.get("url", ""),
provider_config.get("api_key", ""),
provider_config.get("external_domain"),
provider_name,
)
if not await immich.connect():
return None
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
# Parallel fetch — see _build_immich_event above for the same rationale.
import asyncio as _asyncio
album_tasks = [immich.client.get_album(aid) for aid in collection_ids]
link_tasks = [immich.client.get_shared_links(aid) for aid in collection_ids]
album_results, link_results = await _asyncio.gather(
_asyncio.gather(*album_tasks, return_exceptions=True),
_asyncio.gather(*link_tasks, return_exceptions=True),
)
albums: dict[str, ImmichAlbumData] = {}
shared_links: dict[str, list[SharedLinkInfo]] = {}
for album_id, album, links in zip(collection_ids, album_results, link_results):
if isinstance(album, Exception) or album is None:
continue
albums[album_id] = album
shared_links[album_id] = links if not isinstance(links, Exception) else []
# limit=0 → returns ([], collections_extra) with full per-album stats.
_assets, collections_extra = collect_scheduled_assets(
albums, shared_links, ext_domain,
limit=0,
asset_type="all",
favorite_only=False,
min_rating=0,
is_memory=False,
)
first_col = collections_extra[0] if collections_extra else {}
return ServiceEvent(
event_type=EventType.SCHEDULED_MESSAGE,
provider_type=ServiceProviderType.IMMICH,
provider_name=provider_name,
collection_id=collection_ids[0] if collection_ids else "",
collection_name=first_col.get("name", tracker_name),
timestamp=datetime.now(timezone.utc),
added_assets=[],
added_count=0,
extra={
"collections": collections_extra,
"albums": collections_extra,
**(first_col if first_col else {}),
},
)
async def _build_native_memory_event( async def _build_native_memory_event(
immich, immich,
ext_domain: str, ext_domain: str,
@@ -21,7 +21,11 @@ from ..database.models import (
NotificationTrackerState, NotificationTrackerState,
ServiceProvider, ServiceProvider,
) )
from .dispatch_helpers import event_allowed_by_config, load_link_data from .dispatch_helpers import (
event_allowed_by_config,
get_app_timezone,
load_link_data,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -85,7 +89,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
} }
# Load tracker-target links # Load tracker-target links
link_data = await load_link_data(session, tracker_id, check_quiet_hours=True) link_data = await load_link_data(session, tracker_id)
# Load app-level timezone for quiet-hours evaluation.
app_tz = await get_app_timezone(session)
# Snapshot the data we need # Snapshot the data we need
provider_type = provider.type provider_type = provider.type
@@ -191,6 +198,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
for event in events: for event in events:
assets_count = event.added_count or event.removed_count or 0 assets_count = event.added_count or event.removed_count or 0
log = EventLog( log = EventLog(
user_id=tracker.user_id,
tracker_id=tracker_id, tracker_id=tracker_id,
tracker_name=tracker.name, tracker_name=tracker.name,
provider_id=provider.id, provider_id=provider.id,
@@ -235,7 +243,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
for ld in link_data: for ld in link_data:
# Apply per-link event filtering from tracking config # Apply per-link event filtering from tracking config
tc = ld["tracking_config"] tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc): if tc and not event_allowed_by_config(event, tc, app_tz):
_LOGGER.info(" Skipped by tracking config filter") _LOGGER.info(" Skipped by tracking config filter")
continue continue
+3
View File
@@ -25,6 +25,9 @@ fi
# Start backend # Start backend
export NOTIFY_BRIDGE_DATA_DIR=./test-data export NOTIFY_BRIDGE_DATA_DIR=./test-data
export NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32-chars export NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32-chars
# Dev targets (homelab Immich / Gitea / etc.) live on RFC1918 ranges; the SSRF
# guard rejects private addresses by default, which would make trackers fail.
export NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \ nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \
--host 0.0.0.0 --port 8420 > .backend.log 2>&1 & --host 0.0.0.0 --port 8420 > .backend.log 2>&1 &