Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7daadadc2 | |||
| e04ad16ca6 | |||
| d7d0a5d921 | |||
| 93df538819 | |||
| 2be608ba95 | |||
| 5028f15f4f | |||
| 5a232f18b8 | |||
| 3b76a09759 | |||
| 4ff3876e49 | |||
| 83215473c7 | |||
| 4e23d2b054 | |||
| f7d51b27d2 | |||
| 3bb0585e43 | |||
| 58cba88c92 | |||
| 645331d320 | |||
| 6c3dd67c1b | |||
| 56993d2ca3 | |||
| fe92b206b7 | |||
| cf4976da2f | |||
| 80c034d2af | |||
| a7a2b4efa4 |
+8
-234
@@ -1,245 +1,19 @@
|
||||
## v0.1.0 (2026-04-21)
|
||||
# v0.2.5 (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.
|
||||
Hotfix release on top of v0.2.4 — the settings page couldn't save numeric
|
||||
fields after the cache-TTL / max-entries rework. See v0.2.4 notes for the
|
||||
main changes (thumbhash-validated cache, settings UX overhaul, mobile-nav
|
||||
parity).
|
||||
|
||||
### Highlights
|
||||
## Bug 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
|
||||
|
||||
#### Service providers
|
||||
- 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
|
||||
- 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
|
||||
- Telegram commands, app settings, bot polling, webhook handling, UI improvements ([03ec9b3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03ec9b3))
|
||||
- Per-chat command toggle, listener name + toggle in bot tab ([b3b6c31](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b3b6c31))
|
||||
- Rich command templates with public links + media text-first flow ([d0bc767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d0bc767))
|
||||
- Locale-aware command templates, debounced auto-sync, entity pickers ([1167d13](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1167d13))
|
||||
- Remove hardcoded command templates, enforce template system exclusively ([ddcbfda](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ddcbfda))
|
||||
|
||||
#### Template system
|
||||
- Phase 4 — template system ([f36f070](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f36f070))
|
||||
- Locale-aware notification templates + UX improvements ([37388c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/37388c4))
|
||||
- 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
|
||||
- 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
|
||||
- Comprehensive consistency review — UI/UX, code quality, functional parity ([6e51164](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e51164))
|
||||
- Comprehensive codebase review — security, performance, quality, UX ([b803d00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b803d00))
|
||||
- Provider descriptor registry — eliminate provider-specific hardcoding ([8cb836e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8cb836e))
|
||||
- Provider-agnostic bot command system + Gitea commands ([63437c1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/63437c1))
|
||||
- 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))
|
||||
- **Accept numeric values in settings update payload** — Svelte's `bind:value` on `<input type="number">` coerces to a JS number, and Pydantic v2 wouldn't auto-coerce `int → str`, producing a 422 on every save that touched a numeric setting (TTL, max entries) after v0.2.4. Widened numeric fields to `int | str | None` in `SettingsUpdate` and normalized to `str` before persisting. ([d7d0a5d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d7d0a5d))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [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 |
|
||||
- [d7d0a5d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d7d0a5d) — fix(settings): accept numeric values in update payload *(alexei.dolgolyov)*
|
||||
|
||||
</details>
|
||||
|
||||
@@ -12,6 +12,10 @@ services:
|
||||
environment:
|
||||
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
|
||||
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-*}
|
||||
# Homelab target: allow outbound requests to RFC1918 / link-local addresses.
|
||||
# The SSRF guard otherwise rejects 10.*/172.16.*/192.168.*/169.254.* hosts,
|
||||
# which breaks tracking of Immich / Gitea / etc. running on the same LAN.
|
||||
- NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"]
|
||||
interval: 30s
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
+115
-1
@@ -11,6 +11,37 @@ export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
|
||||
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 {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token');
|
||||
@@ -63,6 +94,9 @@ async function doRefreshAccessToken(): Promise<boolean> {
|
||||
}
|
||||
|
||||
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>(
|
||||
path: string,
|
||||
@@ -106,7 +140,17 @@ export async function api<T = any>(
|
||||
|
||||
if (!res.ok) {
|
||||
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();
|
||||
@@ -114,3 +158,73 @@ export async function api<T = any>(
|
||||
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">
|
||||
import { t } from '$lib/i18n';
|
||||
import { parseDate } from '$lib/api';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
interface DayData {
|
||||
@@ -47,7 +48,7 @@
|
||||
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
<script lang="ts">
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface LocaleMeta {
|
||||
code: string;
|
||||
name: string; // English name
|
||||
native: string; // Native script
|
||||
rtl?: boolean;
|
||||
}
|
||||
|
||||
const CATALOG: LocaleMeta[] = [
|
||||
{ code: 'en', name: 'English', native: 'English' },
|
||||
{ code: 'ru', name: 'Russian', native: 'Русский' },
|
||||
{ code: 'de', name: 'German', native: 'Deutsch' },
|
||||
{ code: 'fr', name: 'French', native: 'Français' },
|
||||
{ code: 'es', name: 'Spanish', native: 'Español' },
|
||||
{ code: 'it', name: 'Italian', native: 'Italiano' },
|
||||
{ code: 'pt', name: 'Portuguese', native: 'Português' },
|
||||
{ code: 'pl', name: 'Polish', native: 'Polski' },
|
||||
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
|
||||
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
|
||||
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
|
||||
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
|
||||
{ code: 'da', name: 'Danish', native: 'Dansk' },
|
||||
{ code: 'cs', name: 'Czech', native: 'Čeština' },
|
||||
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
|
||||
{ code: 'ro', name: 'Romanian', native: 'Română' },
|
||||
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
|
||||
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
|
||||
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
|
||||
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
|
||||
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
|
||||
{ code: 'sr', name: 'Serbian', native: 'Српски' },
|
||||
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
|
||||
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
|
||||
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
|
||||
{ code: 'zh', name: 'Chinese', native: '中文' },
|
||||
{ code: 'ja', name: 'Japanese', native: '日本語' },
|
||||
{ code: 'ko', name: 'Korean', native: '한국어' },
|
||||
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
|
||||
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
|
||||
{ code: 'th', name: 'Thai', native: 'ไทย' },
|
||||
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
|
||||
];
|
||||
|
||||
// Locales that ship with default notification & command templates.
|
||||
const SHIPPED = new Set(['en', 'ru']);
|
||||
|
||||
let {
|
||||
value = $bindable<string>(''),
|
||||
}: {
|
||||
value: string;
|
||||
} = $props();
|
||||
|
||||
// Parse the comma-separated backend string into an ordered array of codes.
|
||||
const codes = $derived.by<string[]>(() => {
|
||||
if (!value) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of value.split(',')) {
|
||||
const c = raw.trim().toLowerCase();
|
||||
if (!c || seen.has(c)) continue;
|
||||
seen.add(c);
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function commit(next: string[]) {
|
||||
// De-dupe (preserve order) and serialise back to the backend format.
|
||||
const seen = new Set<string>();
|
||||
const clean = next.map(c => c.trim().toLowerCase())
|
||||
.filter(c => c && !seen.has(c) && (seen.add(c), true));
|
||||
value = clean.join(',');
|
||||
}
|
||||
|
||||
function meta(code: string): LocaleMeta {
|
||||
return CATALOG.find(l => l.code === code) ?? {
|
||||
code,
|
||||
name: code.toUpperCase(),
|
||||
native: code.toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function remove(code: string) {
|
||||
commit(codes.filter(c => c !== code));
|
||||
}
|
||||
|
||||
function makePrimary(code: string) {
|
||||
commit([code, ...codes.filter(c => c !== code)]);
|
||||
}
|
||||
|
||||
function moveUp(code: string) {
|
||||
const i = codes.indexOf(code);
|
||||
if (i <= 0) return;
|
||||
const next = [...codes];
|
||||
[next[i - 1], next[i]] = [next[i], next[i - 1]];
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function moveDown(code: string) {
|
||||
const i = codes.indexOf(code);
|
||||
if (i < 0 || i >= codes.length - 1) return;
|
||||
const next = [...codes];
|
||||
[next[i], next[i + 1]] = [next[i + 1], next[i]];
|
||||
commit(next);
|
||||
}
|
||||
|
||||
// --- Add flow ----------------------------------------------------------
|
||||
|
||||
let addOpen = $state(false);
|
||||
let addQuery = $state('');
|
||||
let addInputEl = $state<HTMLInputElement | null>(null);
|
||||
let highlightIdx = $state(0);
|
||||
|
||||
// Valid BCP 47-ish: 2–3 letter primary, optional '-' subtag(s) 2-8 chars.
|
||||
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
|
||||
|
||||
const selectedSet = $derived(new Set(codes));
|
||||
|
||||
const suggestions = $derived.by(() => {
|
||||
const q = addQuery.trim().toLowerCase();
|
||||
const available = CATALOG.filter(l => !selectedSet.has(l.code));
|
||||
if (!q) return available;
|
||||
return available.filter(l =>
|
||||
l.code.includes(q)
|
||||
|| l.name.toLowerCase().includes(q)
|
||||
|| l.native.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
const canAddCustom = $derived.by(() => {
|
||||
const q = addQuery.trim().toLowerCase();
|
||||
if (!q) return false;
|
||||
if (!CUSTOM_RE.test(q)) return false;
|
||||
if (selectedSet.has(q)) return false;
|
||||
// Skip "custom" entry when it matches an existing catalog entry exactly.
|
||||
if (CATALOG.some(l => l.code === q)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function openAdd() {
|
||||
addOpen = true;
|
||||
addQuery = '';
|
||||
highlightIdx = 0;
|
||||
requestAnimationFrame(() => addInputEl?.focus());
|
||||
}
|
||||
|
||||
function closeAdd() {
|
||||
addOpen = false;
|
||||
addQuery = '';
|
||||
}
|
||||
|
||||
function addCode(code: string) {
|
||||
const c = code.trim().toLowerCase();
|
||||
if (!c) return;
|
||||
commit([...codes, c]);
|
||||
addQuery = '';
|
||||
highlightIdx = 0;
|
||||
requestAnimationFrame(() => addInputEl?.focus());
|
||||
}
|
||||
|
||||
function onAddKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { closeAdd(); return; }
|
||||
const total = suggestions.length + (canAddCustom ? 1 : 0);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.min(highlightIdx + 1, Math.max(0, total - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (highlightIdx < suggestions.length) {
|
||||
addCode(suggestions[highlightIdx].code);
|
||||
} else if (canAddCustom) {
|
||||
addCode(addQuery);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { addQuery; highlightIdx = 0; });
|
||||
|
||||
// --- Drag & drop -------------------------------------------------------
|
||||
|
||||
let dragCode = $state<string | null>(null);
|
||||
let dragOverCode = $state<string | null>(null);
|
||||
|
||||
function onDragStart(e: DragEvent, code: string) {
|
||||
dragCode = code;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', code);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, code: string) {
|
||||
if (!dragCode || dragCode === code) return;
|
||||
e.preventDefault();
|
||||
dragOverCode = code;
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent, code: string) {
|
||||
e.preventDefault();
|
||||
if (!dragCode || dragCode === code) return;
|
||||
const from = codes.indexOf(dragCode);
|
||||
const to = codes.indexOf(code);
|
||||
if (from < 0 || to < 0) return;
|
||||
const next = [...codes];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
commit(next);
|
||||
dragCode = null;
|
||||
dragOverCode = null;
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragCode = null;
|
||||
dragOverCode = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ls-root">
|
||||
{#if codes.length === 0}
|
||||
<div class="ls-empty">
|
||||
<div class="ls-empty-glyph" aria-hidden="true">A あ Я</div>
|
||||
<p class="ls-empty-text">{t('locales.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="ls-list" role="list">
|
||||
{#each codes as code, i (code)}
|
||||
{@const m = meta(code)}
|
||||
{@const isPrimary = i === 0}
|
||||
{@const isShipped = SHIPPED.has(code)}
|
||||
<li
|
||||
class="ls-row"
|
||||
class:ls-row-primary={isPrimary}
|
||||
class:ls-row-dragover={dragOverCode === code}
|
||||
class:ls-row-dragging={dragCode === code}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, code)}
|
||||
ondragover={(e) => onDragOver(e, code)}
|
||||
ondrop={(e) => onDrop(e, code)}
|
||||
ondragend={onDragEnd}
|
||||
>
|
||||
<span class="ls-rail" aria-hidden="true"></span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ls-handle"
|
||||
aria-label={t('locales.reorder')}
|
||||
title={t('locales.reorder')}
|
||||
tabindex="-1"
|
||||
>
|
||||
<MdiIcon name="mdiDragVertical" size={16} />
|
||||
</button>
|
||||
|
||||
<div class="ls-text">
|
||||
<div class="ls-native" dir={m.rtl ? 'rtl' : 'ltr'} lang={code}>{m.native}</div>
|
||||
<div class="ls-meta">
|
||||
<span class="ls-name">{m.name}</span>
|
||||
<span class="ls-dot" aria-hidden="true">·</span>
|
||||
<span class="ls-code">{code}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ls-badges">
|
||||
{#if isPrimary}
|
||||
<span class="ls-tag ls-tag-primary">
|
||||
<MdiIcon name="mdiStar" size={10} />
|
||||
{t('locales.primary')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if isShipped}
|
||||
<span class="ls-tag ls-tag-shipped" title={t('locales.shippedHint')}>
|
||||
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
||||
{t('locales.shipped')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ls-actions">
|
||||
{#if !isPrimary}
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn"
|
||||
onclick={() => makePrimary(code)}
|
||||
aria-label={t('locales.makePrimary')}
|
||||
title={t('locales.makePrimary')}
|
||||
>
|
||||
<MdiIcon name="mdiStarOutline" size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn"
|
||||
onclick={() => moveUp(code)}
|
||||
disabled={i === 0}
|
||||
aria-label={t('locales.moveUp')}
|
||||
title={t('locales.moveUp')}
|
||||
>
|
||||
<MdiIcon name="mdiChevronUp" size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn"
|
||||
onclick={() => moveDown(code)}
|
||||
disabled={i === codes.length - 1}
|
||||
aria-label={t('locales.moveDown')}
|
||||
title={t('locales.moveDown')}
|
||||
>
|
||||
<MdiIcon name="mdiChevronDown" size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn ls-icon-danger"
|
||||
onclick={() => remove(code)}
|
||||
disabled={codes.length <= 1}
|
||||
aria-label={t('locales.remove')}
|
||||
title={codes.length <= 1 ? t('locales.removeLast') : t('locales.remove')}
|
||||
>
|
||||
<MdiIcon name="mdiClose" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Add zone -->
|
||||
<div class="ls-add" class:ls-add-open={addOpen}>
|
||||
{#if !addOpen}
|
||||
<button type="button" class="ls-add-trigger" onclick={openAdd}>
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
<span>{t('locales.add')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="ls-add-panel">
|
||||
<div class="ls-add-input-row">
|
||||
<MdiIcon name="mdiMagnify" size={14} />
|
||||
<input
|
||||
bind:this={addInputEl}
|
||||
bind:value={addQuery}
|
||||
onkeydown={onAddKeydown}
|
||||
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)}
|
||||
placeholder={t('locales.searchPlaceholder')}
|
||||
class="ls-add-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
/>
|
||||
<button type="button" class="ls-icon-btn" onclick={closeAdd} aria-label={t('common.cancel')}>
|
||||
<MdiIcon name="mdiClose" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ls-add-list" role="listbox">
|
||||
{#each suggestions as s, i (s.code)}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === highlightIdx}
|
||||
class="ls-sugg"
|
||||
class:ls-sugg-hl={i === highlightIdx}
|
||||
onmouseenter={() => highlightIdx = i}
|
||||
onmousedown={(e) => { e.preventDefault(); addCode(s.code); }}
|
||||
>
|
||||
<span class="ls-sugg-native" dir={s.rtl ? 'rtl' : 'ltr'} lang={s.code}>{s.native}</span>
|
||||
<span class="ls-sugg-name">{s.name}</span>
|
||||
<span class="ls-sugg-code">{s.code}</span>
|
||||
{#if SHIPPED.has(s.code)}
|
||||
<span class="ls-sugg-shipped" title={t('locales.shippedHint')}>
|
||||
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if canAddCustom}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={highlightIdx === suggestions.length}
|
||||
class="ls-sugg ls-sugg-custom"
|
||||
class:ls-sugg-hl={highlightIdx === suggestions.length}
|
||||
onmouseenter={() => highlightIdx = suggestions.length}
|
||||
onmousedown={(e) => { e.preventDefault(); addCode(addQuery); }}
|
||||
>
|
||||
<MdiIcon name="mdiPlusCircleOutline" size={14} />
|
||||
<span class="ls-sugg-custom-label">{t('locales.addCustom')}</span>
|
||||
<span class="ls-sugg-code">{addQuery.trim().toLowerCase()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if suggestions.length === 0 && !canAddCustom}
|
||||
<div class="ls-sugg-empty">{t('locales.noSuggestions')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="ls-hint">
|
||||
<MdiIcon name="mdiInformationOutline" size={12} />
|
||||
<span>{t('locales.orderHint')}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ls-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
/* ---- Empty state -------------------------------------------------- */
|
||||
.ls-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem 1.125rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--color-primary) 4%, transparent) 0%,
|
||||
transparent 60%),
|
||||
var(--color-background);
|
||||
}
|
||||
.ls-empty-glyph {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 300;
|
||||
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.ls-empty-text {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* ---- List --------------------------------------------------------- */
|
||||
.ls-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.ls-row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.75rem 0.625rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-background);
|
||||
transition: border-color 0.15s, background 0.15s, transform 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ls-row:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
|
||||
}
|
||||
.ls-row.ls-row-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.ls-row.ls-row-dragover {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-background));
|
||||
}
|
||||
.ls-row.ls-row-primary {
|
||||
background:
|
||||
linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
|
||||
transparent 30%),
|
||||
var(--color-background);
|
||||
}
|
||||
|
||||
/* Accent rail — pronounced on primary, near-invisible otherwise */
|
||||
.ls-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.ls-row.ls-row-primary .ls-rail {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.ls-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.125rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.4;
|
||||
cursor: grab;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.ls-row:hover .ls-handle {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ls-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.ls-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.ls-native {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--color-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
.ls-name {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 500;
|
||||
font-size: 0.625rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ls-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.05rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.ls-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ls-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-tag-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground, #fff);
|
||||
}
|
||||
.ls-tag-shipped {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.ls-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
.ls-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.ls-icon-btn:hover:not(:disabled) {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.ls-icon-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ls-icon-btn.ls-icon-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #ef4444 14%, transparent);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ---- Add zone ----------------------------------------------------- */
|
||||
.ls-add {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.ls-add-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.ls-add-trigger:hover {
|
||||
border-color: var(--color-primary);
|
||||
border-style: solid;
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
|
||||
}
|
||||
|
||||
.ls-add-panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-background);
|
||||
overflow: hidden;
|
||||
animation: ls-pop 0.15s ease-out;
|
||||
}
|
||||
@keyframes ls-pop {
|
||||
from { opacity: 0; transform: translateY(-2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.ls-add-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ls-add-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.125rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ls-add-list {
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.ls-sugg {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ls-sugg.ls-sugg-hl {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
.ls-sugg-native {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-sugg-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-sugg-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.05rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ls-sugg.ls-sugg-hl .ls-sugg-code {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
||||
}
|
||||
.ls-sugg-shipped {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ls-sugg-custom {
|
||||
border-top: 1px dashed var(--color-border);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.ls-sugg-custom-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ls-sugg-empty {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* ---- Hint --------------------------------------------------------- */
|
||||
.ls-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,585 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let {
|
||||
value = $bindable<string>('UTC'),
|
||||
}: {
|
||||
value: string;
|
||||
} = $props();
|
||||
|
||||
// --- Catalog -----------------------------------------------------------
|
||||
|
||||
const timezones = $derived.by<string[]>(() => {
|
||||
try {
|
||||
const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] };
|
||||
if (typeof intl.supportedValuesOf === 'function') {
|
||||
return intl.supportedValuesOf('timeZone');
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return ['UTC'];
|
||||
});
|
||||
|
||||
const detectedTz = (() => {
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
|
||||
catch { return 'UTC'; }
|
||||
})();
|
||||
|
||||
// --- Live clock --------------------------------------------------------
|
||||
|
||||
let now = $state(new Date());
|
||||
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => {
|
||||
tickHandle = setInterval(() => { now = new Date(); }, 1000);
|
||||
});
|
||||
onDestroy(() => { if (tickHandle) clearInterval(tickHandle); });
|
||||
|
||||
function splitTz(tz: string): { region: string; city: string } {
|
||||
if (!tz || tz === 'UTC' || tz === 'Etc/UTC') return { region: 'UTC', city: 'UTC' };
|
||||
const parts = tz.split('/');
|
||||
if (parts.length === 1) return { region: 'Other', city: parts[0].replace(/_/g, ' ') };
|
||||
const city = parts[parts.length - 1].replace(/_/g, ' ');
|
||||
const region = parts.slice(0, -1).join(' / ').replace(/_/g, ' ');
|
||||
return { region, city };
|
||||
}
|
||||
|
||||
function fmtTime(tz: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
} catch { return '--:--:--'; }
|
||||
}
|
||||
|
||||
function fmtDate(tz: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: tz,
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
}).format(now);
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function fmtOffset(tz: string): string {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
timeZoneName: 'shortOffset',
|
||||
}).formatToParts(now);
|
||||
const off = parts.find(p => p.type === 'timeZoneName')?.value ?? '';
|
||||
return off || 'UTC';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
// --- Selected state ----------------------------------------------------
|
||||
|
||||
const selected = $derived.by(() => {
|
||||
const s = splitTz(value || 'UTC');
|
||||
return {
|
||||
iana: value || 'UTC',
|
||||
region: s.region,
|
||||
city: s.city,
|
||||
time: fmtTime(value || 'UTC'),
|
||||
date: fmtDate(value || 'UTC'),
|
||||
offset: fmtOffset(value || 'UTC'),
|
||||
};
|
||||
});
|
||||
|
||||
// --- Picker ------------------------------------------------------------
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let highlightIdx = $state(0);
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
let panelEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase().replace(/\s+/g, '_');
|
||||
if (!q) return timezones;
|
||||
return timezones.filter(tz => tz.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// Group filtered tz list by region prefix for visual hierarchy.
|
||||
interface Group { region: string; items: string[] }
|
||||
const groups = $derived.by<Group[]>(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const tz of filtered) {
|
||||
const region = tz.includes('/') ? tz.split('/')[0] : 'Other';
|
||||
if (!map.has(region)) map.set(region, []);
|
||||
map.get(region)!.push(tz);
|
||||
}
|
||||
const REGION_ORDER = ['UTC', 'Europe', 'America', 'Asia', 'Africa', 'Australia', 'Pacific', 'Atlantic', 'Indian', 'Antarctica', 'Arctic', 'Etc', 'Other'];
|
||||
return [...map.entries()]
|
||||
.sort(([a], [b]) => {
|
||||
const ai = REGION_ORDER.indexOf(a);
|
||||
const bi = REGION_ORDER.indexOf(b);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
})
|
||||
.map(([region, items]) => ({ region, items }));
|
||||
});
|
||||
|
||||
// Flattened index for keyboard navigation.
|
||||
const flat = $derived<string[]>(groups.flatMap(g => g.items));
|
||||
|
||||
function openPicker() {
|
||||
open = true;
|
||||
query = '';
|
||||
highlightIdx = Math.max(0, flat.indexOf(value));
|
||||
requestAnimationFrame(() => {
|
||||
inputEl?.focus();
|
||||
scrollToHighlight();
|
||||
});
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
open = false;
|
||||
query = '';
|
||||
}
|
||||
|
||||
function selectTz(tz: string) {
|
||||
value = tz;
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { closePicker(); return; }
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.min(highlightIdx + 1, flat.length - 1);
|
||||
scrollToHighlight();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||
scrollToHighlight();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (flat[highlightIdx]) selectTz(flat[highlightIdx]);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToHighlight() {
|
||||
requestAnimationFrame(() => {
|
||||
panelEl?.querySelector('.tz-opt-hl')?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => { query; highlightIdx = 0; });
|
||||
|
||||
// Close on outside click
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (!open) return;
|
||||
const target = e.target as Node;
|
||||
if (panelEl && !panelEl.contains(target)) closePicker();
|
||||
}
|
||||
onMount(() => {
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
});
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="tz-root">
|
||||
<!-- Selected card -->
|
||||
<button
|
||||
type="button"
|
||||
class="tz-card"
|
||||
class:tz-card-open={open}
|
||||
onclick={() => (open ? closePicker() : openPicker())}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div class="tz-card-left">
|
||||
<div class="tz-region">{selected.region}</div>
|
||||
<div class="tz-city">{selected.city}</div>
|
||||
<div class="tz-sub">
|
||||
<span class="tz-iana">{selected.iana}</span>
|
||||
{#if selected.date}
|
||||
<span class="tz-dot">·</span>
|
||||
<span class="tz-date">{selected.date}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tz-card-right">
|
||||
<div class="tz-clock">{selected.time}</div>
|
||||
<div class="tz-offset">{selected.offset}</div>
|
||||
</div>
|
||||
<span class="tz-chev" aria-hidden="true">
|
||||
<MdiIcon name={open ? 'mdiChevronUp' : 'mdiChevronDown'} size={16} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="tz-panel" bind:this={panelEl} role="listbox">
|
||||
<!-- Search -->
|
||||
<div class="tz-search-row">
|
||||
<MdiIcon name="mdiMagnify" size={14} />
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={t('timezone.searchPlaceholder')}
|
||||
class="tz-search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
/>
|
||||
<kbd class="tz-kbd">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Quick picks -->
|
||||
{#if !query}
|
||||
<div class="tz-quick">
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === detectedTz}
|
||||
onclick={() => selectTz(detectedTz)}
|
||||
>
|
||||
<MdiIcon name="mdiCrosshairsGps" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.detect')}</span>
|
||||
<span class="tz-quick-val">{detectedTz}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
|
||||
onclick={() => selectTz('UTC')}
|
||||
>
|
||||
<MdiIcon name="mdiEarth" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.utc')}</span>
|
||||
<span class="tz-quick-val">UTC+00</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grouped list -->
|
||||
<div class="tz-list">
|
||||
{#if filtered.length === 0}
|
||||
<div class="tz-empty">{t('timezone.noMatches')}</div>
|
||||
{:else}
|
||||
{#each groups as g (g.region)}
|
||||
<div class="tz-group">
|
||||
<div class="tz-group-head">
|
||||
<span class="tz-group-name">{g.region}</span>
|
||||
<span class="tz-group-count">{g.items.length}</span>
|
||||
</div>
|
||||
{#each g.items as tz (tz)}
|
||||
{@const parts = splitTz(tz)}
|
||||
{@const idx = flat.indexOf(tz)}
|
||||
{@const hl = idx === highlightIdx}
|
||||
{@const sel = tz === value}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={sel}
|
||||
class="tz-opt"
|
||||
class:tz-opt-hl={hl}
|
||||
class:tz-opt-sel={sel}
|
||||
onmouseenter={() => (highlightIdx = idx)}
|
||||
onclick={() => selectTz(tz)}
|
||||
>
|
||||
<span class="tz-opt-city">{parts.city}</span>
|
||||
<span class="tz-opt-iana">{tz}</span>
|
||||
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tz-root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
/* ---- Selected card ------------------------------------------------ */
|
||||
.tz-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
|
||||
transparent 55%),
|
||||
var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.tz-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
||||
}
|
||||
.tz-card.tz-card-open {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.tz-card-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.tz-region {
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
|
||||
}
|
||||
.tz-city {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
.tz-iana {
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-dot { opacity: 0.5; }
|
||||
|
||||
.tz-card-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.tz-clock {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-foreground);
|
||||
line-height: 1;
|
||||
/* Stable width so seconds ticker doesn't shift layout */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tz-offset {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.1rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
.tz-chev {
|
||||
color: var(--color-muted-foreground);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---- Panel -------------------------------------------------------- */
|
||||
.tz-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
background: var(--color-card, var(--color-background));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 26rem;
|
||||
animation: tz-pop 0.15s ease-out;
|
||||
}
|
||||
@keyframes tz-pop {
|
||||
from { opacity: 0; transform: translateY(-3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.tz-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.tz-search {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.125rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.tz-kbd {
|
||||
font-size: 0.55rem;
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.2rem;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tz-quick {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tz-quick-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 9999px;
|
||||
background: var(--color-background);
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.tz-quick-btn:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tz-quick-active {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tz-quick-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.tz-quick-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tz-list {
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.tz-empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.tz-group {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.tz-group-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.75rem 0.25rem;
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted-foreground);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-card, var(--color-background));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
z-index: 1;
|
||||
}
|
||||
.tz-group-count {
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tz-opt {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.tz-opt.tz-opt-hl {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
.tz-opt.tz-opt-sel {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
}
|
||||
.tz-opt-city {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-opt.tz-opt-sel .tz-opt-city {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tz-opt-iana {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-opt-offset {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-muted-foreground);
|
||||
padding: 0.1rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-opt.tz-opt-hl .tz-opt-offset {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
||||
}
|
||||
</style>
|
||||
@@ -89,6 +89,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
|
||||
{ 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: '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) ---
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"activeTrackers": "Active Trackers",
|
||||
"targets": "Targets",
|
||||
"recentEvents": "Events",
|
||||
"clearEvents": "Clear",
|
||||
"confirmClearEvents": "Delete all event log entries? This cannot be undone.",
|
||||
"chart": "Event chart",
|
||||
"noEvents": "No events yet. Create a tracker to start monitoring.",
|
||||
"loading": "Loading...",
|
||||
@@ -76,6 +78,9 @@
|
||||
"collectionRenamed": "collection renamed",
|
||||
"collectionDeleted": "collection deleted",
|
||||
"sharingChanged": "sharing changed",
|
||||
"actionSuccess": "action run",
|
||||
"actionPartial": "action partial",
|
||||
"actionFailed": "action failed",
|
||||
"searchEvents": "Search events...",
|
||||
"allEvents": "All Events",
|
||||
"filterAssetsAdded": "Assets Added",
|
||||
@@ -83,6 +88,9 @@
|
||||
"filterRenamed": "Renamed",
|
||||
"filterDeleted": "Deleted",
|
||||
"filterSharingChanged": "Sharing Changed",
|
||||
"filterActionSuccess": "Action Success",
|
||||
"filterActionPartial": "Action Partial",
|
||||
"filterActionFailed": "Action Failed",
|
||||
"allProviders": "All Providers",
|
||||
"newestFirst": "Newest first",
|
||||
"oldestFirst": "Oldest first",
|
||||
@@ -365,6 +373,7 @@
|
||||
"roleAdmin": "Admin",
|
||||
"create": "Create User",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit user",
|
||||
"confirmDelete": "Delete this user?",
|
||||
"joined": "joined",
|
||||
"noUsers": "No users found"
|
||||
@@ -514,6 +523,9 @@
|
||||
"memorySource": "Memory source",
|
||||
"memorySourceAlbums": "Scan tracked albums",
|
||||
"memorySourceNative": "Immich native memories",
|
||||
"quietHours": "Quiet hours",
|
||||
"quietHoursStart": "Start",
|
||||
"quietHoursEnd": "End",
|
||||
"test": "Test",
|
||||
"confirmDelete": "Delete this tracking config?",
|
||||
"sortNone": "None",
|
||||
@@ -659,11 +671,29 @@
|
||||
"telegram": "Telegram",
|
||||
"webhookSecret": "Webhook Secret",
|
||||
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
||||
"cacheTtl": "Media Cache TTL (hours)",
|
||||
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
|
||||
"cacheTtl": "URL Cache TTL (hours)",
|
||||
"cacheTtlHint": "How long to keep URL-keyed Telegram file_ids (e.g. shared links). Set 0 to disable TTL. The asset cache uses content hashing (thumbhash) and ignores this.",
|
||||
"cacheMaxEntries": "Cache Max Entries",
|
||||
"cacheMaxEntriesHint": "Upper bound per cache (URL and asset). Oldest entries are evicted first (LRU). Default 5000.",
|
||||
"cacheStats": "Cache contents",
|
||||
"cacheStatsHint": "Size shown is the total bytes of media originally uploaded to Telegram for cached entries — i.e. approximate re-upload bandwidth the cache is saving. The cache file itself is only a few KB; the media lives on Telegram's servers.",
|
||||
"cacheStatsUrl": "URL cache",
|
||||
"cacheStatsAsset": "Asset cache",
|
||||
"cacheStatsEntries": "entries",
|
||||
"cacheStatsEmpty": "empty",
|
||||
"cacheStatsOldest": "oldest",
|
||||
"cacheStatsNewest": "newest",
|
||||
"clearCache": "Clear Media Cache",
|
||||
"clearCacheHint": "Delete cached Telegram file_ids. Next send will re-upload media.",
|
||||
"clearCacheConfirmTitle": "Clear Telegram cache?",
|
||||
"clearCacheConfirm": "This removes all cached Telegram file_ids. Subsequent notifications will re-upload media, which may take longer and use more bandwidth.",
|
||||
"clearCacheConfirmBtn": "Clear cache",
|
||||
"clearCacheDone": "Telegram cache cleared",
|
||||
"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",
|
||||
"supportedLocales": "Supported Locales",
|
||||
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
|
||||
"supportedLocalesHint": "Languages available when authoring notification and command templates. Built-in defaults ship for English and Russian; other languages start empty.",
|
||||
"saved": "Settings saved"
|
||||
},
|
||||
"hints": {
|
||||
@@ -671,6 +701,7 @@
|
||||
"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.",
|
||||
"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:00–07:00 are supported.",
|
||||
"favoritesOnly": "Only include assets marked as favorites.",
|
||||
"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.",
|
||||
@@ -785,13 +816,44 @@
|
||||
"disabled": "Disabled",
|
||||
"noListeners": "No listeners attached.",
|
||||
"selectBot": "Select bot...",
|
||||
"listenerType": "telegram_bot"
|
||||
"listenerType": "telegram_bot",
|
||||
"editScope": "Edit album scope",
|
||||
"scopeAll": "derived from notification routing",
|
||||
"albumsShort": "albums",
|
||||
"scopeTitle": "Album Scope Override for This Bot",
|
||||
"scopeDescription": "By default this bot's commands see only the albums that actually deliver notifications to the chats it speaks to (computed from your notification trackers). Set an explicit override here to widen or narrow that set for every chat this bot serves.",
|
||||
"scopeInherit": "Inherit: derive from notification routing",
|
||||
"noCollections": "No albums available."
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details"
|
||||
},
|
||||
"timezone": {
|
||||
"searchPlaceholder": "Search cities or IANA codes…",
|
||||
"detect": "Detect",
|
||||
"utc": "UTC",
|
||||
"noMatches": "No timezones match"
|
||||
},
|
||||
"locales": {
|
||||
"empty": "No languages selected. Add one below to start authoring templates.",
|
||||
"add": "Add language",
|
||||
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
|
||||
"addCustom": "Add custom code",
|
||||
"noSuggestions": "No matches. Type a valid locale code (2–3 letters).",
|
||||
"primary": "Primary",
|
||||
"shipped": "Built-in",
|
||||
"shippedHint": "Default notification & command templates ship for this language.",
|
||||
"makePrimary": "Make primary",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"remove": "Remove",
|
||||
"removeLast": "At least one language is required",
|
||||
"reorder": "Drag to reorder",
|
||||
"orderHint": "First language is the primary fallback when a translation is missing. Drag to reorder."
|
||||
},
|
||||
"snack": {
|
||||
"eventsCleared": "{count} event(s) cleared",
|
||||
"providerSaved": "Provider saved",
|
||||
"providerDeleted": "Provider deleted",
|
||||
"trackerCreated": "Tracker created",
|
||||
@@ -810,6 +872,7 @@
|
||||
"botDeleted": "Bot deleted",
|
||||
"userCreated": "User created",
|
||||
"userDeleted": "User deleted",
|
||||
"userUpdated": "User updated",
|
||||
"passwordChanged": "Password changed",
|
||||
"copied": "Copied to clipboard",
|
||||
"genericError": "Something went wrong",
|
||||
@@ -827,6 +890,7 @@
|
||||
"commandTrackerDisabled": "Command tracker disabled",
|
||||
"listenerAdded": "Listener added",
|
||||
"listenerRemoved": "Listener removed",
|
||||
"listenerScopeSaved": "Scope updated",
|
||||
"cmdTemplateSaved": "Command template saved",
|
||||
"cmdTemplateDeleted": "Command template deleted",
|
||||
"emailBotCreated": "Email bot created",
|
||||
@@ -848,6 +912,8 @@
|
||||
"description": "Description",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"cannotDelete": "Cannot delete",
|
||||
"blockedByIntro": "Referenced by:",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"none": "None",
|
||||
@@ -960,6 +1026,9 @@
|
||||
"renamed": "Album was renamed",
|
||||
"deleted": "Album was deleted",
|
||||
"sharingChanged": "Album sharing toggled",
|
||||
"actionSuccess": "Scheduled action completed",
|
||||
"actionPartial": "Scheduled action partially succeeded",
|
||||
"actionFailed": "Scheduled action failed",
|
||||
"newestFirst": "Most recent events on top",
|
||||
"oldestFirst": "Oldest events on top",
|
||||
"chatActionNone": "No indicator shown",
|
||||
@@ -1021,6 +1090,7 @@
|
||||
"name": "Name",
|
||||
"schedule": "Schedule",
|
||||
"interval": "Interval",
|
||||
"cronMode": "Cron expression",
|
||||
"seconds": "seconds",
|
||||
"cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)",
|
||||
"enabled": "Enabled",
|
||||
@@ -1126,6 +1196,18 @@
|
||||
"savedFiles": "Saved Backups",
|
||||
"noFiles": "No backup files yet.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,8 @@
|
||||
"activeTrackers": "Активные трекеры",
|
||||
"targets": "Получатели",
|
||||
"recentEvents": "События",
|
||||
"clearEvents": "Очистить",
|
||||
"confirmClearEvents": "Удалить все записи журнала событий? Это действие нельзя отменить.",
|
||||
"chart": "График событий",
|
||||
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
|
||||
"loading": "Загрузка...",
|
||||
@@ -76,6 +78,9 @@
|
||||
"collectionRenamed": "альбом переименован",
|
||||
"collectionDeleted": "альбом удалён",
|
||||
"sharingChanged": "изменение доступа",
|
||||
"actionSuccess": "действие выполнено",
|
||||
"actionPartial": "действие частично",
|
||||
"actionFailed": "действие провалено",
|
||||
"searchEvents": "Поиск событий...",
|
||||
"allEvents": "Все события",
|
||||
"filterAssetsAdded": "Добавление файлов",
|
||||
@@ -83,6 +88,9 @@
|
||||
"filterRenamed": "Переименование",
|
||||
"filterDeleted": "Удаление",
|
||||
"filterSharingChanged": "Изменение доступа",
|
||||
"filterActionSuccess": "Действие выполнено",
|
||||
"filterActionPartial": "Действие частично",
|
||||
"filterActionFailed": "Действие провалено",
|
||||
"allProviders": "Все провайдеры",
|
||||
"newestFirst": "Сначала новые",
|
||||
"oldestFirst": "Сначала старые",
|
||||
@@ -365,6 +373,7 @@
|
||||
"roleAdmin": "Администратор",
|
||||
"create": "Создать",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать пользователя",
|
||||
"confirmDelete": "Удалить этого пользователя?",
|
||||
"joined": "зарегистрирован",
|
||||
"noUsers": "Пользователи не найдены"
|
||||
@@ -514,6 +523,9 @@
|
||||
"memorySource": "Источник воспоминаний",
|
||||
"memorySourceAlbums": "Сканировать альбомы",
|
||||
"memorySourceNative": "Встроенные воспоминания Immich",
|
||||
"quietHours": "Тихие часы",
|
||||
"quietHoursStart": "Начало",
|
||||
"quietHoursEnd": "Конец",
|
||||
"test": "Тест",
|
||||
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
|
||||
"sortNone": "Нет",
|
||||
@@ -659,11 +671,29 @@
|
||||
"telegram": "Telegram",
|
||||
"webhookSecret": "Секрет вебхука",
|
||||
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
||||
"cacheTtl": "TTL кэша медиа (часы)",
|
||||
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
|
||||
"cacheTtl": "TTL URL-кэша (часы)",
|
||||
"cacheTtlHint": "Сколько хранить Telegram file_id, привязанные к URL (напр. публичные ссылки). 0 — отключить TTL. Кэш ассетов использует хэширование содержимого (thumbhash) и не зависит от этой настройки.",
|
||||
"cacheMaxEntries": "Макс. записей в кэше",
|
||||
"cacheMaxEntriesHint": "Верхний предел записей в каждом кэше (URL и ассеты). При превышении удаляются самые старые (LRU). По умолчанию 5000.",
|
||||
"cacheStats": "Содержимое кэша",
|
||||
"cacheStatsHint": "Показываемый размер — это суммарный объём медиа, который был изначально загружен в Telegram для закэшированных записей, т.е. приблизительный объём повторных загрузок, который экономит кэш. Сам файл кэша занимает лишь несколько КБ; медиа хранится на серверах Telegram.",
|
||||
"cacheStatsUrl": "Кэш URL",
|
||||
"cacheStatsAsset": "Кэш ассетов",
|
||||
"cacheStatsEntries": "записей",
|
||||
"cacheStatsEmpty": "пусто",
|
||||
"cacheStatsOldest": "самая старая",
|
||||
"cacheStatsNewest": "самая свежая",
|
||||
"clearCache": "Очистить кэш медиа",
|
||||
"clearCacheHint": "Удалить кэшированные Telegram file_id. При следующей отправке медиа будут загружены заново.",
|
||||
"clearCacheConfirmTitle": "Очистить кэш Telegram?",
|
||||
"clearCacheConfirm": "Это удалит все кэшированные Telegram file_id. Следующие уведомления будут повторно загружать медиа, что может занять больше времени и трафика.",
|
||||
"clearCacheConfirmBtn": "Очистить кэш",
|
||||
"clearCacheDone": "Кэш Telegram очищен",
|
||||
"timezone": "Часовой пояс",
|
||||
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
|
||||
"locales": "Языки шаблонов",
|
||||
"supportedLocales": "Поддерживаемые локали",
|
||||
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
|
||||
"supportedLocalesHint": "Языки, доступные для редактирования шаблонов уведомлений и команд. Встроенные шаблоны поставляются для английского и русского; другие языки начинают с пустых.",
|
||||
"saved": "Настройки сохранены"
|
||||
},
|
||||
"hints": {
|
||||
@@ -671,6 +701,7 @@
|
||||
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
|
||||
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
|
||||
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
|
||||
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:00–07:00.",
|
||||
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
|
||||
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
||||
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
|
||||
@@ -785,13 +816,44 @@
|
||||
"disabled": "Отключён",
|
||||
"noListeners": "Нет подключённых слушателей.",
|
||||
"selectBot": "Выберите бота...",
|
||||
"listenerType": "telegram_bot"
|
||||
"listenerType": "telegram_bot",
|
||||
"editScope": "Изменить область альбомов",
|
||||
"scopeAll": "из маршрутизации уведомлений",
|
||||
"albumsShort": "альбомов",
|
||||
"scopeTitle": "Переопределение области альбомов для этого бота",
|
||||
"scopeDescription": "По умолчанию команды этого бота видят только альбомы, уведомления которых приходят в его чаты (вычисляется из ваших трекеров уведомлений). Задайте явный список здесь, чтобы расширить или сузить этот набор для всех чатов данного бота.",
|
||||
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
||||
"noCollections": "Нет доступных альбомов."
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали"
|
||||
},
|
||||
"timezone": {
|
||||
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
|
||||
"detect": "Определить",
|
||||
"utc": "UTC",
|
||||
"noMatches": "Нет совпадений"
|
||||
},
|
||||
"locales": {
|
||||
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
||||
"add": "Добавить язык",
|
||||
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
||||
"addCustom": "Добавить свой код",
|
||||
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
|
||||
"primary": "Основной",
|
||||
"shipped": "Встроенный",
|
||||
"shippedHint": "Для этого языка есть встроенные шаблоны уведомлений и команд.",
|
||||
"makePrimary": "Сделать основным",
|
||||
"moveUp": "Выше",
|
||||
"moveDown": "Ниже",
|
||||
"remove": "Удалить",
|
||||
"removeLast": "Должен быть хотя бы один язык",
|
||||
"reorder": "Перетащите для изменения порядка",
|
||||
"orderHint": "Первый язык используется как основной при отсутствии перевода. Перетаскивайте, чтобы изменить порядок."
|
||||
},
|
||||
"snack": {
|
||||
"eventsCleared": "Очищено событий: {count}",
|
||||
"providerSaved": "Провайдер сохранён",
|
||||
"providerDeleted": "Провайдер удалён",
|
||||
"trackerCreated": "Трекер создан",
|
||||
@@ -810,6 +872,7 @@
|
||||
"botDeleted": "Бот удалён",
|
||||
"userCreated": "Пользователь создан",
|
||||
"userDeleted": "Пользователь удалён",
|
||||
"userUpdated": "Пользователь обновлён",
|
||||
"passwordChanged": "Пароль изменён",
|
||||
"copied": "Скопировано",
|
||||
"genericError": "Что-то пошло не так",
|
||||
@@ -827,6 +890,7 @@
|
||||
"commandTrackerDisabled": "Трекер команд отключён",
|
||||
"listenerAdded": "Слушатель добавлен",
|
||||
"listenerRemoved": "Слушатель удалён",
|
||||
"listenerScopeSaved": "Область обновлена",
|
||||
"cmdTemplateSaved": "Шаблон команд сохранён",
|
||||
"cmdTemplateDeleted": "Шаблон команд удалён",
|
||||
"emailBotCreated": "Email бот создан",
|
||||
@@ -848,6 +912,8 @@
|
||||
"description": "Описание",
|
||||
"close": "Закрыть",
|
||||
"confirm": "Подтвердить",
|
||||
"cannotDelete": "Невозможно удалить",
|
||||
"blockedByIntro": "На объект ссылаются:",
|
||||
"error": "Ошибка",
|
||||
"success": "Успешно",
|
||||
"none": "Нет",
|
||||
@@ -960,6 +1026,9 @@
|
||||
"renamed": "Альбом переименован",
|
||||
"deleted": "Альбом удалён",
|
||||
"sharingChanged": "Изменён доступ к альбому",
|
||||
"actionSuccess": "Запланированное действие выполнено",
|
||||
"actionPartial": "Запланированное действие выполнено частично",
|
||||
"actionFailed": "Запланированное действие провалено",
|
||||
"newestFirst": "Сначала новые события",
|
||||
"oldestFirst": "Сначала старые события",
|
||||
"chatActionNone": "Индикатор не показывается",
|
||||
@@ -1021,6 +1090,7 @@
|
||||
"name": "Название",
|
||||
"schedule": "Расписание",
|
||||
"interval": "Интервал",
|
||||
"cronMode": "Cron выражение",
|
||||
"seconds": "секунд",
|
||||
"cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)",
|
||||
"enabled": "Включено",
|
||||
@@ -1126,6 +1196,18 @@
|
||||
"savedFiles": "Сохранённые бэкапы",
|
||||
"noFiles": "Файлов бэкапа пока нет.",
|
||||
"download": "Скачать",
|
||||
"fileDeleted": "Файл бэкапа удалён"
|
||||
"fileDeleted": "Файл бэкапа удалён",
|
||||
"createManual": "Создать бэкап",
|
||||
"manualCreated": "Бэкап создан",
|
||||
"pendingTitle": "Восстановление ожидает — перезапустите для применения",
|
||||
"pendingBy": "Загружено пользователем {by}",
|
||||
"pendingAt": "в {at}",
|
||||
"pendingCancelled": "Ожидающее восстановление отменено",
|
||||
"restorePrepared": "Восстановление подготовлено",
|
||||
"restoreApplyPrompt": "Применить восстановление сейчас (бэкенд перезапустится) или позже при следующем штатном перезапуске?",
|
||||
"applyLater": "Применить позже",
|
||||
"restartNow": "Перезапустить сейчас",
|
||||
"restartingTitle": "Перезапуск бэкенда…",
|
||||
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,14 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
{ 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: {
|
||||
@@ -105,7 +113,10 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
|
||||
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 {
|
||||
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
|
||||
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 */ }
|
||||
}
|
||||
|
||||
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 };
|
||||
return { proceed: true };
|
||||
},
|
||||
|
||||
@@ -192,6 +192,9 @@ export interface TrackingConfig {
|
||||
memory_favorite_only: boolean;
|
||||
memory_asset_type: string;
|
||||
memory_min_rating: number;
|
||||
quiet_hours_enabled: boolean;
|
||||
quiet_hours_start: string | null;
|
||||
quiet_hours_end: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -226,24 +226,15 @@
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
]);
|
||||
|
||||
// "More" panel items — everything not in the bottom bar
|
||||
const mobileMoreItems = $derived<NavItem[]>([
|
||||
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
|
||||
{ href: '/bots?tab=telegram', key: 'nav.bots', icon: 'mdiRobot' },
|
||||
{ href: '/actions', key: 'nav.actions', icon: 'mdiPlayCircleOutline' },
|
||||
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog' },
|
||||
{ href: '/template-configs', key: 'nav.templates', icon: 'mdiFileDocumentEdit' },
|
||||
{ href: '/command-configs', key: 'nav.configs', icon: 'mdiConsoleLine' },
|
||||
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox' },
|
||||
...(auth.isAdmin ? [
|
||||
{ href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' },
|
||||
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
|
||||
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
|
||||
] : []),
|
||||
]);
|
||||
|
||||
// "More" panel mirrors the full desktop sidebar tree so every subnode is
|
||||
// reachable on mobile (previously it was a flat hand-picked list that
|
||||
// hid all target types, bot channels, and several nested pages).
|
||||
let mobileMoreOpen = $state(false);
|
||||
|
||||
function closeMobileMore() {
|
||||
mobileMoreOpen = false;
|
||||
}
|
||||
|
||||
const isAuthPage = $derived(
|
||||
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||
);
|
||||
@@ -384,7 +375,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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);">
|
||||
{#if collapsed}
|
||||
<button onclick={() => {
|
||||
@@ -538,7 +529,7 @@
|
||||
</aside>
|
||||
|
||||
<!-- Mobile bottom nav -->
|
||||
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
|
||||
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 60; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0 calc(0.375rem + env(safe-area-inset-bottom, 0px)); backdrop-filter: blur(12px);">
|
||||
{#each mobileNavItems as item}
|
||||
<a href={item.href} aria-label={t(item.key)}
|
||||
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
||||
@@ -558,40 +549,69 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile "More" panel -->
|
||||
<!-- Mobile "More" panel — mirrors the full desktop nav tree -->
|
||||
{#if mobileMoreOpen}
|
||||
<div class="mobile-more-backdrop" style="position: fixed; inset: 0; z-index: 49; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);"
|
||||
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;"
|
||||
onclick={closeMobileMore} role="presentation"></div>
|
||||
<div class="mobile-more-panel" style="position: fixed; bottom: calc(3rem + env(safe-area-inset-bottom, 0px)); 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: calc(70vh - env(safe-area-inset-bottom, 0px)); overflow-y: auto;"
|
||||
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);">
|
||||
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each mobileMoreItems as item}
|
||||
<a href={item.href}
|
||||
onclick={() => mobileMoreOpen = false}
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
|
||||
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||
>
|
||||
<MdiIcon name={item.icon} size={20} />
|
||||
<span class="text-xs text-center leading-tight">{t(item.key)}</span>
|
||||
</a>
|
||||
<div class="space-y-3">
|
||||
{#each navEntries as entry}
|
||||
{#if isGroup(entry)}
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
|
||||
style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name={entry.icon} size={13} />
|
||||
<span>{t(entry.key)}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each entry.children as child}
|
||||
<a href={child.href} onclick={closeMobileMore}
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200 relative"
|
||||
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||
>
|
||||
<MdiIcon name={child.icon} size={20} />
|
||||
<span class="text-xs text-center leading-tight">{t(child.key)}</span>
|
||||
{#if child.countKey && navCounts[child.countKey]}
|
||||
<span class="nav-badge-sm" style="position: absolute; top: 0.25rem; right: 0.25rem;">{navCounts[child.countKey]}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<a href={entry.href} onclick={closeMobileMore}
|
||||
class="flex items-center gap-2 p-3 rounded-lg transition-all duration-200 relative"
|
||||
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||
>
|
||||
<MdiIcon name={entry.icon} size={18} />
|
||||
<span class="text-sm flex-1">{t(entry.key)}</span>
|
||||
{#if entry.countKey && navCounts[entry.countKey]}
|
||||
<span class="nav-badge">{navCounts[entry.countKey]}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
<button onclick={() => { mobileMoreOpen = false; logout(); }}
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiLogout" size={20} />
|
||||
<span class="text-xs text-center leading-tight">{t('nav.logout')}</span>
|
||||
</button>
|
||||
<div class="pt-2" style="border-top: 1px solid var(--color-border);">
|
||||
<button onclick={() => { closeMobileMore(); logout(); }}
|
||||
class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiLogout" size={18} />
|
||||
<span class="text-sm">{t('nav.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-auto pb-16 md:pb-0">
|
||||
<main class="flex-1 overflow-auto md:pb-0"
|
||||
style="padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));">
|
||||
{#key page.url.pathname}
|
||||
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
|
||||
{@render children()}
|
||||
@@ -611,19 +631,22 @@
|
||||
<!-- Password change modal -->
|
||||
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
|
||||
<form onsubmit={changePassword} class="space-y-3">
|
||||
<input type="text" name="username" autocomplete="username" value={auth.user?.username ?? ''}
|
||||
readonly aria-hidden="true" tabindex="-1"
|
||||
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;" />
|
||||
<div>
|
||||
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
||||
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
|
||||
<input id="pwd-current" type="password" autocomplete="current-password" bind:value={pwdCurrent} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||
<input id="pwd-new" type="password" bind:value={pwdNew} required minlength="8"
|
||||
<input id="pwd-new" type="password" autocomplete="new-password" bind:value={pwdNew} required minlength="8"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label>
|
||||
<input id="pwd-confirm" type="password" bind:value={pwdConfirm} required minlength="8"
|
||||
<input id="pwd-confirm" type="password" autocomplete="new-password" bind:value={pwdConfirm} required minlength="8"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if pwdMsg}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { providersCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -11,6 +11,8 @@
|
||||
import EventChart from '$lib/components/EventChart.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.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 { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
@@ -56,6 +58,21 @@
|
||||
let eventsLimit = $state(loadEventsPerPage());
|
||||
let eventsOffset = $state(0);
|
||||
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 totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
|
||||
@@ -191,7 +208,7 @@
|
||||
] : []);
|
||||
|
||||
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);
|
||||
if (mins < 1) return t('dashboard.justNow');
|
||||
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
|
||||
@@ -206,15 +223,20 @@
|
||||
collection_renamed: 'dashboard.collectionRenamed',
|
||||
collection_deleted: 'dashboard.collectionDeleted',
|
||||
sharing_changed: 'dashboard.sharingChanged',
|
||||
action_success: 'dashboard.actionSuccess',
|
||||
action_partial: 'dashboard.actionPartial',
|
||||
action_failed: 'dashboard.actionFailed',
|
||||
};
|
||||
|
||||
const eventIcons: Record<string, string> = {
|
||||
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
|
||||
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
||||
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
||||
};
|
||||
const eventColors: Record<string, string> = {
|
||||
assets_added: '#059669', assets_removed: '#ef4444',
|
||||
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
||||
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
|
||||
};
|
||||
|
||||
</script>
|
||||
@@ -252,13 +274,23 @@
|
||||
</div>
|
||||
|
||||
<!-- Events section -->
|
||||
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||
<MdiIcon name="mdiPulse" size={18} />
|
||||
{t('dashboard.recentEvents')}
|
||||
<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} />
|
||||
{t('dashboard.recentEvents')}
|
||||
{#if status.total_events > 0}
|
||||
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
|
||||
{/if}
|
||||
</h3>
|
||||
{#if status.total_events > 0}
|
||||
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
|
||||
<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}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
@@ -370,6 +402,9 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
|
||||
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
|
||||
|
||||
<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: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: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); }
|
||||
.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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import type { ActionExecution } from '$lib/types';
|
||||
@@ -47,14 +47,14 @@
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '-';
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
return parseDate(iso).toLocaleString();
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function formatDuration(start: string, end: string | null): string {
|
||||
if (!end) return '-';
|
||||
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`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
} catch { return '-'; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 { emailBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -58,12 +59,17 @@
|
||||
finally { emailSubmitting = false; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function removeEmail(id: number) {
|
||||
confirmDeleteEmail = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
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; }
|
||||
}
|
||||
};
|
||||
@@ -173,3 +179,5 @@
|
||||
|
||||
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
|
||||
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 { matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -56,12 +57,17 @@
|
||||
finally { matrixSubmitting = false; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function removeMatrix(id: number) {
|
||||
confirmDeleteMatrix = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
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; }
|
||||
}
|
||||
};
|
||||
@@ -155,3 +161,5 @@
|
||||
|
||||
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
|
||||
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 { telegramBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -64,24 +65,30 @@
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
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; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toggleSection(botId: number, section: string) {
|
||||
async function toggleSection(botId: number, section: string) {
|
||||
if (expandedSection[botId] === section) {
|
||||
expandedSection = { ...expandedSection, [botId]: '' };
|
||||
return;
|
||||
}
|
||||
if (section === 'chats' && !chats[botId]) await loadChats(botId);
|
||||
else if (section === 'listeners' && !botListenerStatus[botId]) await loadListenerStatus(botId);
|
||||
expandedSection = { ...expandedSection, [botId]: section };
|
||||
if (section === 'chats') loadChats(botId);
|
||||
}
|
||||
|
||||
async function loadChats(botId: number) {
|
||||
@@ -338,12 +345,14 @@
|
||||
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
|
||||
<button onclick={() => toggleSection(bot.id, 'chats')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap">
|
||||
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
||||
disabled={chatsLoading[bot.id]}
|
||||
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 onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap">
|
||||
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
|
||||
<button onclick={() => toggleSection(bot.id, 'listeners')}
|
||||
disabled={botListenerLoading[bot.id]}
|
||||
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>
|
||||
<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" />
|
||||
@@ -518,3 +527,5 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -92,7 +93,10 @@
|
||||
|
||||
function openNew() {
|
||||
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);
|
||||
if (match) form.command_template_config_id = match.id;
|
||||
editing = null;
|
||||
@@ -137,6 +141,7 @@
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(cfg: CommandConfig) {
|
||||
confirmDelete = {
|
||||
id: cfg.id,
|
||||
@@ -145,7 +150,11 @@
|
||||
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
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; }
|
||||
}
|
||||
};
|
||||
@@ -296,3 +305,5 @@
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
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 { sanitizePreview } from '$lib/sanitize';
|
||||
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||
@@ -189,6 +190,8 @@
|
||||
|
||||
function openNew() {
|
||||
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;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
@@ -265,6 +268,7 @@
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
@@ -274,6 +278,8 @@
|
||||
await load();
|
||||
snackSuccess(t('snack.cmdTemplateDeleted'));
|
||||
} catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
@@ -458,6 +464,8 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<!-- Variables reference modal -->
|
||||
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
|
||||
{#if showVarsFor && varsRef[showVarsFor]}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import CrossLink from '$lib/components/CrossLink.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
@@ -83,7 +84,17 @@
|
||||
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) {
|
||||
form = {
|
||||
name: trk.name,
|
||||
@@ -178,6 +189,35 @@
|
||||
} 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 {
|
||||
return providers.find(p => p.id === id)?.name || '?';
|
||||
}
|
||||
@@ -289,10 +329,18 @@
|
||||
<div class="space-y-1">
|
||||
{#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 gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<MdiIcon name="mdiRobot" size={14} />
|
||||
<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>
|
||||
<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>
|
||||
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
|
||||
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
|
||||
@@ -321,3 +369,59 @@
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
|
||||
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">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -136,10 +136,29 @@
|
||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||
_prevProviderId = form.provider_id;
|
||||
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) {
|
||||
form = {
|
||||
@@ -256,7 +275,7 @@
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const d = parseDate(dateStr);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch (e) { console.warn('Date format error:', e); return ''; }
|
||||
}
|
||||
@@ -339,8 +358,19 @@
|
||||
if (ttTesting[key]) return;
|
||||
ttTesting = { ...ttTesting, [key]: testType };
|
||||
try {
|
||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
// 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'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { providersCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -11,6 +11,7 @@
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
@@ -131,12 +132,17 @@
|
||||
}
|
||||
|
||||
function startDelete(provider: any) { confirmDelete = provider; }
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const id = confirmDelete.id;
|
||||
confirmDelete = null;
|
||||
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>
|
||||
|
||||
@@ -280,6 +286,8 @@
|
||||
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
|
||||
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<style>
|
||||
.health-dot {
|
||||
width: 10px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import type { WebhookPayloadLog } from '$lib/types';
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
return parseDate(iso).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,25 +9,66 @@
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
|
||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
interface CacheBucketStats {
|
||||
count: number;
|
||||
total_size_bytes: number;
|
||||
oldest: string | null;
|
||||
newest: string | null;
|
||||
}
|
||||
interface CacheStats {
|
||||
url: CacheBucketStats;
|
||||
asset: CacheBucketStats;
|
||||
}
|
||||
|
||||
let loaded = $state(false);
|
||||
let saving = $state(false);
|
||||
let clearingCache = $state(false);
|
||||
let confirmClearCache = $state(false);
|
||||
let error = $state('');
|
||||
let settings = $state({
|
||||
external_url: '',
|
||||
telegram_webhook_secret: '',
|
||||
telegram_cache_ttl_hours: '48',
|
||||
telegram_cache_ttl_hours: '720',
|
||||
telegram_asset_cache_max_entries: '5000',
|
||||
supported_locales: 'en,ru',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
let cacheStats = $state<CacheStats | null>(null);
|
||||
|
||||
async function loadCacheStats() {
|
||||
try {
|
||||
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
|
||||
} catch { cacheStats = null; }
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
settings = await api('/settings');
|
||||
await loadCacheStats();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { loaded = true; }
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let v = bytes;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatTs(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true; error = '';
|
||||
try {
|
||||
@@ -36,6 +77,17 @@
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
saving = false;
|
||||
}
|
||||
|
||||
async function clearTelegramCache() {
|
||||
confirmClearCache = false;
|
||||
clearingCache = true;
|
||||
try {
|
||||
await api('/settings/telegram-cache/clear', { method: 'POST' });
|
||||
snackSuccess(t('settings.clearCacheDone'));
|
||||
await loadCacheStats();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
clearingCache = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('settings.title')} description={t('settings.description')} />
|
||||
@@ -57,6 +109,10 @@
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
||||
<TimezoneSelector bind:value={settings.timezone} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -69,14 +125,68 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
||||
<input bind:value={settings.telegram_webhook_secret} type="password" placeholder={t('providers.optional')}
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
||||
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
|
||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="1" max="720"
|
||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
|
||||
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
|
||||
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
|
||||
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
|
||||
{#each [
|
||||
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
|
||||
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
|
||||
] as bucket}
|
||||
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-medium">{bucket.label}</span>
|
||||
{#if bucket.data && bucket.data.count > 0}
|
||||
<span>
|
||||
<span class="font-mono">{bucket.data.count}</span>
|
||||
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
|
||||
{#if bucket.data.total_size_bytes > 0}
|
||||
<span style="color: var(--color-muted-foreground);"> · </span>
|
||||
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
|
||||
{#if bucket.data.oldest}
|
||||
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
|
||||
{/if}
|
||||
{#if bucket.data.newest}
|
||||
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
<MdiIcon name="mdiDeleteSweep" size={16} />
|
||||
{clearingCache ? t('common.loading') : t('settings.clearCache')}
|
||||
</button>
|
||||
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -88,9 +198,8 @@
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
||||
<input bind:value={settings.supported_locales} placeholder="en,ru,de,fr"
|
||||
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" />
|
||||
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
||||
<LocaleSelector bind:value={settings.supported_locales} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -99,4 +208,12 @@
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmModal open={confirmClearCache}
|
||||
title={t('settings.clearCacheConfirmTitle')}
|
||||
message={t('settings.clearCacheConfirm')}
|
||||
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
||||
confirmIcon="mdiDeleteSweep"
|
||||
onconfirm={clearTelegramCache}
|
||||
oncancel={() => confirmClearCache = false} />
|
||||
{/if}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api, fetchAuth } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
@@ -60,15 +59,23 @@
|
||||
let backupFiles = $state<any[]>([]);
|
||||
let loadingFiles = $state(false);
|
||||
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 () => {
|
||||
try {
|
||||
const [settings, files] = await Promise.all([
|
||||
const [settings, files, p] = await Promise.all([
|
||||
api('/backup/scheduled'),
|
||||
api('/backup/files'),
|
||||
api('/backup/pending-restore'),
|
||||
]);
|
||||
scheduledSettings = settings;
|
||||
backupFiles = files;
|
||||
pending = p;
|
||||
} catch (err: any) {
|
||||
error = 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 ---
|
||||
async function doExport() {
|
||||
if (exportSecrets === 'include') {
|
||||
@@ -120,16 +174,7 @@
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', importFile);
|
||||
const token = localStorage.getItem('access_token');
|
||||
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}`);
|
||||
}
|
||||
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
|
||||
validationResult = await res.json();
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
@@ -151,18 +196,15 @@
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', importFile);
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await fetch(`/api/backup/import?conflict_mode=${importConflict}`, {
|
||||
const res = await fetchAuth(`/backup/prepare-restore?conflict_mode=${importConflict}`, {
|
||||
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}`);
|
||||
}
|
||||
importResult = await res.json();
|
||||
snackSuccess(t('backup.importSuccess'));
|
||||
pending = importResult;
|
||||
snackSuccess(t('backup.restorePrepared'));
|
||||
postRestoreModalOpen = true;
|
||||
importFile = null;
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
@@ -256,6 +298,33 @@
|
||||
<Loading />
|
||||
{:else}
|
||||
<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">
|
||||
|
||||
<!-- Export Section -->
|
||||
@@ -502,9 +571,14 @@
|
||||
<MdiIcon name="mdiFolder" size={18} />
|
||||
{t('backup.savedFiles')}
|
||||
</h3>
|
||||
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
|
||||
<MdiIcon name="mdiRefresh" size={14} />
|
||||
</button>
|
||||
<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}>
|
||||
<MdiIcon name="mdiRefresh" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if backupFiles.length === 0}
|
||||
@@ -568,3 +642,69 @@
|
||||
onconfirm={() => deleteFile(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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
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 { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -114,7 +115,7 @@
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', bot_id: 0, bot_token: '',
|
||||
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
|
||||
username: '',
|
||||
// ntfy shared settings
|
||||
@@ -193,6 +194,10 @@
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
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;
|
||||
showTelegramSettings = false;
|
||||
showForm = true;
|
||||
@@ -289,12 +294,15 @@
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
async function remove(id: number) {
|
||||
try {
|
||||
await api(`/targets/${id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.targetDeleted'));
|
||||
} catch (err: any) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
}
|
||||
@@ -529,3 +537,5 @@
|
||||
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
|
||||
oncancel={() => confirmDeleteReceiver = null}
|
||||
/>
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
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 { sanitizePreview } from '$lib/sanitize';
|
||||
import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||
@@ -208,7 +209,12 @@
|
||||
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) {
|
||||
form = {
|
||||
provider_type: c.provider_type,
|
||||
@@ -253,12 +259,17 @@
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
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; }
|
||||
}
|
||||
};
|
||||
@@ -455,6 +466,8 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<!-- 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}>
|
||||
{#if showVarsFor && varsRef[showVarsFor]}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
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 { trackingConfigsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -72,12 +73,17 @@
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
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; }
|
||||
}
|
||||
};
|
||||
@@ -175,9 +181,12 @@
|
||||
{:else if field.type === 'grid-select' && field.gridItems}
|
||||
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
||||
{: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}
|
||||
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)]" />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -257,6 +266,8 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<style>
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getAuth } from '$lib/auth.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -31,6 +31,13 @@
|
||||
let resetMsg = $state('');
|
||||
let resetSuccess = $state(false);
|
||||
|
||||
// Admin edit username/role
|
||||
let editUserId = $state<number | null>(null);
|
||||
let editUsername = $state('');
|
||||
let editRole = $state('user');
|
||||
let editMsg = $state('');
|
||||
let editSuccess = $state(false);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { users = await api('/users'); }
|
||||
@@ -56,6 +63,20 @@
|
||||
function openResetPassword(user: any) {
|
||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
||||
}
|
||||
function openEditUser(user: any) {
|
||||
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
|
||||
}
|
||||
async function saveUserEdit(e: SubmitEvent) {
|
||||
e.preventDefault(); editMsg = ''; editSuccess = false;
|
||||
try {
|
||||
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
||||
editMsg = t('snack.userUpdated');
|
||||
editSuccess = true;
|
||||
snackSuccess(editMsg);
|
||||
await load();
|
||||
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
|
||||
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
|
||||
}
|
||||
async function resetUserPassword(e: SubmitEvent) {
|
||||
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
||||
try {
|
||||
@@ -111,9 +132,10 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<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 class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
||||
{#if user.id !== auth.user?.id}
|
||||
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
||||
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
||||
@@ -144,5 +166,28 @@
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Admin edit username/role modal -->
|
||||
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
|
||||
<form onsubmit={saveUserEdit} class="space-y-3">
|
||||
<div>
|
||||
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||
<input id="edit-username" bind:value={editUsername} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
||||
<select id="edit-role" bind:value={editRole}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if editMsg}
|
||||
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
|
||||
{/if}
|
||||
<Button type="submit" class="w-full">{t('common.save')}</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -16,6 +16,13 @@ from .ssrf import UnsafeURLError, validate_outbound_url
|
||||
|
||||
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||
|
||||
# Cap on how many asset downloads run concurrently inside
|
||||
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
|
||||
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
|
||||
# max_asset_size``, which matters on small-RAM Docker hosts when a batch
|
||||
# contains many large videos.
|
||||
_PRELOAD_CONCURRENCY = 6
|
||||
|
||||
|
||||
def _new_session() -> aiohttp.ClientSession:
|
||||
"""Per-dispatch aiohttp session with a sane default timeout.
|
||||
@@ -38,6 +45,11 @@ from .receiver import (
|
||||
)
|
||||
from .telegram.cache import TelegramFileCache
|
||||
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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -146,6 +158,90 @@ class NotificationDispatcher:
|
||||
return await send_method(target, default_message, event)
|
||||
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(
|
||||
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
||||
) -> dict[str, Any]:
|
||||
@@ -172,6 +268,7 @@ class NotificationDispatcher:
|
||||
external_url = (target.provider_external_url or "").rstrip("/")
|
||||
provider_urls = [u for u in (internal_url, external_url) if u]
|
||||
assets = []
|
||||
media_assets: list[Any] = [] # aligned with `assets` for preload
|
||||
for asset in event.added_assets[:max_media]:
|
||||
url = asset.preview_url or asset.thumbnail_url or asset.full_url
|
||||
if url:
|
||||
@@ -187,13 +284,34 @@ class NotificationDispatcher:
|
||||
if asset.extra.get("cache_key"):
|
||||
asset_entry["cache_key"] = asset.extra["cache_key"]
|
||||
assets.append(asset_entry)
|
||||
media_assets.append(asset)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
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)
|
||||
|
||||
# Asset cache (when in thumbhash mode) invalidates entries when the
|
||||
# asset's visual content changes. The resolver maps asset id → its
|
||||
# current thumbhash. Providers that expose thumbhash put it in
|
||||
# ``asset.extra["thumbhash"]`` (currently Immich).
|
||||
thumbhash_map = {
|
||||
asset.id: asset.extra.get("thumbhash")
|
||||
for asset in event.added_assets
|
||||
if asset.extra.get("thumbhash")
|
||||
}
|
||||
thumbhash_resolver = (
|
||||
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
|
||||
)
|
||||
|
||||
client = TelegramClient(
|
||||
session, bot_token,
|
||||
url_cache=self._url_cache,
|
||||
asset_cache=self._asset_cache,
|
||||
thumbhash_resolver=thumbhash_resolver,
|
||||
)
|
||||
|
||||
for receiver in target.receivers:
|
||||
|
||||
@@ -11,56 +11,69 @@ from notify_bridge_core.storage import StorageBackend
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
|
||||
DEFAULT_MAX_ENTRIES = 5000
|
||||
|
||||
|
||||
class TelegramFileCache:
|
||||
"""Cache for Telegram file_ids to avoid re-uploading media.
|
||||
|
||||
Supports two validation modes:
|
||||
- TTL mode (default): entries expire after a configured time-to-live
|
||||
- Thumbhash mode: entries validated by comparing stored thumbhash with current
|
||||
"""
|
||||
Two complementary invalidation strategies, usable together or separately:
|
||||
- TTL: entries expire after ``ttl_seconds``. Set to 0 to disable TTL
|
||||
(cache essentially forever, subject only to the size cap).
|
||||
- Thumbhash mode: entries are validated on read by comparing the stored
|
||||
thumbhash with the one the caller supplies; a mismatch drops the entry.
|
||||
Intended for content-addressable assets (e.g. Immich) where re-uploads
|
||||
should be triggered by visual change, not elapsed time.
|
||||
|
||||
THUMBHASH_MAX_ENTRIES = 2000
|
||||
``max_entries`` always applies as an LRU size cap (by ``cached_at``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: StorageBackend,
|
||||
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
|
||||
use_thumbhash: bool = False,
|
||||
max_entries: int = DEFAULT_MAX_ENTRIES,
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._data: dict[str, Any] | None = None
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._use_thumbhash = use_thumbhash
|
||||
self._max_entries = max_entries
|
||||
|
||||
async def async_load(self) -> None:
|
||||
self._data = await self._backend.load() or {"files": {}}
|
||||
await self._cleanup_expired()
|
||||
|
||||
async def _cleanup_expired(self) -> None:
|
||||
if self._use_thumbhash:
|
||||
files = self._data.get("files", {}) if self._data else {}
|
||||
if len(files) > self.THUMBHASH_MAX_ENTRIES:
|
||||
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
|
||||
for key in sorted_keys[: len(files) - self.THUMBHASH_MAX_ENTRIES]:
|
||||
del files[key]
|
||||
await self._backend.save(self._data)
|
||||
return
|
||||
|
||||
if not self._data or "files" not in self._data:
|
||||
return
|
||||
files = self._data["files"]
|
||||
changed = False
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [
|
||||
url for url, entry in self._data["files"].items()
|
||||
if entry.get("cached_at") and
|
||||
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
|
||||
]
|
||||
|
||||
if expired:
|
||||
# TTL sweep — only when TTL validation is active (i.e. no thumbhash
|
||||
# mode and a positive TTL). In thumbhash mode we rely entirely on
|
||||
# content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
|
||||
# cache forever, subject only to the size cap.
|
||||
if not self._use_thumbhash and self._ttl_seconds > 0:
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [
|
||||
url for url, entry in files.items()
|
||||
if entry.get("cached_at") and
|
||||
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
|
||||
]
|
||||
for key in expired:
|
||||
del self._data["files"][key]
|
||||
del files[key]
|
||||
changed = True
|
||||
|
||||
# LRU cap — always enforced. Evicts oldest-cached entries first.
|
||||
if self._max_entries > 0 and len(files) > self._max_entries:
|
||||
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
|
||||
for key in sorted_keys[: len(files) - self._max_entries]:
|
||||
del files[key]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await self._backend.save(self._data)
|
||||
|
||||
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
|
||||
@@ -77,17 +90,26 @@ class TelegramFileCache:
|
||||
if stored and stored != thumbhash:
|
||||
del self._data["files"][key]
|
||||
return None
|
||||
else:
|
||||
elif self._ttl_seconds > 0:
|
||||
cached_at_str = entry.get("cached_at")
|
||||
if cached_at_str:
|
||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
|
||||
if age > self._ttl_seconds:
|
||||
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(
|
||||
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:
|
||||
if self._data is None:
|
||||
self._data = {"files": {}}
|
||||
@@ -99,20 +121,34 @@ class TelegramFileCache:
|
||||
}
|
||||
if thumbhash is not None:
|
||||
entry["thumbhash"] = thumbhash
|
||||
if size is not None:
|
||||
entry["size"] = size
|
||||
|
||||
self._data["files"][key] = entry
|
||||
await self._backend.save(self._data)
|
||||
|
||||
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:
|
||||
"""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:
|
||||
return
|
||||
if self._data is None:
|
||||
self._data = {"files": {}}
|
||||
|
||||
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] = {
|
||||
"file_id": file_id,
|
||||
"type": media_type,
|
||||
@@ -120,6 +156,8 @@ class TelegramFileCache:
|
||||
}
|
||||
if thumbhash is not None:
|
||||
entry["thumbhash"] = thumbhash
|
||||
if size is not None:
|
||||
entry["size"] = size
|
||||
self._data["files"][key] = entry
|
||||
|
||||
await self._backend.save(self._data)
|
||||
@@ -127,3 +165,32 @@ class TelegramFileCache:
|
||||
async def async_remove(self) -> None:
|
||||
await self._backend.remove()
|
||||
self._data = None
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Return summary stats about the current cache contents.
|
||||
|
||||
Includes the number of cached entries, total tracked size in bytes
|
||||
(only counts entries with a recorded ``size``), and the oldest /
|
||||
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
|
||||
"""
|
||||
files = self._data.get("files", {}) if self._data else {}
|
||||
count = len(files)
|
||||
total_size = 0
|
||||
oldest: str | None = None
|
||||
newest: str | None = None
|
||||
for entry in files.values():
|
||||
size = entry.get("size")
|
||||
if isinstance(size, int):
|
||||
total_size += size
|
||||
cached_at = entry.get("cached_at")
|
||||
if cached_at:
|
||||
if oldest is None or cached_at < oldest:
|
||||
oldest = cached_at
|
||||
if newest is None or cached_at > newest:
|
||||
newest = cached_at
|
||||
return {
|
||||
"count": count,
|
||||
"total_size_bytes": total_size,
|
||||
"oldest": oldest,
|
||||
"newest": newest,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
import aiohttp
|
||||
@@ -29,6 +30,36 @@ _LOGGER = logging.getLogger(__name__)
|
||||
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:
|
||||
"""Async Telegram Bot API client for sending notifications with media."""
|
||||
|
||||
@@ -76,6 +107,94 @@ class TelegramClient:
|
||||
is_asset = is_asset_cache_key(key)
|
||||
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(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -107,6 +226,7 @@ class TelegramClient:
|
||||
parse_mode, max_asset_data_size, send_large_photos_as_documents,
|
||||
assets[0].get("content_type"), assets[0].get("cache_key"),
|
||||
download_headers=assets[0].get("headers"),
|
||||
preloaded_data=assets[0].get("data"),
|
||||
)
|
||||
if len(assets) == 1 and assets[0].get("type") == "video":
|
||||
return await self._send_video(
|
||||
@@ -114,28 +234,31 @@ class TelegramClient:
|
||||
parse_mode, max_asset_data_size,
|
||||
assets[0].get("content_type"), assets[0].get("cache_key"),
|
||||
download_headers=assets[0].get("headers"),
|
||||
preloaded_data=assets[0].get("data"),
|
||||
)
|
||||
if len(assets) == 1 and assets[0].get("type", "document") == "document":
|
||||
url = assets[0].get("url")
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for document"}
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
dl_headers = assets[0].get("headers") or {}
|
||||
async with self._session.get(download_url, headers=dl_headers) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||
return {"success": False, "error": f"Media size exceeds limit"}
|
||||
filename = url.split("/")[-1].split("?")[0] or "file"
|
||||
return await self._send_document(
|
||||
chat_id, data, filename, caption, reply_to_message_id,
|
||||
parse_mode, url, assets[0].get("content_type"),
|
||||
assets[0].get("cache_key"),
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": f"Failed to download media: {err}"}
|
||||
data = assets[0].get("data")
|
||||
if data is None:
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
dl_headers = assets[0].get("headers") or {}
|
||||
async with self._session.get(download_url, headers=dl_headers) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
|
||||
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:
|
||||
return {"success": False, "error": f"Media size exceeds limit"}
|
||||
filename = url.split("/")[-1].split("?")[0] or "file"
|
||||
return await self._send_document(
|
||||
chat_id, data, filename, caption, reply_to_message_id,
|
||||
parse_mode, url, assets[0].get("content_type"),
|
||||
assets[0].get("cache_key"),
|
||||
)
|
||||
|
||||
return await self._send_media_group(
|
||||
chat_id, assets, caption, reply_to_message_id, max_group_size,
|
||||
@@ -177,6 +300,16 @@ class TelegramClient:
|
||||
# Retry without parse_mode on parse errors
|
||||
desc = str(result.get("description", ""))
|
||||
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)
|
||||
async with self._session.post(telegram_url, json=payload) as retry_resp:
|
||||
retry_result = await retry_resp.json()
|
||||
@@ -211,133 +344,85 @@ class TelegramClient:
|
||||
max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False,
|
||||
content_type: str | None = None, cache_key: str | None = None,
|
||||
download_headers: dict[str, str] | None = None,
|
||||
preloaded_data: bytes | None = None,
|
||||
) -> NotificationResult:
|
||||
if not content_type:
|
||||
content_type = "image/jpeg"
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for photo"}
|
||||
|
||||
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key)
|
||||
|
||||
# Check cache
|
||||
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
|
||||
cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
|
||||
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
|
||||
if cached and cached.get("file_id"):
|
||||
payload = {"chat_id": chat_id, "photo": cached["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}/sendPhoto"
|
||||
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
|
||||
cached_result = await self._send_from_cache(
|
||||
_PHOTO_KIND, chat_id, cached["file_id"],
|
||||
caption, reply_to_message_id, parse_mode,
|
||||
)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
async with self._session.get(download_url, headers=download_headers or {}) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
|
||||
if data is None:
|
||||
return {"success": False, "error": f"Failed to download photo: {err}"}
|
||||
|
||||
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}
|
||||
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}
|
||||
|
||||
exceeds_limits, reason, _, _ = check_photo_limits(data)
|
||||
if exceeds_limits:
|
||||
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 {"success": False, "error": f"Photo {reason}", "skipped": True}
|
||||
exceeds_limits, reason, _, _ = check_photo_limits(data)
|
||||
if exceeds_limits:
|
||||
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 {"success": False, "error": f"Photo {reason}", "skipped": True}
|
||||
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
form.add_field("photo", data, filename="photo.jpg", 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}/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)}
|
||||
return await self._upload_media(
|
||||
_PHOTO_KIND, chat_id, data,
|
||||
_PHOTO_KIND.default_filename,
|
||||
content_type or _PHOTO_KIND.default_content_type,
|
||||
caption, reply_to_message_id, parse_mode,
|
||||
cache, key, thumbhash,
|
||||
)
|
||||
|
||||
async def _send_video(
|
||||
self, chat_id: str, url: str | None, caption: str | None = None,
|
||||
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
|
||||
max_asset_data_size: int | None = None, content_type: str | None = None,
|
||||
cache_key: str | None = None, download_headers: dict[str, str] | None = None,
|
||||
preloaded_data: bytes | None = None,
|
||||
) -> NotificationResult:
|
||||
if not content_type:
|
||||
content_type = "video/mp4"
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for video"}
|
||||
|
||||
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key)
|
||||
|
||||
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
|
||||
cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
|
||||
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
|
||||
if cached and cached.get("file_id"):
|
||||
payload = {"chat_id": chat_id, "video": cached["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}/sendVideo"
|
||||
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
|
||||
cached_result = await self._send_from_cache(
|
||||
_VIDEO_KIND, chat_id, cached["file_id"],
|
||||
caption, reply_to_message_id, parse_mode,
|
||||
)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
async with self._session.get(download_url, headers=download_headers or {}) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
|
||||
if data is None:
|
||||
return {"success": False, "error": f"Failed to download video: {err}"}
|
||||
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
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,
|
||||
}
|
||||
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
form.add_field("video", data, filename="video.mp4", 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}/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)}
|
||||
return await self._upload_media(
|
||||
_VIDEO_KIND, chat_id, data,
|
||||
_VIDEO_KIND.default_filename,
|
||||
content_type or _VIDEO_KIND.default_content_type,
|
||||
caption, reply_to_message_id, parse_mode,
|
||||
cache, key, thumbhash,
|
||||
)
|
||||
|
||||
async def _send_document(
|
||||
self, chat_id: str, data: bytes, filename: str = "file",
|
||||
@@ -348,50 +433,24 @@ class TelegramClient:
|
||||
if not content_type:
|
||||
content_type, _ = mimetypes.guess_type(filename)
|
||||
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:
|
||||
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash)
|
||||
if cached and cached.get("file_id") and cached.get("type") == "document":
|
||||
payload = {"chat_id": chat_id, "document": cached["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}/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)}
|
||||
return await self._upload_media(
|
||||
_DOCUMENT_KIND, chat_id, data, filename, content_type,
|
||||
caption, reply_to_message_id, parse_mode,
|
||||
cache, key, thumbhash,
|
||||
)
|
||||
|
||||
async def _send_media_group(
|
||||
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_reply = reply_to_message_id if chunk_idx == 0 else None
|
||||
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":
|
||||
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:
|
||||
continue
|
||||
if not result.get("success"):
|
||||
@@ -433,7 +492,8 @@ class TelegramClient:
|
||||
# Track cache info per media_json entry (in order) so we can map
|
||||
# Telegram response items back to cache keys for newly uploaded items.
|
||||
# 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
|
||||
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"):
|
||||
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:
|
||||
download_url = self._resolve_url(url)
|
||||
dl_headers = item.get("headers") or {}
|
||||
@@ -500,7 +574,7 @@ class TelegramClient:
|
||||
ck_is_asset = is_asset_cache_key(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
|
||||
media_cache_info.append((ck, media_type, th))
|
||||
media_cache_info.append((ck, media_type, th, len(data)))
|
||||
else:
|
||||
continue
|
||||
|
||||
@@ -523,14 +597,14 @@ class TelegramClient:
|
||||
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
|
||||
|
||||
# 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):
|
||||
if i >= len(media_cache_info):
|
||||
break
|
||||
info = media_cache_info[i]
|
||||
if info is None:
|
||||
continue # was a cache hit, skip
|
||||
ck, mt, th = info
|
||||
ck, mt, th, sz = info
|
||||
file_id = None
|
||||
if msg.get("photo"):
|
||||
file_id = msg["photo"][-1].get("file_id")
|
||||
@@ -539,7 +613,7 @@ class TelegramClient:
|
||||
elif msg.get("document"):
|
||||
file_id = msg["document"].get("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:
|
||||
# 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]))
|
||||
@@ -568,6 +642,18 @@ class TelegramClient:
|
||||
except aiohttp.ClientError as 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]:
|
||||
"""Call getWebhookInfo to check current webhook status."""
|
||||
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getWebhookInfo"
|
||||
|
||||
@@ -132,8 +132,10 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
target_album_ids = [single]
|
||||
|
||||
try:
|
||||
# Step 1: Gather candidate assets from criteria
|
||||
candidate_ids = await self._gather_candidates(criteria)
|
||||
# Step 1: Gather candidate assets from criteria. Asset type is
|
||||
# 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:
|
||||
return RuleResult(
|
||||
@@ -146,6 +148,7 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
)
|
||||
|
||||
# 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 dry_run:
|
||||
_LOGGER.info("[DRY RUN] Would create album '%s'", create_album_name)
|
||||
@@ -153,6 +156,8 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
else:
|
||||
created = await self._client.create_album(create_album_name)
|
||||
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])
|
||||
|
||||
if not target_album_ids:
|
||||
@@ -169,6 +174,7 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
|
||||
for album_id in target_album_ids:
|
||||
album_asset_ids: set[str] = set()
|
||||
needs_thumbnail = album_id in album_created_now
|
||||
|
||||
if album_id and album_id != "__dry_run_new__":
|
||||
album = await self._client.get_album(album_id)
|
||||
@@ -176,27 +182,56 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
if not dry_run:
|
||||
created = await self._client.create_album(create_album_name)
|
||||
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)
|
||||
elif album is None:
|
||||
album_details.append({"album_id": album_id, "error": "not found"})
|
||||
continue
|
||||
elif album is not None:
|
||||
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]
|
||||
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:
|
||||
for i in range(0, len(new_asset_ids), 500):
|
||||
batch = new_asset_ids[i : i + 500]
|
||||
await self._client.add_assets_to_album(album_id, batch)
|
||||
_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:
|
||||
_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_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(
|
||||
rule_name=rule_name,
|
||||
@@ -228,10 +263,16 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
|
||||
async def _gather_candidates(
|
||||
self, criteria: dict[str, Any]
|
||||
) -> list[str]:
|
||||
"""Gather asset IDs matching the criteria (union of all sources)."""
|
||||
) -> tuple[list[str], dict[str, str]]:
|
||||
"""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()
|
||||
result: list[str] = []
|
||||
types_by_id: dict[str, str] = {}
|
||||
|
||||
# Source 1: Person assets
|
||||
person_ids = criteria.get("person_ids", [])
|
||||
@@ -243,6 +284,7 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
if self._matches_filters(asset, criteria):
|
||||
seen.add(aid)
|
||||
result.append(aid)
|
||||
types_by_id[aid] = asset.get("type", "") or ""
|
||||
|
||||
# Source 2: Smart search
|
||||
query = criteria.get("query", "")
|
||||
@@ -254,6 +296,7 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
if self._matches_filters(asset, criteria):
|
||||
seen.add(aid)
|
||||
result.append(aid)
|
||||
types_by_id[aid] = asset.get("type", "") or ""
|
||||
|
||||
# Exclude assets belonging to excluded persons
|
||||
exclude_person_ids = criteria.get("exclude_person_ids", [])
|
||||
@@ -266,8 +309,12 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
if aid:
|
||||
excluded_asset_ids.add(aid)
|
||||
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(
|
||||
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):
|
||||
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(
|
||||
id=asset.id,
|
||||
type=media_type,
|
||||
@@ -252,8 +262,8 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
|
||||
description=asset.description or None,
|
||||
tags=list(asset.people),
|
||||
thumbnail_url=f"{external_url}/api/assets/{asset.id}/thumbnail",
|
||||
preview_url=f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview",
|
||||
full_url=f"{external_url}/api/assets/{asset.id}/original",
|
||||
preview_url=preview_url,
|
||||
full_url=full_url,
|
||||
extra={
|
||||
"owner_id": asset.owner_id,
|
||||
"is_favorite": asset.is_favorite,
|
||||
@@ -264,7 +274,11 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
|
||||
"state": asset.state,
|
||||
"country": asset.country,
|
||||
"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,
|
||||
"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)
|
||||
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():
|
||||
links = shared_links.get(album_id, [])
|
||||
album_public_url = get_public_url(external_url, links) or ""
|
||||
@@ -322,6 +342,9 @@ def collect_scheduled_assets(
|
||||
"owner": album.owner,
|
||||
})
|
||||
|
||||
if stats_only:
|
||||
continue
|
||||
|
||||
filtered = filter_assets(
|
||||
list(album.assets.values()),
|
||||
favorite_only=favorite_only,
|
||||
@@ -334,6 +357,9 @@ def collect_scheduled_assets(
|
||||
asset_album_map[asset.id] = (album_id, album_public_url)
|
||||
all_eligible.append(asset)
|
||||
|
||||
if stats_only:
|
||||
return [], collections_extra
|
||||
|
||||
# Random sample
|
||||
if len(all_eligible) > limit:
|
||||
selected = random.sample(all_eligible, limit)
|
||||
|
||||
@@ -3,14 +3,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ...notifications.ssrf import UnsafeURLError, validate_outbound_url
|
||||
from .models import ImmichAlbumData, SharedLinkInfo
|
||||
|
||||
_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:
|
||||
"""Async client for the Immich API."""
|
||||
@@ -25,6 +58,19 @@ class ImmichClient:
|
||||
self._url = url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
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. Homelab deployments pointing at RFC1918 targets
|
||||
# must set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the runtime env.
|
||||
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}. "
|
||||
"If this is a LAN/homelab Immich, set NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1."
|
||||
) from err
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
@@ -36,6 +82,14 @@ class ImmichClient:
|
||||
|
||||
@external_domain.setter
|
||||
def external_domain(self, value: str | None) -> None:
|
||||
# Mirror the constructor's SSRF guard. Set
|
||||
# ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` for LAN/homelab targets.
|
||||
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
|
||||
|
||||
@property
|
||||
@@ -235,33 +289,97 @@ class ImmichClient:
|
||||
query: str,
|
||||
album_ids: list[str] | None = None,
|
||||
limit: int = 10,
|
||||
page: int = 1,
|
||||
) -> 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:
|
||||
payload["albumIds"] = album_ids
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/smart",
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
items = data.get("assets", {}).get("items", [])
|
||||
return items[:limit]
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
return []
|
||||
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
|
||||
return await self._search_items(
|
||||
f"{self._url}/api/search/smart", payload, limit, "smart",
|
||||
)
|
||||
|
||||
async def search_metadata(
|
||||
self,
|
||||
query: str,
|
||||
album_ids: list[str] | None = None,
|
||||
limit: int = 10,
|
||||
page: int = 1,
|
||||
) -> 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:
|
||||
payload["albumIds"] = album_ids
|
||||
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
|
||||
return await self._search_items(
|
||||
f"{self._url}/api/search/metadata", payload, limit, "metadata",
|
||||
)
|
||||
|
||||
async def _search_items(
|
||||
self,
|
||||
url: str,
|
||||
payload: dict[str, Any],
|
||||
limit: int,
|
||||
kind: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shared POST-and-extract-items helper with error logging.
|
||||
|
||||
Returns an empty list on any error; previously these paths swallowed
|
||||
non-200s silently, making "/search always returns no results" on
|
||||
misbehaving Immich deployments impossible to diagnose without a
|
||||
network trace. Logging keeps the empty-list contract but tells the
|
||||
operator *why* it's empty.
|
||||
"""
|
||||
try:
|
||||
async with self._session.post(
|
||||
url,
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
body_snip = await response.text()
|
||||
_LOGGER.warning(
|
||||
"Immich %s search non-200: HTTP %s body=%s",
|
||||
kind, response.status, _redact_body(body_snip),
|
||||
)
|
||||
return []
|
||||
data = await response.json()
|
||||
# Modern Immich: {"assets": {"items": [...], ...}}
|
||||
assets_block = data.get("assets")
|
||||
if isinstance(assets_block, dict):
|
||||
items = assets_block.get("items", []) or []
|
||||
elif isinstance(assets_block, list):
|
||||
# Older/alternate shape — flat list of assets.
|
||||
items = assets_block
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Immich %s search returned unexpected shape: keys=%s",
|
||||
kind, list(data.keys())[:5],
|
||||
)
|
||||
items = []
|
||||
return items[:limit]
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Immich %s search transport error: %s", kind, err)
|
||||
except Exception as err: # noqa: BLE001 — don't crash caller on unexpected JSON
|
||||
_LOGGER.warning("Immich %s search parse error: %s", kind, err)
|
||||
return []
|
||||
|
||||
async def search_by_person(
|
||||
self, person_id: str, limit: int = 10
|
||||
) -> 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:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/metadata",
|
||||
@@ -276,21 +394,6 @@ class ImmichClient:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def search_by_person(
|
||||
self, person_id: str, limit: int = 10
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/people/{person_id}/assets",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data[:limit] if isinstance(data, list) else []
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def get_memories(
|
||||
self,
|
||||
date: str | None = None,
|
||||
@@ -329,7 +432,15 @@ class ImmichClient:
|
||||
async def add_assets_to_album(
|
||||
self, album_id: str, asset_ids: list[str]
|
||||
) -> 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}
|
||||
try:
|
||||
async with self._session.put(
|
||||
@@ -337,14 +448,63 @@ class ImmichClient:
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
body_text = await response.text()
|
||||
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(
|
||||
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:
|
||||
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(
|
||||
self, album_id: str, asset_ids: list[str]
|
||||
) -> dict[str, Any]:
|
||||
@@ -386,22 +546,49 @@ class ImmichClient:
|
||||
raise ImmichApiError(f"Error creating album: {err}") from err
|
||||
|
||||
async def get_person_assets_all(self, person_id: str) -> list[dict[str, Any]]:
|
||||
"""Fetch ALL assets for a person (no limit)."""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/people/{person_id}/assets",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data if isinstance(data, list) else []
|
||||
if response.status == 404:
|
||||
return []
|
||||
raise ImmichApiError(
|
||||
f"Failed to fetch person {person_id} assets: HTTP {response.status}"
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
raise ImmichApiError(f"Error fetching person assets: {err}") from err
|
||||
"""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:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/metadata",
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
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:
|
||||
# Person doesn't exist — return empty rather than raising
|
||||
return all_items
|
||||
raise ImmichApiError(
|
||||
f"Failed to fetch person {person_id} assets: HTTP {response.status}"
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
raise ImmichApiError(f"Error fetching person assets: {err}") from err
|
||||
return all_items
|
||||
|
||||
async def search_smart_all(
|
||||
self, query: str, limit: int = 1000
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
📊 Status
|
||||
Trackers: {{ trackers_active }}/{{ trackers_total }} active
|
||||
Albums: {{ total_albums }}
|
||||
Last event: {{ last_event }}
|
||||
Last event: {{ last_event }}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
📊 Статус
|
||||
Трекеры: {{ trackers_active }}/{{ trackers_total }} активных
|
||||
Альбомы: {{ total_albums }}
|
||||
Последнее событие: {{ last_event }}
|
||||
Последнее событие: {{ last_event }}
|
||||
|
||||
@@ -2,16 +2,67 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Per-target maximum video size (bytes). None = no limit.
|
||||
_MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = {
|
||||
"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(
|
||||
event: ServiceEvent,
|
||||
@@ -61,9 +112,12 @@ def build_template_context(
|
||||
"preview_url": asset.preview_url or "",
|
||||
"full_url": asset.full_url or "",
|
||||
}
|
||||
# Flatten extras into asset dict for template access
|
||||
asset_dict.update(asset.extra)
|
||||
# Flatten extras into asset dict for template access — same
|
||||
# sensitive-key filtering applied as the top-level merge.
|
||||
_safe_merge_extras(asset_dict, asset.extra)
|
||||
asset_dict.setdefault("oversized", False)
|
||||
asset_dict.setdefault("file_size", None)
|
||||
asset_dict.setdefault("playback_size", None)
|
||||
assets.append(asset_dict)
|
||||
|
||||
# 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_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
|
||||
if max_video_bytes:
|
||||
for a in assets:
|
||||
if a.get("type") == "VIDEO":
|
||||
fs = a.get("file_size")
|
||||
oversized = fs is not None and fs > max_video_bytes
|
||||
size = a.get("playback_size")
|
||||
oversized = size is not None and size > max_video_bytes
|
||||
a["oversized"] = oversized
|
||||
if oversized:
|
||||
has_oversized = True
|
||||
@@ -132,8 +190,11 @@ def build_template_context(
|
||||
if len(locations) == 1 and "" not in locations:
|
||||
ctx["common_location"] = locations.pop()
|
||||
|
||||
# Provider-specific extras merged at top level
|
||||
ctx.update(event.extra)
|
||||
# Provider-specific extras merged at top level. Sensitive keys (tokens,
|
||||
# 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)
|
||||
ctx.setdefault("public_url", "")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-server"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -20,17 +20,27 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
_SETTING_KEYS = {
|
||||
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
|
||||
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
||||
"telegram_cache_ttl_hours": None, # no env fallback, default 48
|
||||
"telegram_cache_ttl_hours": None, # URL cache TTL; 0 disables TTL
|
||||
"telegram_asset_cache_max_entries": None, # LRU cap for both caches
|
||||
"supported_locales": None, # comma-separated locale codes
|
||||
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
|
||||
}
|
||||
|
||||
_DEFAULTS = {
|
||||
"external_url": "",
|
||||
"telegram_webhook_secret": "",
|
||||
"telegram_cache_ttl_hours": "48",
|
||||
# 720h = 30d. URL cache only; asset cache uses thumbhash validation
|
||||
# (content-addressable) and ignores TTL entirely.
|
||||
"telegram_cache_ttl_hours": "720",
|
||||
"telegram_asset_cache_max_entries": "5000",
|
||||
"supported_locales": "en,ru",
|
||||
"timezone": "UTC",
|
||||
}
|
||||
|
||||
# Settings whose changes require dropping in-memory Telegram caches so the
|
||||
# next dispatch rebuilds them with the new parameters. Files are preserved.
|
||||
_CACHE_SETTING_KEYS = {"telegram_cache_ttl_hours", "telegram_asset_cache_max_entries"}
|
||||
|
||||
|
||||
async def get_setting(session: AsyncSession, key: str) -> str:
|
||||
"""Read a setting from DB, falling back to env var then default."""
|
||||
@@ -46,10 +56,16 @@ async def get_setting(session: AsyncSession, key: str) -> str:
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
# Numeric fields declared as int|str so clients can send either form.
|
||||
# Svelte's bind:value on <input type="number"> coerces to a JS number,
|
||||
# so the frontend sends ints for these; older/manual clients may send
|
||||
# strings. We normalize to str before persisting.
|
||||
external_url: str | None = None
|
||||
telegram_webhook_secret: str | None = None
|
||||
telegram_cache_ttl_hours: str | None = None
|
||||
telegram_cache_ttl_hours: int | str | None = None
|
||||
telegram_asset_cache_max_entries: int | str | None = None
|
||||
supported_locales: str | None = None
|
||||
timezone: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -77,19 +93,32 @@ async def update_settings(
|
||||
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
|
||||
old_base_url = await get_setting(session, "external_url")
|
||||
old_secret = await get_setting(session, "telegram_webhook_secret")
|
||||
old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
|
||||
|
||||
for key in _SETTING_KEYS:
|
||||
value = getattr(body, key, None)
|
||||
if value is None:
|
||||
continue
|
||||
value_str = str(value)
|
||||
row = await session.get(AppSetting, key)
|
||||
if row:
|
||||
row.value = value
|
||||
row.value = value_str
|
||||
else:
|
||||
row = AppSetting(key=key, value=value)
|
||||
row = AppSetting(key=key, value=value_str)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
|
||||
# Drop in-memory caches if any cache-tuning setting actually changed, so
|
||||
# the next dispatch rebuilds them with the new parameters. Files survive.
|
||||
cache_changed = False
|
||||
for key in _CACHE_SETTING_KEYS:
|
||||
if await get_setting(session, key) != old_cache_values[key]:
|
||||
cache_changed = True
|
||||
break
|
||||
if cache_changed:
|
||||
from ..services.watcher import reset_telegram_caches_in_memory
|
||||
await reset_telegram_caches_in_memory()
|
||||
|
||||
new_base_url = await get_setting(session, "external_url")
|
||||
new_secret = await get_setting(session, "telegram_webhook_secret")
|
||||
|
||||
@@ -108,6 +137,25 @@ async def update_settings(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/telegram-cache/stats")
|
||||
async def telegram_cache_stats(
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Return counts and sizes for the Telegram file_id caches."""
|
||||
from ..services.watcher import get_telegram_cache_stats
|
||||
return await get_telegram_cache_stats()
|
||||
|
||||
|
||||
@router.post("/telegram-cache/clear")
|
||||
async def clear_telegram_cache(
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Clear the Telegram file_id cache (URL and asset) from disk and memory."""
|
||||
from ..services.watcher import clear_telegram_caches
|
||||
result = await clear_telegram_caches()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/locales")
|
||||
async def get_supported_locales(
|
||||
user: User = Depends(get_current_user),
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Configuration backup/restore API (admin only)."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
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 sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
@@ -16,10 +21,29 @@ from ..services.backup_schema import (
|
||||
ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode,
|
||||
)
|
||||
from ..services.backup_service import (
|
||||
cleanup_old_backups, export_backup, import_backup, list_backup_files,
|
||||
validate_backup,
|
||||
cleanup_old_backups, export_backup, export_backup_to_file, import_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__)
|
||||
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
return app_config.data_dir / "backups"
|
||||
|
||||
@@ -87,9 +174,7 @@ async def validate_config(
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Validate a backup file without importing."""
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
content = await _read_upload_bounded(file)
|
||||
|
||||
try:
|
||||
raw = json.loads(content)
|
||||
@@ -112,9 +197,7 @@ async def import_config(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Import configuration from a backup file."""
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
content = await _read_upload_bounded(file)
|
||||
|
||||
try:
|
||||
raw = json.loads(content)
|
||||
@@ -131,6 +214,202 @@ async def import_config(
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -205,6 +484,37 @@ async def get_backup_files(
|
||||
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}")
|
||||
async def download_backup_file(
|
||||
filename: str,
|
||||
|
||||
@@ -138,13 +138,11 @@ async def get_command_variables(
|
||||
# --- Immich-specific ---
|
||||
immich = {
|
||||
"status": {
|
||||
"description": "/status tracker summary",
|
||||
"description": "/status tracker summary (scoped to this chat)",
|
||||
"variables": {
|
||||
**common_vars,
|
||||
"trackers_active": "Number of active trackers",
|
||||
"trackers_total": "Total tracker count",
|
||||
"total_albums": "Total tracked albums",
|
||||
"last_event": "Last event timestamp string",
|
||||
"total_albums": "Tracked albums visible to this chat",
|
||||
"last_event": "Last event timestamp string (scoped to this chat's albums)",
|
||||
},
|
||||
},
|
||||
"albums": {
|
||||
|
||||
@@ -42,6 +42,11 @@ class CommandTrackerUpdate(BaseModel):
|
||||
class ListenerCreate(BaseModel):
|
||||
listener_type: str
|
||||
listener_id: int
|
||||
allowed_album_ids: list[str] | None = None
|
||||
|
||||
|
||||
class ListenerUpdate(BaseModel):
|
||||
allowed_album_ids: list[str] | None = None
|
||||
|
||||
|
||||
# --- Command Tracker CRUD ---
|
||||
@@ -299,6 +304,7 @@ async def add_listener(
|
||||
command_tracker_id=tracker_id,
|
||||
listener_type=body.listener_type,
|
||||
listener_id=body.listener_id,
|
||||
allowed_album_ids=body.allowed_album_ids,
|
||||
)
|
||||
session.add(listener)
|
||||
await session.commit()
|
||||
@@ -316,6 +322,30 @@ async def add_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)
|
||||
async def remove_listener(
|
||||
tracker_id: int,
|
||||
@@ -394,6 +424,7 @@ async def _listener_response(session: AsyncSession, l: CommandTrackerListener) -
|
||||
"command_tracker_id": l.command_tracker_id,
|
||||
"listener_type": l.listener_type,
|
||||
"listener_id": l.listener_id,
|
||||
"allowed_album_ids": l.allowed_album_ids,
|
||||
"name": name,
|
||||
"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:
|
||||
"""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:
|
||||
detail = f"Cannot delete {entity_name}: used by {len(consumers)} consumer(s). " + "; ".join(consumers)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
||||
summary = f"Cannot delete {entity_name}: used by {len(consumers)} consumer(s)."
|
||||
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]:
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlmodel import func, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import (
|
||||
Action,
|
||||
CommandConfig,
|
||||
CommandTemplateConfig,
|
||||
CommandTracker,
|
||||
@@ -54,12 +56,10 @@ async def get_status(
|
||||
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||
)).one()
|
||||
|
||||
# Build events query with filters
|
||||
events_query = (
|
||||
select(EventLog)
|
||||
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
|
||||
.where(NotificationTracker.user_id == user.id)
|
||||
)
|
||||
# Build events query with filters. EventLog.user_id is the owner column;
|
||||
# action events (event_type starts with "action_") have tracker_id NULL but
|
||||
# user_id set, so we filter by user_id directly.
|
||||
events_query = select(EventLog).where(EventLog.user_id == user.id)
|
||||
|
||||
if 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(
|
||||
EventLog.collection_name.contains(search)
|
||||
| EventLog.tracker_name.contains(search)
|
||||
| EventLog.action_name.contains(search)
|
||||
| EventLog.provider_name.contains(search)
|
||||
)
|
||||
|
||||
@@ -84,6 +85,65 @@ async def get_status(
|
||||
|
||||
events_query = events_query.offset(offset).limit(limit)
|
||||
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 {
|
||||
"providers": providers_count,
|
||||
@@ -94,19 +154,43 @@ async def get_status(
|
||||
{
|
||||
"id": e.id,
|
||||
"event_type": e.event_type,
|
||||
"collection_name": e.collection_name,
|
||||
"tracker_name": e.tracker_name or "",
|
||||
"provider_name": e.provider_name or "",
|
||||
"collection_name": _display_subject(e),
|
||||
"tracker_name": _display_tracker_name(e),
|
||||
"action_id": e.action_id,
|
||||
"action_name": _display_action_name(e),
|
||||
"provider_name": _display_provider_name(e),
|
||||
"provider_id": e.provider_id,
|
||||
"assets_count": e.assets_count or 0,
|
||||
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
|
||||
"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")
|
||||
async def get_nav_counts(
|
||||
user: User = Depends(get_current_user),
|
||||
@@ -192,8 +276,7 @@ async def get_event_chart(
|
||||
EventLog.event_type,
|
||||
func.count().label("total"),
|
||||
)
|
||||
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
|
||||
.where(NotificationTracker.user_id == user.id, EventLog.created_at >= cutoff)
|
||||
.where(EventLog.user_id == user.id, EventLog.created_at >= cutoff)
|
||||
)
|
||||
|
||||
if event_type:
|
||||
@@ -204,6 +287,7 @@ async def get_event_chart(
|
||||
query = query.where(
|
||||
EventLog.collection_name.contains(search)
|
||||
| EventLog.tracker_name.contains(search)
|
||||
| EventLog.action_name.contains(search)
|
||||
| EventLog.provider_name.contains(search)
|
||||
)
|
||||
|
||||
|
||||
@@ -162,8 +162,9 @@ async def get_template_variables(
|
||||
"city": "City name",
|
||||
"state": "State/region name",
|
||||
"country": "Country name",
|
||||
"file_size": "File size in bytes (null if unknown)",
|
||||
"oversized": "Whether video exceeds the target's size limit (boolean, videos only)",
|
||||
"file_size": "Original asset size in bytes (null if unknown)",
|
||||
"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)",
|
||||
"url": "Public viewer URL (if shared)",
|
||||
"download_url": "Direct download URL (if shared)",
|
||||
|
||||
@@ -54,6 +54,9 @@ class TrackingConfigCreate(BaseModel):
|
||||
memory_favorite_only: bool = False
|
||||
memory_asset_type: str = "all"
|
||||
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):
|
||||
@@ -93,6 +96,9 @@ class TrackingConfigUpdate(BaseModel):
|
||||
memory_favorite_only: bool | None = None
|
||||
memory_asset_type: str | 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("")
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import select
|
||||
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}
|
||||
|
||||
|
||||
@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):
|
||||
new_password: str
|
||||
|
||||
@@ -87,6 +163,9 @@ async def reset_user_password(
|
||||
if len(body.new_password) < 8:
|
||||
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()
|
||||
# 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)
|
||||
await session.commit()
|
||||
return {"success": True}
|
||||
|
||||
@@ -27,7 +27,11 @@ from ..database.models import (
|
||||
ServiceProvider,
|
||||
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__)
|
||||
|
||||
@@ -144,9 +148,12 @@ async def _dispatch_webhook_event(
|
||||
if not link_data:
|
||||
continue
|
||||
|
||||
app_tz = await get_app_timezone(session)
|
||||
|
||||
# Log event
|
||||
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
|
||||
session.add(EventLog(
|
||||
user_id=tracker.user_id,
|
||||
tracker_id=tracker.id,
|
||||
tracker_name=tracker.name,
|
||||
provider_id=provider_id,
|
||||
@@ -163,7 +170,7 @@ async def _dispatch_webhook_event(
|
||||
|
||||
# Dispatch to targets
|
||||
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:
|
||||
results = await dispatcher.dispatch(event, target_configs)
|
||||
for r in results:
|
||||
@@ -512,12 +519,13 @@ def _build_target_configs(
|
||||
event: ServiceEvent,
|
||||
link_data: list[dict[str, Any]],
|
||||
provider_config: dict[str, Any],
|
||||
app_tz: str = "UTC",
|
||||
) -> list[TargetConfig]:
|
||||
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
|
||||
target_configs: list[TargetConfig] = []
|
||||
for ld in link_data:
|
||||
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
|
||||
|
||||
tmpl = ld["template_config"]
|
||||
|
||||
@@ -6,7 +6,10 @@ from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from ..database.models import CommandConfig, CommandTracker, ServiceProvider, TelegramBot
|
||||
from ..database.models import (
|
||||
CommandConfig, CommandTracker, CommandTrackerListener,
|
||||
ServiceProvider, TelegramBot,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -51,6 +54,10 @@ class ProviderCommandHandler(ABC):
|
||||
bot: TelegramBot,
|
||||
tracker: CommandTracker,
|
||||
config: CommandConfig,
|
||||
*,
|
||||
listener: CommandTrackerListener | None = None,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
"""Handle a provider-specific command for a single tracker.
|
||||
|
||||
@@ -65,6 +72,13 @@ class ProviderCommandHandler(ABC):
|
||||
bot: The Telegram bot instance.
|
||||
tracker: The command tracker being dispatched.
|
||||
config: The command config for this tracker.
|
||||
listener: The listener row for this (tracker, bot) pair.
|
||||
allowed_album_ids: Precomputed album scope for this (bot, chat)
|
||||
pair. Resolved by the dispatcher from the listener override
|
||||
(if set) or the notification-routing graph. ``None`` means
|
||||
"no scope restriction" (rarely the right default for album
|
||||
providers — empty set is the common case).
|
||||
page: 1-based page number for paginated commands (/search, /find).
|
||||
|
||||
Returns:
|
||||
A CommandResponse, or None if unhandled.
|
||||
|
||||
@@ -8,7 +8,14 @@ from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import EventLog, NotificationTracker, ServiceProvider
|
||||
from ..database.models import (
|
||||
EventLog,
|
||||
NotificationTarget,
|
||||
NotificationTracker,
|
||||
NotificationTrackerTarget,
|
||||
ServiceProvider,
|
||||
TargetReceiver,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,25 +27,125 @@ async def get_trackers_for_provider(provider_id: int) -> list[NotificationTracke
|
||||
return await _get_notification_trackers_for_providers({provider_id})
|
||||
|
||||
|
||||
async def get_last_event_str(tracker_ids: list[int]) -> str:
|
||||
async def get_last_event_str(
|
||||
tracker_ids: list[int],
|
||||
*,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
) -> str:
|
||||
"""Get formatted timestamp of most recent event for given trackers.
|
||||
|
||||
Returns a 'YYYY-MM-DD HH:MM' string, or '-' if no events exist.
|
||||
|
||||
When ``allowed_album_ids`` is provided, only events whose
|
||||
``collection_id`` is in the set are considered — matches the per-chat
|
||||
scope applied via ``CommandTrackerListener.allowed_album_ids``.
|
||||
"""
|
||||
if not tracker_ids:
|
||||
return "-"
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
query = (
|
||||
select(EventLog)
|
||||
.where(EventLog.tracker_id.in_(tracker_ids))
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if allowed_album_ids is not None:
|
||||
query = query.where(EventLog.collection_id.in_(list(allowed_album_ids)))
|
||||
result = await session.exec(query.limit(1))
|
||||
last_event = result.first()
|
||||
return last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
|
||||
|
||||
|
||||
async def resolve_chat_album_scope(
|
||||
*,
|
||||
provider_id: int,
|
||||
bot_id: int,
|
||||
chat_id: str,
|
||||
) -> set[str]:
|
||||
"""Compute the album scope for a (provider, bot, chat) triple.
|
||||
|
||||
Walks the notification-routing graph: find every notification tracker for
|
||||
``provider_id`` that ultimately delivers to a Telegram receiver matching
|
||||
this ``(bot_id, chat_id)``, then union their ``collection_ids``. The
|
||||
result is the set of albums this specific chat legitimately sees
|
||||
notifications for — which is the natural "allowed albums" for commands
|
||||
issued in that chat.
|
||||
|
||||
Returns:
|
||||
set of album ids. Empty set = "no tracker routes to this chat" —
|
||||
caller should treat as "show nothing" (defense in depth); otherwise
|
||||
a bot's chats would leak the provider's full album catalog.
|
||||
|
||||
Notes:
|
||||
- Only enabled ``TargetReceiver`` rows are considered.
|
||||
- Both direct Telegram targets and broadcast targets that fan out
|
||||
to a Telegram child target are resolved.
|
||||
- Explicit ``CommandTrackerListener.allowed_album_ids`` override is
|
||||
NOT applied here — that's the dispatcher's job. This helper is
|
||||
the "derived" fallback.
|
||||
"""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
# 1. Telegram receivers in this chat (directly or via broadcast).
|
||||
direct_rows = (await session.exec(
|
||||
select(TargetReceiver, NotificationTarget)
|
||||
.join(
|
||||
NotificationTarget,
|
||||
TargetReceiver.target_id == NotificationTarget.id,
|
||||
)
|
||||
.where(
|
||||
TargetReceiver.enabled == True, # noqa: E712
|
||||
NotificationTarget.type == "telegram",
|
||||
)
|
||||
)).all()
|
||||
target_ids: set[int] = set()
|
||||
for recv, target in direct_rows:
|
||||
rc_chat = str(recv.config.get("chat_id", "") or "")
|
||||
rc_bot = target.config.get("bot_id")
|
||||
if rc_chat == str(chat_id) and rc_bot == bot_id:
|
||||
target_ids.add(target.id)
|
||||
|
||||
# Follow broadcast parents: any broadcast target whose
|
||||
# child_target_ids includes one of our direct Telegram target_ids
|
||||
# also counts as "routes to this chat".
|
||||
broadcast_rows = (await session.exec(
|
||||
select(NotificationTarget).where(NotificationTarget.type == "broadcast")
|
||||
)).all()
|
||||
for b in broadcast_rows:
|
||||
children = set(b.config.get("child_target_ids", []) or [])
|
||||
disabled = set(b.config.get("disabled_child_ids", []) or [])
|
||||
if (children - disabled) & target_ids:
|
||||
target_ids.add(b.id)
|
||||
|
||||
if not target_ids:
|
||||
return set()
|
||||
|
||||
# 2. Trackers pointing at those targets.
|
||||
tracker_target_rows = (await session.exec(
|
||||
select(NotificationTrackerTarget).where(
|
||||
NotificationTrackerTarget.target_id.in_(target_ids)
|
||||
)
|
||||
)).all()
|
||||
tracker_ids = {tt.tracker_id for tt in tracker_target_rows}
|
||||
if not tracker_ids:
|
||||
return set()
|
||||
|
||||
# 3. Filter trackers by provider and collect collection_ids.
|
||||
trackers = (await session.exec(
|
||||
select(NotificationTracker).where(
|
||||
NotificationTracker.id.in_(tracker_ids),
|
||||
NotificationTracker.provider_id == provider_id,
|
||||
)
|
||||
)).all()
|
||||
|
||||
scope: set[str] = set()
|
||||
for tr in trackers:
|
||||
for aid in (tr.collection_ids or []):
|
||||
if aid:
|
||||
scope.add(aid)
|
||||
return scope
|
||||
|
||||
|
||||
def get_tracked_collection_ids(
|
||||
provider: ServiceProvider,
|
||||
trackers: list[NotificationTracker],
|
||||
|
||||
@@ -77,6 +77,10 @@ class GiteaCommandHandler(ProviderCommandHandler):
|
||||
bot: TelegramBot,
|
||||
tracker: CommandTracker,
|
||||
config: CommandConfig,
|
||||
*,
|
||||
listener: Any = None,
|
||||
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Gitea has no album model)
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
fn = _TEXT_COMMANDS.get(cmd)
|
||||
if fn is None:
|
||||
|
||||
@@ -95,7 +95,7 @@ def _render_cmd_template(
|
||||
async def _resolve_command_context(
|
||||
bot: TelegramBot,
|
||||
) -> tuple[
|
||||
list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
|
||||
list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]],
|
||||
dict[int, dict[str, dict[str, str]]],
|
||||
]:
|
||||
"""Resolve all enabled command trackers, configs, and providers for a bot.
|
||||
@@ -148,7 +148,7 @@ async def _resolve_command_context(
|
||||
else:
|
||||
providers_by_id = {}
|
||||
|
||||
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]] = []
|
||||
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]] = []
|
||||
for listener in listeners:
|
||||
tracker = trackers_by_id.get(listener.command_tracker_id)
|
||||
if not tracker or not tracker.enabled:
|
||||
@@ -159,12 +159,12 @@ async def _resolve_command_context(
|
||||
provider = providers_by_id.get(tracker.provider_id)
|
||||
if not provider:
|
||||
continue
|
||||
tuples.append((tracker, config, provider))
|
||||
tuples.append((tracker, config, provider, listener))
|
||||
|
||||
# Load command template slots per config (not merged)
|
||||
templates_by_config_id: dict[int, dict[str, dict[str, str]]] = {}
|
||||
seen_config_ids: set[int] = set()
|
||||
for _, config, _ in tuples:
|
||||
for _, config, _, _ in tuples:
|
||||
cfg_id = config.command_template_config_id
|
||||
if cfg_id and cfg_id not in seen_config_ids:
|
||||
seen_config_ids.add(cfg_id)
|
||||
@@ -204,7 +204,7 @@ def _merge_all_templates(
|
||||
|
||||
|
||||
def _merge_enabled_commands(
|
||||
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
|
||||
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]],
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
"""Merge enabled_commands (union) and rate_limits from all configs.
|
||||
|
||||
@@ -215,7 +215,7 @@ def _merge_enabled_commands(
|
||||
|
||||
enabled: set[str] = set()
|
||||
merged_limits: dict[str, int] = {}
|
||||
for _, config, _ in ctx:
|
||||
for _, config, _, _ in ctx:
|
||||
enabled.update(config.enabled_commands or [])
|
||||
for category, cooldown in (config.rate_limits or {}).items():
|
||||
if category not in merged_limits:
|
||||
@@ -278,8 +278,18 @@ async def handle_command(
|
||||
# Provider-specific dispatch — per-tracker
|
||||
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
|
||||
|
||||
from .command_utils import resolve_chat_album_scope
|
||||
|
||||
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:
|
||||
_LOGGER.warning(
|
||||
"Truncated command responses at %d for bot %d cmd /%s",
|
||||
@@ -295,9 +305,27 @@ async def handle_command(
|
||||
count = min(count_override or config.default_count or 5, 20)
|
||||
response_mode = config.response_mode or "media"
|
||||
|
||||
# Resolve the album scope for this (provider, bot, chat) triple.
|
||||
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
|
||||
# - Otherwise derive from notification routing: only albums that
|
||||
# already deliver notifications to this chat are queryable from
|
||||
# it. Prevents commands leaking the full album catalog into
|
||||
# chats that were never set up to receive from those trackers.
|
||||
if listener is not None and listener.allowed_album_ids is not None:
|
||||
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
|
||||
else:
|
||||
allowed_album_ids = await resolve_chat_album_scope(
|
||||
provider_id=provider.id,
|
||||
bot_id=bot.id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
result = await handler.handle(
|
||||
cmd, args, count, locale, response_mode,
|
||||
provider, tracker_templates, bot, tracker, config,
|
||||
listener=listener,
|
||||
allowed_album_ids=allowed_album_ids,
|
||||
page=page,
|
||||
)
|
||||
if result is not None:
|
||||
responses.append(result)
|
||||
@@ -339,12 +367,23 @@ async def send_reply(
|
||||
bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
) -> None:
|
||||
"""Send a text reply via TelegramClient."""
|
||||
"""Send a text reply via TelegramClient.
|
||||
|
||||
Command responses are listings (albums, people, events, ...) that embed
|
||||
multiple links; Telegram's default behavior of rendering a preview of
|
||||
the first URL is almost never what the user wants and clashes with the
|
||||
"Disable link previews" toggle operators set on their Telegram target.
|
||||
We always pass ``disable_web_page_preview=True`` here.
|
||||
"""
|
||||
if session is None:
|
||||
from ..services.http_session import get_http_session
|
||||
session = await get_http_session()
|
||||
client = TelegramClient(session, bot_token)
|
||||
result = await client.send_message(chat_id, text, reply_to_message_id=reply_to_message_id)
|
||||
result = await client.send_message(
|
||||
chat_id, text,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
if not result.get("success"):
|
||||
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _cmd_albums(
|
||||
provider: ServiceProvider, locale: str,
|
||||
provider: ServiceProvider,
|
||||
locale: str,
|
||||
*,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
trackers = await get_trackers_for_provider(provider.id)
|
||||
if not trackers:
|
||||
@@ -31,6 +34,13 @@ async def _cmd_albums(
|
||||
if aid not in seen:
|
||||
seen.add(aid)
|
||||
album_ids.append(aid)
|
||||
|
||||
# Intersect with the dispatcher-resolved scope (listener override, else
|
||||
# derived from notification routing for this chat). Without this,
|
||||
# /albums leaks the full tracked-album list into chats never wired up.
|
||||
if allowed_album_ids is not None:
|
||||
album_ids = [aid for aid in album_ids if aid in allowed_album_ids]
|
||||
|
||||
if not album_ids:
|
||||
return {"albums": []}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
async def _cmd_events(
|
||||
provider: ServiceProvider,
|
||||
count: int, locale: str,
|
||||
*,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
trackers = await get_trackers_for_provider(provider.id)
|
||||
tracker_ids = [t.id for t in trackers]
|
||||
@@ -35,12 +37,14 @@ async def _cmd_events(
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
query = (
|
||||
select(EventLog)
|
||||
.where(EventLog.tracker_id.in_(tracker_ids))
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(count)
|
||||
)
|
||||
if allowed_album_ids is not None:
|
||||
query = query.where(EventLog.collection_id.in_(list(allowed_album_ids)))
|
||||
result = await session.exec(query.limit(count))
|
||||
events = result.all()
|
||||
|
||||
events_data = [
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from ...database.models import (
|
||||
CommandConfig, CommandTracker,
|
||||
CommandConfig, CommandTracker, CommandTrackerListener,
|
||||
ServiceProvider, TelegramBot,
|
||||
)
|
||||
from ...services import make_immich_provider
|
||||
@@ -25,17 +25,31 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def _cmd_status(
|
||||
provider: ServiceProvider, locale: str,
|
||||
*,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
trackers = await get_trackers_for_provider(provider.id)
|
||||
active = sum(1 for t in trackers if t.enabled)
|
||||
total = len(trackers)
|
||||
total_albums = sum(len(t.collection_ids or []) for t in trackers)
|
||||
|
||||
# Count only albums visible to this chat. Without the scope filter,
|
||||
# /status in a restricted chat leaks the full album count across the
|
||||
# provider. ``None`` = no filter; empty set = show nothing.
|
||||
total_albums = 0
|
||||
for t in trackers:
|
||||
for aid in (t.collection_ids or []):
|
||||
if allowed_album_ids is None or aid in allowed_album_ids:
|
||||
total_albums += 1
|
||||
|
||||
# Last-event timestamp is already scoped — see get_last_event_str, which
|
||||
# filters EventLog by collection_id against allowed_album_ids.
|
||||
tracker_ids = [t.id for t in trackers]
|
||||
last_str = await get_last_event_str(tracker_ids)
|
||||
last_str = await get_last_event_str(
|
||||
tracker_ids, allowed_album_ids=allowed_album_ids,
|
||||
)
|
||||
|
||||
# Tracker counts (``trackers_active`` / ``trackers_total``) are a
|
||||
# per-provider aggregate — they'd leak info about trackers this chat
|
||||
# has no visibility into once we've scoped everything else. Omitted.
|
||||
return {
|
||||
"trackers_active": active, "trackers_total": total,
|
||||
"total_albums": total_albums, "last_event": last_str,
|
||||
}
|
||||
|
||||
@@ -78,15 +92,19 @@ class ImmichCommandHandler(ProviderCommandHandler):
|
||||
bot: TelegramBot,
|
||||
tracker: CommandTracker,
|
||||
config: CommandConfig,
|
||||
*,
|
||||
listener: CommandTrackerListener | None = None,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
if cmd == "status":
|
||||
ctx = await _cmd_status(provider, locale)
|
||||
ctx = await _cmd_status(provider, locale, allowed_album_ids=allowed_album_ids)
|
||||
return CommandResponse(text=_render_cmd_template(cmd_templates, "status", locale, ctx))
|
||||
if cmd == "albums":
|
||||
ctx = await _cmd_albums(provider, locale)
|
||||
ctx = await _cmd_albums(provider, locale, allowed_album_ids=allowed_album_ids)
|
||||
return CommandResponse(text=_render_cmd_template(cmd_templates, "albums", locale, ctx))
|
||||
if cmd == "events":
|
||||
ctx = await _cmd_events(provider, count, locale)
|
||||
ctx = await _cmd_events(provider, count, locale, allowed_album_ids=allowed_album_ids)
|
||||
return CommandResponse(text=_render_cmd_template(cmd_templates, "events", locale, ctx))
|
||||
if cmd == "people":
|
||||
ctx = await _cmd_people(provider, locale)
|
||||
@@ -96,6 +114,7 @@ class ImmichCommandHandler(ProviderCommandHandler):
|
||||
return await _cmd_immich(
|
||||
cmd, args, count, locale, response_mode,
|
||||
provider, cmd_templates,
|
||||
allowed_album_ids=allowed_album_ids, page=page,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -104,13 +123,27 @@ async def _cmd_immich(
|
||||
cmd: str, args: str, count: int, locale: str,
|
||||
response_mode: str, provider: ServiceProvider,
|
||||
cmd_templates: dict[str, dict[str, str]],
|
||||
*,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
"""Handle commands that need Immich API access and may return media."""
|
||||
notification_trackers = await get_trackers_for_provider(provider.id)
|
||||
|
||||
all_album_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
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)
|
||||
|
||||
# Intersect with the scope resolved by the dispatcher (from the listener
|
||||
# override if set, otherwise from the notification-routing graph for this
|
||||
# chat). ``None`` = no filter (rare); empty set = show nothing (common
|
||||
# when the chat has no tracker routing).
|
||||
if allowed_album_ids is not None:
|
||||
all_album_ids = [aid for aid in all_album_ids if aid in allowed_album_ids]
|
||||
|
||||
ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/")
|
||||
|
||||
@@ -135,9 +168,9 @@ async def _cmd_immich(
|
||||
result: str | dict[str, Any] | None = None
|
||||
|
||||
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":
|
||||
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":
|
||||
result = await cmd_person(client, args, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls)
|
||||
elif cmd == "place":
|
||||
|
||||
@@ -25,12 +25,13 @@ async def cmd_search(
|
||||
locale: str, response_mode: str,
|
||||
cmd_templates: dict[str, dict[str, str]],
|
||||
asset_public_urls: dict[str, str] | None = None,
|
||||
page: int = 1,
|
||||
) -> str | dict[str, Any]:
|
||||
"""Handle /search command."""
|
||||
if not args:
|
||||
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)
|
||||
_enrich_assets(assets, asset_public_urls or {})
|
||||
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count, page=page)
|
||||
assets = _enrich_assets(assets, asset_public_urls or {})
|
||||
return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
|
||||
@@ -39,12 +40,13 @@ async def cmd_find(
|
||||
locale: str, response_mode: str,
|
||||
cmd_templates: dict[str, dict[str, str]],
|
||||
asset_public_urls: dict[str, str] | None = None,
|
||||
page: int = 1,
|
||||
) -> str | dict[str, Any]:
|
||||
"""Handle /find command."""
|
||||
if not args:
|
||||
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)
|
||||
_enrich_assets(assets, asset_public_urls or {})
|
||||
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count, page=page)
|
||||
assets = _enrich_assets(assets, asset_public_urls or {})
|
||||
return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
|
||||
@@ -66,7 +68,7 @@ async def cmd_person(
|
||||
if not person_id:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
|
||||
assets = await client.search_by_person(person_id, limit=count)
|
||||
_enrich_assets(assets, asset_public_urls or {})
|
||||
assets = _enrich_assets(assets, asset_public_urls or {})
|
||||
return _format_assets(assets, "person", args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
|
||||
@@ -82,5 +84,5 @@ async def cmd_place(
|
||||
assets = await client.search_smart(
|
||||
f"photos taken in {args}", album_ids=all_album_ids, limit=count
|
||||
)
|
||||
_enrich_assets(assets, asset_public_urls or {})
|
||||
assets = _enrich_assets(assets, asset_public_urls or {})
|
||||
return _format_assets(assets, "place", args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
@@ -52,6 +52,10 @@ class NutCommandHandler(ProviderCommandHandler):
|
||||
bot: TelegramBot,
|
||||
tracker: CommandTracker,
|
||||
config: CommandConfig,
|
||||
*,
|
||||
listener: Any = None,
|
||||
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (NUT has no album model)
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
fn = _TEXT_COMMANDS.get(cmd)
|
||||
if fn is None:
|
||||
|
||||
@@ -69,6 +69,10 @@ class PlankaCommandHandler(ProviderCommandHandler):
|
||||
bot: TelegramBot,
|
||||
tracker: CommandTracker,
|
||||
config: CommandConfig,
|
||||
*,
|
||||
listener: Any = None,
|
||||
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Planka has no album model)
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
fn = _TEXT_COMMANDS.get(cmd)
|
||||
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_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
|
||||
("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):
|
||||
await conn.execute(text(sql))
|
||||
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
|
||||
if await _has_table(conn, "telegram_bot"):
|
||||
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")
|
||||
|
||||
# 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
|
||||
if await _has_table(conn, "template_config"):
|
||||
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")
|
||||
|
||||
# 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
|
||||
# (template content moved to template_slot child rows)
|
||||
if await _has_table(conn, "template_config"):
|
||||
|
||||
@@ -204,6 +204,13 @@ class TrackingConfig(SQLModel, table=True):
|
||||
memory_asset_type: str = Field(default="all")
|
||||
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)
|
||||
|
||||
|
||||
@@ -467,6 +474,11 @@ class CommandTrackerListener(SQLModel, table=True):
|
||||
)
|
||||
listener_type: str # e.g. "telegram_bot"
|
||||
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)
|
||||
|
||||
|
||||
@@ -476,6 +488,10 @@ class EventLog(SQLModel, table=True):
|
||||
__tablename__ = "event_log"
|
||||
|
||||
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
|
||||
tracker_id: int | None = Field(
|
||||
default=None,
|
||||
@@ -484,6 +500,13 @@ class EventLog(SQLModel, table=True):
|
||||
sa_column_kwargs={"name": "notification_tracker_id"},
|
||||
)
|
||||
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_name: str = Field(default="")
|
||||
event_type: str = Field(index=True)
|
||||
|
||||
@@ -110,6 +110,10 @@ async def _seed_provider_command_template(
|
||||
await session.flush()
|
||||
else:
|
||||
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"):
|
||||
slots = load_default_command_templates(locale, provider_type=provider_type)
|
||||
@@ -166,7 +170,7 @@ async def _seed_default_command_templates() -> None:
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
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(
|
||||
session, "gitea", "Default Gitea Commands", "Default Gitea command templates",
|
||||
@@ -242,7 +246,7 @@ async def _seed_default_tracking_configs() -> None:
|
||||
"provider_type": "immich",
|
||||
"name": "Default Immich",
|
||||
"track_assets_added": True,
|
||||
"track_assets_removed": True,
|
||||
"track_assets_removed": False,
|
||||
"track_collection_renamed": True,
|
||||
"track_collection_deleted": True,
|
||||
"track_sharing_changed": False,
|
||||
@@ -251,7 +255,7 @@ async def _seed_default_tracking_configs() -> None:
|
||||
"provider_type": "google_photos",
|
||||
"name": "Default Google Photos",
|
||||
"track_assets_added": True,
|
||||
"track_assets_removed": True,
|
||||
"track_assets_removed": False,
|
||||
"track_collection_renamed": True,
|
||||
"track_collection_deleted": True,
|
||||
"track_sharing_changed": False,
|
||||
|
||||
@@ -66,6 +66,9 @@ async def lifespan(app: FastAPI):
|
||||
await migrate_user_token_version(engine)
|
||||
from .database.seeds import 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)
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
|
||||
from .api.app_settings import get_setting as _get_setting
|
||||
|
||||
@@ -16,6 +16,7 @@ from ..database.models import (
|
||||
Action,
|
||||
ActionExecution,
|
||||
ActionRule,
|
||||
EventLog,
|
||||
ServiceProvider,
|
||||
)
|
||||
|
||||
@@ -115,13 +116,42 @@ async def run_action(
|
||||
execution.error = action_result.error or ""
|
||||
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:
|
||||
action = await session.get(Action, action_id)
|
||||
if action:
|
||||
action.last_run_at = datetime.now(timezone.utc)
|
||||
action.last_run_status = execution.status if execution else ""
|
||||
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()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, time, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -29,12 +30,32 @@ from ..database.models import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def in_quiet_hours(start: str | None, end: str | None) -> bool:
|
||||
"""Check if the current UTC time is within the quiet hours window."""
|
||||
def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
|
||||
"""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:
|
||||
return False
|
||||
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_end = time.fromisoformat(end)
|
||||
if t_start <= t_end:
|
||||
@@ -46,8 +67,25 @@ def in_quiet_hours(start: str | None, end: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
||||
"""Check if an event type is allowed by the tracking config's flags."""
|
||||
async def get_app_timezone(session: AsyncSession) -> str:
|
||||
"""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
|
||||
flag_map = {
|
||||
# Immich events
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Notification sender — unified send logic for all paths (dispatch + test)."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -11,6 +12,10 @@ from ..database.models import NotificationTarget, TargetReceiver
|
||||
|
||||
_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]] = {
|
||||
"en": {
|
||||
"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:
|
||||
"""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":
|
||||
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)
|
||||
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:
|
||||
"""Send test notifications to all child targets of a broadcast target."""
|
||||
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",
|
||||
"download_url": "https://immich.example.com/api/assets/abc123/original",
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -43,7 +44,8 @@ _SAMPLE_VIDEO_ASSET = {
|
||||
"photo_url": None,
|
||||
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ async def start_scheduler() -> None:
|
||||
# Schedule daily cleanup of old event log entries
|
||||
_schedule_event_cleanup()
|
||||
|
||||
# Schedule periodic Telegram chat title refresh
|
||||
_schedule_telegram_chat_sync()
|
||||
|
||||
# Start debounced command auto-sync scheduler
|
||||
from .command_sync import 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")
|
||||
|
||||
|
||||
# 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:
|
||||
"""Delete EventLog entries older than 90 days."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -28,6 +28,16 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# Track last update_id per bot to use as offset
|
||||
_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]:
|
||||
"""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)
|
||||
|
||||
|
||||
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:
|
||||
"""Fetch updates from Telegram and process them."""
|
||||
engine = get_engine()
|
||||
@@ -167,6 +235,15 @@ async def _poll_bot(bot_id: int) -> None:
|
||||
offset=offset + 1 if offset else None, limit=50,
|
||||
)
|
||||
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
|
||||
updates = result.get("result", [])
|
||||
except Exception as e:
|
||||
|
||||
@@ -55,17 +55,21 @@ async def dispatch_test_notification(
|
||||
provider_config = dict(provider.config)
|
||||
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
|
||||
if tt.tracking_config_id:
|
||||
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
if 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_slots: dict[str, dict[str, str]] | None = None
|
||||
slot_name = _TEST_TYPE_SLOT_MAP.get(test_type, test_type)
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
if template_config_id:
|
||||
template_config = await session.get(TemplateConfig, template_config_id)
|
||||
if template_config:
|
||||
slot_result = await session.exec(
|
||||
select(TemplateSlot).where(
|
||||
@@ -79,7 +83,8 @@ async def dispatch_test_notification(
|
||||
if 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)
|
||||
|
||||
target_cfg = TargetConfig(
|
||||
@@ -95,21 +100,58 @@ async def dispatch_test_notification(
|
||||
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
|
||||
event = await _build_event(
|
||||
provider_type=provider.type,
|
||||
provider_config=provider_config,
|
||||
provider_name=provider.name or provider.type,
|
||||
tracker_name=tracker.name or "",
|
||||
tracker_filters=dict(tracker.filters) if tracker.filters else {},
|
||||
collection_ids=collection_ids,
|
||||
test_type=test_type,
|
||||
tracking_config=tracking_config,
|
||||
)
|
||||
try:
|
||||
event = await _build_event(
|
||||
provider_type=provider.type,
|
||||
provider_config=provider_config,
|
||||
provider_name=provider.name or provider.type,
|
||||
tracker_name=tracker.name or "",
|
||||
tracker_filters=dict(tracker.filters) if tracker.filters else {},
|
||||
collection_ids=collection_ids,
|
||||
test_type=test_type,
|
||||
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:
|
||||
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"):
|
||||
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
|
||||
url_cache, asset_cache = await _get_telegram_caches()
|
||||
@@ -136,6 +178,13 @@ async def _build_event(
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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(
|
||||
provider_config=provider_config,
|
||||
provider_name=provider_name,
|
||||
@@ -201,14 +250,23 @@ async def _build_immich_event(
|
||||
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] = {}
|
||||
shared_links: dict[str, list[SharedLinkInfo]] = {}
|
||||
for album_id in collection_ids:
|
||||
album = await immich.client.get_album(album_id)
|
||||
if album:
|
||||
albums[album_id] = album
|
||||
shared_links[album_id] = await immich.client.get_shared_links(album_id)
|
||||
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 []
|
||||
|
||||
assets, collections_extra = collect_scheduled_assets(
|
||||
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(
|
||||
immich,
|
||||
ext_domain: str,
|
||||
|
||||
@@ -21,7 +21,11 @@ from ..database.models import (
|
||||
NotificationTrackerState,
|
||||
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__)
|
||||
|
||||
@@ -31,8 +35,34 @@ _asset_cache: TelegramFileCache | None = None
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _load_cache_settings() -> tuple[int, int]:
|
||||
"""Return (url_ttl_seconds, asset_max_entries) from app settings.
|
||||
|
||||
Defaults apply when the settings rows are missing. Reads in a short-lived
|
||||
session to avoid coupling to the caller's transaction.
|
||||
"""
|
||||
from ..api.app_settings import get_setting
|
||||
async with AsyncSession(get_engine()) as session:
|
||||
ttl_hours_str = await get_setting(session, "telegram_cache_ttl_hours")
|
||||
max_entries_str = await get_setting(session, "telegram_asset_cache_max_entries")
|
||||
try:
|
||||
ttl_hours = int(ttl_hours_str) if ttl_hours_str else 720
|
||||
except ValueError:
|
||||
ttl_hours = 720
|
||||
try:
|
||||
max_entries = int(max_entries_str) if max_entries_str else 5000
|
||||
except ValueError:
|
||||
max_entries = 5000
|
||||
return ttl_hours * 3600, max_entries
|
||||
|
||||
|
||||
async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFileCache | None]:
|
||||
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR."""
|
||||
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR.
|
||||
|
||||
The URL cache runs in TTL mode (URLs aren't content-addressable); the asset
|
||||
cache runs in thumbhash mode so entries invalidate on visual change rather
|
||||
than age. Both honor an LRU size cap from settings.
|
||||
"""
|
||||
global _url_cache, _asset_cache
|
||||
if _url_cache is not None:
|
||||
return _url_cache, _asset_cache
|
||||
@@ -46,16 +76,91 @@ async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFile
|
||||
if not data_dir:
|
||||
return None, None
|
||||
cache_dir = Path(data_dir) / "cache"
|
||||
url_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_url_cache.json"))
|
||||
asset_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_asset_cache.json"))
|
||||
ttl_seconds, max_entries = await _load_cache_settings()
|
||||
url_cache = TelegramFileCache(
|
||||
JsonFileBackend(cache_dir / "telegram_url_cache.json"),
|
||||
ttl_seconds=ttl_seconds,
|
||||
max_entries=max_entries,
|
||||
)
|
||||
asset_cache = TelegramFileCache(
|
||||
JsonFileBackend(cache_dir / "telegram_asset_cache.json"),
|
||||
use_thumbhash=True,
|
||||
max_entries=max_entries,
|
||||
)
|
||||
await url_cache.async_load()
|
||||
await asset_cache.async_load()
|
||||
_url_cache = url_cache
|
||||
_asset_cache = asset_cache
|
||||
_LOGGER.info("Initialized Telegram file caches in %s", cache_dir)
|
||||
_LOGGER.info(
|
||||
"Initialized Telegram caches in %s (url ttl=%ds, max_entries=%d, asset thumbhash mode)",
|
||||
cache_dir, ttl_seconds, max_entries,
|
||||
)
|
||||
return _url_cache, _asset_cache
|
||||
|
||||
|
||||
async def reset_telegram_caches_in_memory() -> None:
|
||||
"""Drop in-memory cache refs without touching files on disk.
|
||||
|
||||
Used after settings changes so the next dispatch re-initializes caches
|
||||
with fresh parameters. Contrast with ``clear_telegram_caches`` which also
|
||||
deletes cached file_ids.
|
||||
"""
|
||||
global _url_cache, _asset_cache
|
||||
async with _cache_lock:
|
||||
_url_cache = None
|
||||
_asset_cache = None
|
||||
_LOGGER.info("Reset Telegram cache refs in memory (files preserved)")
|
||||
|
||||
|
||||
async def get_telegram_cache_stats() -> dict[str, Any]:
|
||||
"""Return stats for the URL and asset Telegram caches.
|
||||
|
||||
Loads caches lazily if they haven't been touched by a dispatch yet.
|
||||
Returns zero-counts when ``NOTIFY_BRIDGE_DATA_DIR`` is not configured.
|
||||
"""
|
||||
url_cache, asset_cache = await _get_telegram_caches()
|
||||
empty = {"count": 0, "total_size_bytes": 0, "oldest": None, "newest": None}
|
||||
return {
|
||||
"url": url_cache.stats() if url_cache else empty,
|
||||
"asset": asset_cache.stats() if asset_cache else empty,
|
||||
}
|
||||
|
||||
|
||||
async def clear_telegram_caches() -> dict[str, Any]:
|
||||
"""Delete both Telegram file caches from disk and reset in-memory state.
|
||||
|
||||
Next dispatch re-initializes the caches via `_get_telegram_caches()`.
|
||||
Returns a summary with the paths that were removed.
|
||||
"""
|
||||
global _url_cache, _asset_cache
|
||||
async with _cache_lock:
|
||||
removed: list[str] = []
|
||||
for cache, label in ((_url_cache, "url"), (_asset_cache, "asset")):
|
||||
if cache is not None:
|
||||
await cache.async_remove()
|
||||
removed.append(label)
|
||||
|
||||
# Also remove files from disk in case caches were never initialized
|
||||
# in this process (data_dir set but dispatch never ran).
|
||||
import os
|
||||
from pathlib import Path
|
||||
data_dir = os.environ.get("NOTIFY_BRIDGE_DATA_DIR")
|
||||
if data_dir:
|
||||
cache_dir = Path(data_dir) / "cache"
|
||||
for name in ("telegram_url_cache.json", "telegram_asset_cache.json"):
|
||||
path = cache_dir / name
|
||||
if path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError as e:
|
||||
_LOGGER.warning("Failed to remove %s: %s", path, e)
|
||||
|
||||
_url_cache = None
|
||||
_asset_cache = None
|
||||
_LOGGER.info("Cleared Telegram file caches: %s", removed or "none in memory")
|
||||
return {"cleared": True, "removed": removed}
|
||||
|
||||
|
||||
async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
"""Poll a tracker's provider for changes and dispatch notifications."""
|
||||
engine = get_engine()
|
||||
@@ -85,7 +190,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
# 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
|
||||
provider_type = provider.type
|
||||
@@ -191,6 +299,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
for event in events:
|
||||
assets_count = event.added_count or event.removed_count or 0
|
||||
log = EventLog(
|
||||
user_id=tracker.user_id,
|
||||
tracker_id=tracker_id,
|
||||
tracker_name=tracker.name,
|
||||
provider_id=provider.id,
|
||||
@@ -235,7 +344,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
for ld in link_data:
|
||||
# Apply per-link event filtering from 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")
|
||||
continue
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ fi
|
||||
# Start backend
|
||||
export NOTIFY_BRIDGE_DATA_DIR=./test-data
|
||||
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 \
|
||||
--host 0.0.0.0 --port 8420 > .backend.log 2>&1 &
|
||||
|
||||
|
||||
Reference in New Issue
Block a user