Compare commits

...

31 Commits

Author SHA1 Message Date
alexei.dolgolyov 1f880daa0c chore: release v0.3.2
Release / release (push) Successful in 1m22s
2026-04-23 13:38:28 +03:00
alexei.dolgolyov 1024085cdd fix(scheduler): honor app timezone for cron triggers and log scheduled events
CronTrigger.from_crontab was constructed without a timezone, so a cron like
'0 9 * * *' fired at 09:00 host-local instead of 09:00 in the admin-configured
timezone. Now all tracker/action cron triggers are built with the app tz, and
the setting endpoint rebuilds existing cron jobs when the tz changes (since
CronTrigger freezes its tz at construction time).

The scheduler provider also renders current_date/time/datetime/weekday in the
configured tz and exposes a new 'timezone' template variable.

EventLog entries for scheduled_message now include schedule_type,
cron_expression/interval_seconds, timezone, and fire_count, and the dashboard
shows the event type with a label/icon/color.
2026-04-23 13:35:49 +03:00
alexei.dolgolyov 5604c733d1 chore: release v0.3.1
Release / release (push) Successful in 1m1s
2026-04-22 19:27:45 +03:00
alexei.dolgolyov 3b7808aa9c perf(immich): TTL cache for album bodies and shared-link listings
Bot commands like /random, /latest, /memory refetch the same albums in
quick succession; the GET /api/albums/{id} response can be tens of MB on
large albums, and /api/shared-links has no per-album filter so every
get_shared_links call was already paying for the full server-wide list.

- Module-level 60s TTL cache for album bodies, keyed by
  (server_digest, album_id), 32-entry FIFO cap.  Module-scoped (not
  instance-scoped) because ImmichClient is constructed fresh per request
  in several places, so an instance cache would never survive a second
  caller.  Mirrors the existing _users_cache pattern.
- Module-level 60s TTL cache for the bucketed shared-links map, keyed by
  server_digest.  get_shared_links(album_id) now delegates to a single
  server-wide fetch that serves every album.
- server_digest hashes url+api_key so raw creds don't sit in dict keys.
- get_album(use_cache=False) escape hatch for paths that must observe
  current server state — wired into ImmichActionExecutor.execute (diffs
  the album to decide what to add) and ImmichServiceProvider.poll's
  full-fetch path (stale data would silently delay removal events).
- Async locks guard cache writes with under-lock re-check so concurrent
  misses collapse to one fetch.
2026-04-22 19:27:09 +03:00
alexei.dolgolyov 155d25edf9 chore: release v0.3.0
Release / release (push) Successful in 1m19s
2026-04-22 18:59:15 +03:00
alexei.dolgolyov 69711bbc84 feat(commands): keep chat-action hint alive during slow command fetches
Slow bot commands (/latest, /random, /favorites, /memory, /search,
/find, /person, /place, /summary) spend most of their wall time
fetching assets from the service provider, not uploading to Telegram.
Telegram chat actions expire after ~5s, so the previous one-shot hint
vanished long before media arrived — users saw nothing happening.

- TelegramClient.start_chat_action_keepalive: promoted from private
  helper to public API, posts the action every 4s until cancelled.
- telegram_send.telegram_chat_action: async context manager that
  starts the keep-alive task on enter and cancels + awaits it on
  exit. A None action makes it a no-op so callers don't branch.
- classify_command_chat_action: maps command name to the right
  Telegram action (upload_photo for media-returning commands, typing
  for /summary, None for fast DB-only commands like /status /events).
- webhook.py + telegram_poller.py: wrap handle_command in the context
  manager so the hint persists through the whole fetch+upload window
  in both webhook and long-poll modes.
2026-04-22 18:56:18 +03:00
alexei.dolgolyov fe38d20b96 perf(immich): skip full album fetch on idle ticks; delta-fetch for active ones
Optimizes polling for large Immich albums (tested path targets ~200k
assets). Combined impact on idle albums drops per-tick cost from ~150 MB
fetch to ~few hundred bytes; active albums fetch O(changes) instead of
O(library).

Core changes
- ImmichAlbumMeta + get_album_meta() using ?withoutAssets=true as a
  cheap change-detection probe.
- poll() fast-path: skip full fetch when meta fingerprint matches and
  no pending assets are outstanding.
- poll() delta-path: search/metadata with updatedAfter when fingerprint
  changed, falling back to full fetch on count decrease or mixed
  add+remove that delta can't reconcile.
- asyncio.gather over meta probes so a 20-album tracker pays one
  round-trip of latency instead of 20.
- Event payload cap (50 added / 200 removed) so a bulk import can't
  explode a Jinja template or exceed Telegram's message limits.
- Module-level users cache (1h TTL, sha256-keyed) shared across
  providers on the same Immich server.
- Tick-scoped shared-links cache via new
  get_all_shared_links_by_album() — one /api/shared-links request per
  tick instead of one per changed album.

Server changes
- meta_fingerprint JSON column on NotificationTrackerState + migration.
- watcher skips the asset_ids DB rewrite when the fingerprint didn't
  change, avoiding ~8 MB JSON writes on idle ticks for huge albums.
- Adaptive polling: after 10 empty ticks skip 1-in-2, after 30 skip
  1-in-4, reset on first detected change; resets on schedule changes.
- APScheduler jitter (interval/4, capped at 30s) to smooth thundering-
  herd bursts when many trackers share the same scan_interval.
2026-04-22 18:55:26 +03:00
alexei.dolgolyov d02616069d chore: release v0.2.8
Release / release (push) Successful in 1m58s
2026-04-22 18:02:09 +03:00
alexei.dolgolyov 7dae68fd93 fix(commands): match notification cache-key format so writes share one namespace
common._format_assets was passing cache_key=<bare asset UUID>, but the
notification dispatcher writes keys as <host>:<uuid> (derived from the
URL by extract_asset_id_from_url). Result: the two paths populated
different keys for the same asset, so neither could hit the other's
cached file_id and the WebUI stats only ever reflected the notification
side.

Drop the explicit cache_key — TelegramClient derives <host>:<uuid> from
the URL, identical to the notification path, so one file_id cached by
any dispatch or /random / /latest reply is reused by every later send.
2026-04-22 17:00:07 +03:00
alexei.dolgolyov e6481605ca chore: release v0.2.7
Release / release (push) Successful in 4m23s
2026-04-22 16:47:25 +03:00
alexei.dolgolyov 6de9a1289e fix(telegram): unify send routine across notifications and commands
- Route cache_key values that look like asset UUIDs through asset_cache
  in TelegramClient._get_cache_and_key. Single-asset sends previously
  stored file_ids in url_cache while the media-group path stored them
  in asset_cache, so repeat sends never hit.
- Extract build_asset_media_urls so the notification dispatcher
  (asset_to_media) and the bot command handlers (common._format_assets)
  share one rule for /video/playback vs thumbnail URLs.
- Add services/telegram_send.py as the single factory for constructing
  a TelegramClient. It always wires the shared aiohttp session and both
  file caches, so commands now reuse file_ids populated by notification
  dispatches (and vice versa) instead of re-uploading the same bytes.
- send_reply / send_media_group in commands/handler.py now delegate to
  the factory rather than constructing their own uncached clients.
2026-04-22 16:45:31 +03:00
alexei.dolgolyov 325eabd751 chore: release v0.2.6
Release / release (push) Successful in 1m19s
2026-04-22 16:29:24 +03:00
alexei.dolgolyov fab6169cf9 fix(commands): enrich search assets, surface variables for all command slots
- UI: command-template-configs now resolves slot variables against the
  active provider first (varsRef[provider_type][slot]) before falling back
  to shared entries, so provider-specific slots like /search, /status,
  /repos, /issues, /boards show the Variables button and autocomplete.
- Backend: /search, /find, /person, /place now normalize raw Immich API
  responses through build_asset_dict, extracting city/country from
  exifInfo and mapping isFavorite -> is_favorite so templates render
  location and favorite indicators.
- Telegram: extract build_telegram_asset_entry into a shared helper so
  the notification dispatcher and command media groups agree on video
  typing and /video/playback URLs; videos no longer render as still
  thumbnails in /latest /random /favorites media mode.
- Commands: send_media_group now reuses the same Telegram file_id caches
  as the notification dispatcher, avoiding re-upload churn for repeated
  commands.
2026-04-22 16:28:26 +03:00
alexei.dolgolyov 85311684d9 fix(settings): don't clobber webhook secret with its mask on save
GET /settings returns the Telegram webhook secret masked as "***<last4>".
The frontend binds that masked value into its state, and any Save ships it
back — the PUT handler then persisted the mask as the new secret, silently
invalidating HMAC for every webhook-mode bot. The next GET re-masks the
mask to itself, so the UI showed no corruption.

Treat incoming values that begin with "***" as "unchanged" for the
webhook-secret field. Empty strings still pass through (explicit clear).
2026-04-22 16:10:34 +03:00
alexei.dolgolyov d7daadadc2 chore: release v0.2.5
Release / release (push) Successful in 1m9s
2026-04-22 15:56:20 +03:00
alexei.dolgolyov e04ad16ca6 docs: add hotfix note to v0.2.4 release notes 2026-04-22 15:51:28 +03:00
alexei.dolgolyov d7d0a5d921 fix(settings): accept numeric values in update payload
Svelte bind:value on <input type="number"> coerces to a JS number, so the
frontend sends {telegram_cache_ttl_hours: 0} after v0.2.4. Pydantic v2
won't auto-coerce int -> str, which produced a 422 on every save that
touched a numeric setting.

- Widen numeric fields to int | str | None in SettingsUpdate.
- Normalize to str before persisting (DB column is text).
2026-04-22 15:44:40 +03:00
alexei.dolgolyov 93df538819 chore: release v0.2.4
Release / release (push) Successful in 1m18s
2026-04-22 15:36:08 +03:00
alexei.dolgolyov 2be608ba95 feat(cache): thumbhash-validated asset cache + settings UX overhaul
Cache engine:
- TelegramFileCache: configurable max_entries (LRU cap applies in both TTL
  and thumbhash modes), ttl_seconds<=0 disables TTL, stats() method.
- Dispatcher builds an asset.id -> thumbhash resolver from event.added_assets
  (Immich populates thumbhash in extra) and passes it to TelegramClient, so
  asset-cache entries invalidate on visual change rather than age.
- Watcher wires app settings into cache init: URL cache = TTL + LRU cap,
  asset cache = thumbhash + LRU cap. Adds soft-reset (in-memory only) used
  when cache params change.

Settings:
- New key telegram_asset_cache_max_entries (default 5000).
- telegram_cache_ttl_hours default bumped 48 -> 720 (30d); now URL-only.
- PUT /settings resets in-memory caches when cache keys change (files kept).
- New endpoints: GET/POST /settings/telegram-cache/stats and /clear.

Settings page:
- Cache stats card (count + size + oldest/newest per bucket) with a hint
  explaining that the size is cumulative uploaded-to-Telegram bytes.
- Clear-cache button behind a confirm modal.
- New TimezoneSelector + LocaleSelector components replace raw inputs.
- max-entries input, TTL range updated (0..8760, 0 = disabled).

Mobile nav:
- "More" panel now mirrors the full sidebar tree (groups + subnodes) so
  every destination is reachable on mobile; previously flat hand-picked list.
- Nav height uses env(safe-area-inset-bottom); panel bottom + z-index fixed
  so content can't visually overlay the bottom bar.

A11y / DOM warnings:
- Password-change form has a hidden username field for password-manager
  association; autocomplete hints on all three password inputs.
- Telegram webhook secret wrapped in a no-op form + autocomplete=off.

Bug fix:
- update_settings used any(await ... for ...) which raised TypeError at
  runtime (async generator not an iterator); replaced with explicit loop.
2026-04-22 15:09:59 +03:00
alexei.dolgolyov 5028f15f4f chore: release v0.2.3
Release / release (push) Successful in 1m17s
2026-04-22 03:30:45 +03:00
alexei.dolgolyov 5a232f18b8 feat(commands): drop tracker counts from /status
trackers_active / trackers_total are per-provider aggregates — once the
rest of /status is scoped to the chat's album set (total_albums and
last_event both filtered by the derived scope), leaving tracker counts
in would leak info about trackers this chat has no visibility into.

- _cmd_status no longer emits trackers_active / trackers_total.
- Immich default status templates (en, ru) just show Albums + Last event.
- Variable catalog updated so the template editor stops suggesting the
  removed vars for the Immich /status slot.
2026-04-22 03:28:05 +03:00
alexei.dolgolyov 3b76a09759 feat(commands): per-chat album scope derived from notification routing
The "per-chat album scope" feature stored on CommandTrackerListener was
really per-bot: listener_id = bot.id, and every chat that bot served
shared the same scope.  Commands like /albums, /random, /status,
/events leaked the full provider catalog into chats that were never
wired up to receive notifications from those trackers.

New model: the album scope for /commands in a given chat is derived
from the notification-routing graph.  For a (provider, bot, chat_id)
triple we walk TargetReceiver (chat_id match, enabled) →
NotificationTarget (telegram or broadcast parent) →
NotificationTrackerTarget → NotificationTracker (provider match) and
union their collection_ids.  That's the natural "what does this chat
get notifications about" set, and it becomes the command scope.

- New helper: command_utils.resolve_chat_album_scope(provider_id,
  bot_id, chat_id) -> set[str].  Empty set is the default for chats
  with no routing — commands return nothing rather than leaking the
  provider's catalog.
- Dispatcher computes the scope per (tracker, bot, chat) and threads
  it through handler.handle(..., allowed_album_ids=...).  Explicit
  CommandTrackerListener.allowed_album_ids override, when set, still
  wins verbatim (kept as an escape hatch for users who want a divergent
  scope for a whole bot).
- /status, /albums, /events, and all /_cmd_immich-routed commands
  (/random, /search, /find, /latest, /memory, /summary, /favorites,
  /place, /person) now intersect with the resolved scope.
- UI scope modal relabeled: it's an explicit *override for this bot*,
  not a per-chat setting.  Default is "derive from notification
  routing", which matches what users already configured elsewhere.

Also:
- /search, /find, /person, /place — _enrich_assets return value was
  discarded, dropping public_url enrichment.  Assign the return value.
- search_smart / search_metadata — consolidated into _search_items
  helper that logs non-200 responses and transport errors instead of
  silently returning [].  Makes "always no results" bugs actually
  diagnosable.  Also accepts the alternate {"assets": [...]} flat-list
  shape from older Immich versions.
- Immich search error bodies go through _redact_body so credentials
  echoed by authenticating proxies don't land in server logs.
2026-04-22 03:20:51 +03:00
alexei.dolgolyov 4ff3876e49 fix(commands): /albums honors per-chat scope, disable link previews
- /albums ignored CommandTrackerListener.allowed_album_ids and listed
  every album tracked by the provider — scoped chats saw neighbours'
  albums.  Thread the listener through _cmd_albums and apply the same
  intersect filter the media commands already use in _cmd_immich.
- Command text replies are listings (albums, events, people, ...) that
  embed multiple links; Telegram's default behavior of rendering a
  preview for the first URL is never useful here and ignored the
  "Disable link previews" toggle operators set on their target.  Always
  pass disable_web_page_preview=True from send_reply.
2026-04-22 03:03:09 +03:00
alexei.dolgolyov 83215473c7 chore: release v0.2.2
Release / release (push) Successful in 1m0s
2026-04-22 02:51:10 +03:00
alexei.dolgolyov 4e23d2b054 chore(compose): hardcode NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 in compose
This project ships for homelab use; downstream targets (Immich, Gitea,
...) sit on RFC1918 addresses which the SSRF guard blocks by default.
Setting the flag directly in compose — not via ${...} substitution —
avoids the Portainer gotcha where the stack-level "Environment variables"
panel is for compose-file substitutions only, not runtime container env.
Operators who want to run this on a public-facing box can drop the line.
2026-04-22 02:49:19 +03:00
alexei.dolgolyov f7d51b27d2 Revert "chore(compose): default NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 for homelab"
This reverts commit 3bb0585e43.
2026-04-22 02:47:09 +03:00
alexei.dolgolyov 3bb0585e43 chore(compose): default NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 for homelab
Homelab targets (Immich, Gitea, ...) are almost always on RFC1918
addresses, which the SSRF guard rejects by default.  Exporting the flag
to 1 in the compose file — overridable via the host environment —
matches how this project is actually deployed (TrueNAS / unraid / etc.)
without weakening the defense for anyone who sets it to 0 on a
public-facing box.
2026-04-22 02:46:10 +03:00
alexei.dolgolyov 58cba88c92 docs(immich-ssrf): surface NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS hint in error
Homelab/LAN Immich instances trip the SSRF guard (Host 192.168.x resolves
to blocked address).  The fix is to set NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
in the runtime env — call that out directly in the error message so
operators don't have to dig through source to find it.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:28:55 +03:00
62 changed files with 4307 additions and 492 deletions
+15 -69
View File
@@ -1,82 +1,28 @@
## v0.2.0 (2026-04-22) # v0.3.2 (2026-04-22)
First feature release since the initial `v0.1.0` cut: a broad polish pass across Scheduler now honors the app-level timezone. Before this, a cron expression
the backend, frontend, and schema, plus two fixes landed on top. like `0 9 * * *` was firing at 09:00 in the server's host-local tz — not
at 09:00 in the timezone the admin configured under Settings — because
`CronTrigger.from_crontab` was constructed without a tz. Same fix extends
to scheduler-provider template rendering so `{{ current_date }}` / `{{ current_time }}`
match the configured tz, and scheduled firings now show up in the dashboard
event feed with context.
### Features ## Bug Fixes
#### Immich commands & tracking - **Cron triggers honor app timezone** — all tracker and action cron triggers are now built with the configured app tz; `CronTrigger` freezes its tz at construction, so the `PUT /settings` endpoint rebuilds existing cron jobs when the timezone changes. Scheduled messages that were silently firing at host-local time will fire at the intended time after upgrade. ([1024085](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1024085))
- **Scheduler template context renders in the app tz** — `current_date`, `current_time`, `current_datetime`, `current_weekday` in scheduler-provider templates are now formatted in the configured timezone instead of UTC/host-local. Custom templates that built date strings in the wrong tz now render correctly. ([1024085](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1024085))
- Per-chat album scope for Immich commands (`/search`, `/latest`, `/memory`, etc.) with a new *Edit album scope* modal on command-tracker listeners (inherit or explicit multiselect) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e)) ## Features
- `/search` and `/find` accept a trailing page number; Immich client `search_smart` / `search_metadata` take a `page` param ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Auto-organize rules now set the target album's thumbnail to the first added image (falls back to any asset type) when the album has none ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
#### Dashboard & status - **New `timezone` template variable** — scheduler-provider templates can reference `{{ timezone }}` to display the active IANA tz alongside a date/time. Added across the context builder, variable catalog, sample context, and runtime validator (per the project's 6-file sync rule for template vars). ([1024085](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1024085))
- **`scheduled_message` events surface in the dashboard feed** — `EventLog` entries for scheduled firings now carry `schedule_type`, `cron_expression` / `interval_seconds`, `timezone`, and `fire_count`; the dashboard renders them with a dedicated label, icon, and colour so operators can see at a glance when scheduled messages actually fired. ([1024085](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1024085))
- Action events (`action_success` / `action_partial` / `action_failed`) are emitted on every non-dry-run and surfaced on the dashboard with icons, filters, and colors ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Clear-events button + confirm modal on the dashboard (`DELETE /api/status/events`, scoped to the current user) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Event rows render live tracker/provider/action names via FK join, with snapshot fallback when an entity has been deleted ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
#### Backup & restore
- Full backup restore flow: prepare-restore writes a pending marker, a restart banner offers Apply-now / Apply-later, and the lifespan hook applies on next startup and archives under `data/applied_restores/` ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Manual *Create backup* button on the Backup page (`POST /api/backup/files`, same format as scheduled) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- `apply-restart` sends SIGTERM so the lifespan shutdown runs; `NOTIFY_BRIDGE_SUPERVISED` env var gates the button ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
#### Users & deletion protection
- `PATCH /api/users/{id}` for username and role changes with a last-admin guard, plus an *Edit user* modal on the Users page ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Deletion protection now returns structured `{message, entity, blocked_by}`; `ApiError` carries `.blockedBy` and the new `BlockedByModal` is wired into 8 deletion flows ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
#### Telegram
- Per-receiver locale for Telegram test messages (resolves `TelegramChat.language_override` per chat instead of applying the first receiver's locale to everyone) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Telegram poller detects the "webhook is active" 409 and auto-calls `deleteWebhook` for bots whose DB `update_mode` is polling (throttled per bot) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- New `TelegramClient.get_chat` and `set_album_thumbnail` helpers (CLAUDE.md rule 6 — all Bot API calls go through `TelegramClient`) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
#### Form UX
- Auto-select first available tracking / template / command / config + bot on create forms (trackers, command-trackers, targets, template/command configs) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Global provider selector is visible even when there is only one provider ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Telegram target `disable_url_preview` defaults to `true` ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
### Bug Fixes
- Telegram target list: load chats/listeners **before** expanding so the slide animation computes the right height ([cf4976d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cf4976d))
- Test dispatch falls back to tracker defaults for tracking/template config (matching `load_link_data`), distinguishes "no template config linked" vs. "slot missing in linked config", and the frontend `testTrackerTarget` now treats `{success:false,error:...}` in a 2xx body as a failure instead of flashing a success snack ([80c034d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/80c034d))
- Immich person-asset lookup switched from the removed `GET /api/people/{id}/assets` to `POST /api/search/metadata` with `personIds` — fixes `/person` and auto-organize rules silently returning zero candidates on Immich 1.106+ ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- `add_assets_to_album` now surfaces the Immich error body on non-2xx responses ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Immich tracker "Checking links" parallelised (concurrency cap 6) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
---
### Development / Internal
#### Schema / Migrations
- `event_log`: add `user_id`, `action_id`, `action_name` (+ defensive migration and backfill of `user_id` from `notification_tracker`) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- `command_tracker_listener`: add `allowed_album_ids` ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
#### Refactors & Internals
- Bounded concurrency (semaphores) in `NotificationDispatcher._preload_asset_data` and `_refresh_telegram_chat_titles`; chat-title sweep extended to 24h since `save_chat_from_webhook` covers active chats opportunistically ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Periodic-summary test path reuses the shared `collect_scheduled_assets` primitive (`limit=0`) so test and production go through one path ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- New `fetchAuth` helper for multipart/binary calls (reuses `api()`'s refresh + `ApiError` mapping) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- `parseDate` helper for consistent UTC date rendering ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
#### Seeds
- Rename "Default Commands" → "Default Immich Commands"; `track_assets_removed` now defaults to `False` ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
--- ---
<details> <details>
<summary>All Commits</summary> <summary>All Commits</summary>
| Hash | Message | Author | - [1024085](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1024085) — fix(scheduler): honor app timezone for cron triggers and log scheduled events *(alexei.dolgolyov)*
|------|---------|--------|
| [cf4976d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cf4976d) | fix(telegram): load chats/listeners before expanding to fix slide animation height | alexei.dolgolyov |
| [80c034d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/80c034d) | fix(test-dispatch): fall back to tracker defaults, surface soft errors | alexei.dolgolyov |
| [a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e) | feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events | alexei.dolgolyov |
</details> </details>
+4
View File
@@ -12,6 +12,10 @@ services:
environment: environment:
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)} - 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:-*} - 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: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"]
interval: 30s interval: 30s
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"private": true, "private": true,
"version": "0.2.0", "version": "0.3.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+46 -27
View File
@@ -94,6 +94,9 @@ async function doRefreshAccessToken(): Promise<boolean> {
} }
const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_TIMEOUT_MS = 30_000;
// Longer cap for fetchAuth — it's used for multipart uploads (backup restore)
// and binary downloads where a 30s limit can cut off a legit slow upload.
const DEFAULT_FETCHAUTH_TIMEOUT_MS = 120_000;
export async function api<T = any>( export async function api<T = any>(
path: string, path: string,
@@ -170,42 +173,58 @@ export async function api<T = any>(
*/ */
export async function fetchAuth( export async function fetchAuth(
path: string, path: string,
options: RequestInit = {}, options: RequestInit & { timeoutMs?: number } = {},
): Promise<Response> { ): Promise<Response> {
const token = getToken(); const token = getToken();
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) }; const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
if (token) headers['Authorization'] = `Bearer ${token}`; if (token) headers['Authorization'] = `Bearer ${token}`;
const url = path.startsWith('http') ? path : `${API_BASE}${path}`; const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
let res = await fetch(url, { ...options, headers });
if (res.status === 401 && token) { // Abort after timeout so uploads/downloads don't hang indefinitely if
const refreshed = await refreshAccessToken(); // the backend stops responding. Callers can override per-request via
if (refreshed) { // options.timeoutMs or pass their own signal to opt out.
headers['Authorization'] = `Bearer ${getToken()}`; const { timeoutMs, ...fetchOptions } = options;
res = await fetch(url, { ...options, headers }); 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) { if (res.status === 401) {
clearTokens(); clearTokens();
if (typeof window !== 'undefined') window.location.href = '/login'; if (typeof window !== 'undefined') window.location.href = '/login';
throw new ApiError('Unauthorized', 401); 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; 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,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: 23 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>
+53 -7
View File
@@ -78,6 +78,7 @@
"collectionRenamed": "collection renamed", "collectionRenamed": "collection renamed",
"collectionDeleted": "collection deleted", "collectionDeleted": "collection deleted",
"sharingChanged": "sharing changed", "sharingChanged": "sharing changed",
"scheduledMessage": "scheduled message",
"actionSuccess": "action run", "actionSuccess": "action run",
"actionPartial": "action partial", "actionPartial": "action partial",
"actionFailed": "action failed", "actionFailed": "action failed",
@@ -523,6 +524,9 @@
"memorySource": "Memory source", "memorySource": "Memory source",
"memorySourceAlbums": "Scan tracked albums", "memorySourceAlbums": "Scan tracked albums",
"memorySourceNative": "Immich native memories", "memorySourceNative": "Immich native memories",
"quietHours": "Quiet hours",
"quietHoursStart": "Start",
"quietHoursEnd": "End",
"test": "Test", "test": "Test",
"confirmDelete": "Delete this tracking config?", "confirmDelete": "Delete this tracking config?",
"sortNone": "None", "sortNone": "None",
@@ -668,11 +672,29 @@
"telegram": "Telegram", "telegram": "Telegram",
"webhookSecret": "Webhook Secret", "webhookSecret": "Webhook Secret",
"webhookSecretHint": "Secret token to verify webhook requests from Telegram", "webhookSecretHint": "Secret token to verify webhook requests from Telegram",
"cacheTtl": "Media Cache TTL (hours)", "cacheTtl": "URL Cache TTL (hours)",
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading", "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", "locales": "Template Languages",
"supportedLocales": "Supported Locales", "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" "saved": "Settings saved"
}, },
"hints": { "hints": {
@@ -680,6 +702,7 @@
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.", "scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
"memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.", "memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.",
"memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).", "memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).",
"quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:0007:00 are supported.",
"favoritesOnly": "Only include assets marked as favorites.", "favoritesOnly": "Only include assets marked as favorites.",
"maxAssets": "Maximum number of asset details to include in a single notification message.", "maxAssets": "Maximum number of asset details to include in a single notification message.",
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.", "periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
@@ -796,17 +819,40 @@
"selectBot": "Select bot...", "selectBot": "Select bot...",
"listenerType": "telegram_bot", "listenerType": "telegram_bot",
"editScope": "Edit album scope", "editScope": "Edit album scope",
"scopeAll": "all albums", "scopeAll": "derived from notification routing",
"albumsShort": "albums", "albumsShort": "albums",
"scopeTitle": "Album Scope for This Chat", "scopeTitle": "Album Scope Override for This Bot",
"scopeDescription": "Restrict which tracked albums this chat can query via commands. Leave on \"inherit\" to allow all albums from the tracker.", "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: allow all tracked albums", "scopeInherit": "Inherit: derive from notification routing",
"noCollections": "No albums available." "noCollections": "No albums available."
}, },
"snackbar": { "snackbar": {
"showDetails": "Show details", "showDetails": "Show details",
"hideDetails": "Hide 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 (23 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": { "snack": {
"eventsCleared": "{count} event(s) cleared", "eventsCleared": "{count} event(s) cleared",
"providerSaved": "Provider saved", "providerSaved": "Provider saved",
+53 -7
View File
@@ -78,6 +78,7 @@
"collectionRenamed": "альбом переименован", "collectionRenamed": "альбом переименован",
"collectionDeleted": "альбом удалён", "collectionDeleted": "альбом удалён",
"sharingChanged": "изменение доступа", "sharingChanged": "изменение доступа",
"scheduledMessage": "запланированное сообщение",
"actionSuccess": "действие выполнено", "actionSuccess": "действие выполнено",
"actionPartial": "действие частично", "actionPartial": "действие частично",
"actionFailed": "действие провалено", "actionFailed": "действие провалено",
@@ -523,6 +524,9 @@
"memorySource": "Источник воспоминаний", "memorySource": "Источник воспоминаний",
"memorySourceAlbums": "Сканировать альбомы", "memorySourceAlbums": "Сканировать альбомы",
"memorySourceNative": "Встроенные воспоминания Immich", "memorySourceNative": "Встроенные воспоминания Immich",
"quietHours": "Тихие часы",
"quietHoursStart": "Начало",
"quietHoursEnd": "Конец",
"test": "Тест", "test": "Тест",
"confirmDelete": "Удалить эту конфигурацию отслеживания?", "confirmDelete": "Удалить эту конфигурацию отслеживания?",
"sortNone": "Нет", "sortNone": "Нет",
@@ -668,11 +672,29 @@
"telegram": "Telegram", "telegram": "Telegram",
"webhookSecret": "Секрет вебхука", "webhookSecret": "Секрет вебхука",
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram", "webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
"cacheTtl": "TTL кэша медиа (часы)", "cacheTtl": "TTL URL-кэша (часы)",
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой", "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": "Языки шаблонов", "locales": "Языки шаблонов",
"supportedLocales": "Поддерживаемые локали", "supportedLocales": "Поддерживаемые локали",
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)", "supportedLocalesHint": "Языки, доступные для редактирования шаблонов уведомлений и команд. Встроенные шаблоны поставляются для английского и русского; другие языки начинают с пустых.",
"saved": "Настройки сохранены" "saved": "Настройки сохранены"
}, },
"hints": { "hints": {
@@ -680,6 +702,7 @@
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.", "scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.", "memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).", "memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:0007:00.",
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.", "favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
"maxAssets": "Максимальное количество ассетов в одном уведомлении.", "maxAssets": "Максимальное количество ассетов в одном уведомлении.",
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.", "periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
@@ -796,17 +819,40 @@
"selectBot": "Выберите бота...", "selectBot": "Выберите бота...",
"listenerType": "telegram_bot", "listenerType": "telegram_bot",
"editScope": "Изменить область альбомов", "editScope": "Изменить область альбомов",
"scopeAll": "все альбомы", "scopeAll": "из маршрутизации уведомлений",
"albumsShort": "альбомов", "albumsShort": "альбомов",
"scopeTitle": "Область альбомов для этого чата", "scopeTitle": "Переопределение области альбомов для этого бота",
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.", "scopeDescription": "По умолчанию команды этого бота видят только альбомы, уведомления которых приходят в его чаты (вычисляется из ваших трекеров уведомлений). Задайте явный список здесь, чтобы расширить или сузить этот набор для всех чатов данного бота.",
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы", "scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
"noCollections": "Нет доступных альбомов." "noCollections": "Нет доступных альбомов."
}, },
"snackbar": { "snackbar": {
"showDetails": "Показать детали", "showDetails": "Показать детали",
"hideDetails": "Скрыть детали" "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": { "snack": {
"eventsCleared": "Очищено событий: {count}", "eventsCleared": "Очищено событий: {count}",
"providerSaved": "Провайдер сохранён", "providerSaved": "Провайдер сохранён",
+8
View File
@@ -88,6 +88,14 @@ export const immichDescriptor: ProviderDescriptor = {
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' }, { key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' },
], ],
}, },
{
key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours',
enabledField: 'quiet_hours_enabled', enabledDefault: false,
fields: [
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'number', defaultValue: '22:00' },
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'number', defaultValue: '07:00' },
],
},
], ],
collectionMeta: { collectionMeta: {
+3
View File
@@ -192,6 +192,9 @@ export interface TrackingConfig {
memory_favorite_only: boolean; memory_favorite_only: boolean;
memory_asset_type: string; memory_asset_type: string;
memory_min_rating: number; memory_min_rating: number;
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
created_at: string; created_at: string;
} }
+63 -40
View File
@@ -226,24 +226,15 @@
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' }, { href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
]); ]);
// "More" panel items — everything not in the bottom bar // "More" panel mirrors the full desktop sidebar tree so every subnode is
const mobileMoreItems = $derived<NavItem[]>([ // reachable on mobile (previously it was a flat hand-picked list that
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' }, // hid all target types, bot channels, and several nested pages).
{ 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' },
] : []),
]);
let mobileMoreOpen = $state(false); let mobileMoreOpen = $state(false);
function closeMobileMore() {
mobileMoreOpen = false;
}
const isAuthPage = $derived( const isAuthPage = $derived(
page.url.pathname === '/login' || page.url.pathname === '/setup' page.url.pathname === '/login' || page.url.pathname === '/setup'
); );
@@ -538,7 +529,7 @@
</aside> </aside>
<!-- Mobile bottom nav --> <!-- 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} {#each mobileNavItems as item}
<a href={item.href} aria-label={t(item.key)} <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" 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> </button>
</nav> </nav>
<!-- Mobile "More" panel --> <!-- Mobile "More" panel — mirrors the full desktop nav tree -->
{#if mobileMoreOpen} {#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);" <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> onclick={closeMobileMore} role="presentation"></div>
<div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;" <div class="mobile-more-panel" style="position: fixed; bottom: 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 }}> transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length >= 1} {#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);"> <div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact /> <IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
</div> </div>
{/if} {/if}
<div class="grid grid-cols-3 gap-2"> <div class="space-y-3">
{#each mobileMoreItems as item} {#each navEntries as entry}
<a href={item.href} {#if isGroup(entry)}
onclick={() => mobileMoreOpen = false} <div>
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200" <div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'};" style="color: var(--color-muted-foreground);">
> <MdiIcon name={entry.icon} size={13} />
<MdiIcon name={item.icon} size={20} /> <span>{t(entry.key)}</span>
<span class="text-xs text-center leading-tight">{t(item.key)}</span> </div>
</a> <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} {/each}
<button onclick={() => { mobileMoreOpen = false; logout(); }} <div class="pt-2" style="border-top: 1px solid var(--color-border);">
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200" <button onclick={() => { closeMobileMore(); logout(); }}
style="color: var(--color-muted-foreground);"> class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
<MdiIcon name="mdiLogout" size={20} /> style="color: var(--color-muted-foreground);">
<span class="text-xs text-center leading-tight">{t('nav.logout')}</span> <MdiIcon name="mdiLogout" size={18} />
</button> <span class="text-sm">{t('nav.logout')}</span>
</button>
</div>
</div> </div>
</div> </div>
{/if} {/if}
<!-- Main content --> <!-- 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} {#key page.url.pathname}
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}> <div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
{@render children()} {@render children()}
@@ -611,19 +631,22 @@
<!-- Password change modal --> <!-- Password change modal -->
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}> <Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
<form onsubmit={changePassword} class="space-y-3"> <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> <div>
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label> <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)]" /> class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div> </div>
<div> <div>
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label> <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)]" /> class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div> </div>
<div> <div>
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label> <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)]" /> class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div> </div>
{#if pwdMsg} {#if pwdMsg}
+3
View File
@@ -223,6 +223,7 @@
collection_renamed: 'dashboard.collectionRenamed', collection_renamed: 'dashboard.collectionRenamed',
collection_deleted: 'dashboard.collectionDeleted', collection_deleted: 'dashboard.collectionDeleted',
sharing_changed: 'dashboard.sharingChanged', sharing_changed: 'dashboard.sharingChanged',
scheduled_message: 'dashboard.scheduledMessage',
action_success: 'dashboard.actionSuccess', action_success: 'dashboard.actionSuccess',
action_partial: 'dashboard.actionPartial', action_partial: 'dashboard.actionPartial',
action_failed: 'dashboard.actionFailed', action_failed: 'dashboard.actionFailed',
@@ -231,11 +232,13 @@
const eventIcons: Record<string, string> = { const eventIcons: Record<string, string> = {
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus', assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant', collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
scheduled_message: 'mdiCalendarClock',
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle', action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
}; };
const eventColors: Record<string, string> = { const eventColors: Record<string, string> = {
assets_added: '#059669', assets_removed: '#ef4444', assets_added: '#059669', assets_removed: '#ef4444',
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b', collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
scheduled_message: '#8b5cf6',
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626', action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
}; };
@@ -117,6 +117,14 @@
return form.slots[slotName]?.[activeLocale] || ''; return form.slots[slotName]?.[activeLocale] || '';
} }
/** Resolve variable reference for a slot, preferring provider-specific over shared. */
function getVarsFor(slotName: string) {
const providerVars = varsRef[form.provider_type];
return providerVars?.[slotName] ?? varsRef[slotName];
}
let modalVars = $derived(showVarsFor ? getVarsFor(showVarsFor) : null);
/** Set slot template for current locale (immutable update). */ /** Set slot template for current locale (immutable update). */
function setSlotValue(slotName: string, value: string) { function setSlotValue(slotName: string, value: string) {
form.slots = { form.slots = {
@@ -369,7 +377,7 @@
{t('templateConfig.preview')} {t('templateConfig.preview')}
</button> </button>
{/if} {/if}
{#if varsRef[slot.name]} {#if getVarsFor(slot.name)}
<button type="button" onclick={() => showVarsFor = slot.name} <button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button> class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if} {/if}
@@ -385,7 +393,7 @@
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }} onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3} rows={3}
errorLine={slotErrorLines[slot.name] || null} errorLine={slotErrorLines[slot.name] || null}
variables={varsRef[slot.name] || undefined} variables={getVarsFor(slot.name) || undefined}
/> />
{/if} {/if}
@@ -468,11 +476,11 @@
<!-- Variables reference modal --> <!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}> <Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]} {#if showVarsFor && modalVars}
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{varsRef[showVarsFor].description}</p> <p class="text-sm text-[var(--color-muted-foreground)] mb-3">{modalVars.description}</p>
<div class="space-y-1"> <div class="space-y-1">
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p> <p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]} {#each Object.entries(modalVars.variables || {}) as [name, desc]}
<div class="flex items-start gap-2 text-sm"> <div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code> <code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span> <span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
@@ -484,11 +492,19 @@
['album_fields', 'album', 'Album fields'], ['album_fields', 'album', 'Album fields'],
['command_fields', 'cmd', 'Command fields'], ['command_fields', 'cmd', 'Command fields'],
['event_fields', 'event', 'Event fields'], ['event_fields', 'event', 'Event fields'],
['repo_fields', 'repo', 'Repository fields'],
['issue_fields', 'issue', 'Issue fields'],
['pr_fields', 'pr', 'Pull request fields'],
['commit_fields', 'c', 'Commit fields'],
['board_fields', 'board', 'Board fields'],
['card_fields', 'card', 'Card fields'],
['list_fields', 'lst', 'List fields'],
['device_fields', 'd', 'Device fields'],
] as [fieldKey, prefix, title]} ] as [fieldKey, prefix, title]}
{#if varsRef[showVarsFor][fieldKey]} {#if modalVars[fieldKey]}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]"> <div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p> <p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p>
{#each Object.entries(varsRef[showVarsFor][fieldKey]) as [name, desc]} {#each Object.entries(modalVars[fieldKey]) as [name, desc]}
<div class="flex items-start gap-2 text-sm"> <div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code> <code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span> <span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
+124 -7
View File
@@ -9,25 +9,66 @@
import Hint from '$lib/components/Hint.svelte'; import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.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 loaded = $state(false);
let saving = $state(false); let saving = $state(false);
let clearingCache = $state(false);
let confirmClearCache = $state(false);
let error = $state(''); let error = $state('');
let settings = $state({ let settings = $state({
external_url: '', external_url: '',
telegram_webhook_secret: '', telegram_webhook_secret: '',
telegram_cache_ttl_hours: '48', telegram_cache_ttl_hours: '720',
telegram_asset_cache_max_entries: '5000',
supported_locales: 'en,ru', 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 () => { onMount(async () => {
try { try {
settings = await api('/settings'); settings = await api('/settings');
await loadCacheStats();
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
finally { loaded = true; } 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() { async function save() {
saving = true; error = ''; saving = true; error = '';
try { try {
@@ -36,6 +77,17 @@
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
saving = false; 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> </script>
<PageHeader title={t('settings.title')} description={t('settings.description')} /> <PageHeader title={t('settings.title')} description={t('settings.description')} />
@@ -57,6 +109,10 @@
<input bind:value={settings.external_url} placeholder="https://notify.example.com" <input bind:value={settings.external_url} placeholder="https://notify.example.com"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" /> class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div> </div>
<div>
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
<TimezoneSelector bind:value={settings.timezone} />
</div>
</div> </div>
</Card> </Card>
@@ -69,14 +125,68 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label> <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')} <form onsubmit={(e) => e.preventDefault()} autocomplete="off">
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" /> <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>
<div> <div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label> <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)]" /> class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div> </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> </div>
</Card> </Card>
@@ -88,9 +198,8 @@
</h3> </h3>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="block text-xs font-medium mb-1">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label> <label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
<input bind:value={settings.supported_locales} placeholder="en,ru,de,fr" <LocaleSelector bind:value={settings.supported_locales} />
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div> </div>
</div> </div>
</Card> </Card>
@@ -99,4 +208,12 @@
{saving ? t('common.loading') : t('common.save')} {saving ? t('common.loading') : t('common.save')}
</Button> </Button>
</div> </div>
<ConfirmModal open={confirmClearCache}
title={t('settings.clearCacheConfirmTitle')}
message={t('settings.clearCacheConfirm')}
confirmLabel={t('settings.clearCacheConfirmBtn')}
confirmIcon="mdiDeleteSweep"
onconfirm={clearTelegramCache}
oncancel={() => confirmClearCache = false} />
{/if} {/if}
@@ -181,9 +181,12 @@
{:else if field.type === 'grid-select' && field.gridItems} {:else if field.type === 'grid-select' && field.gridItems}
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact /> <IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
{:else} {:else}
<input type={field.key.includes('date') ? 'date' : field.key.includes('times') ? 'text' : 'number'} <input type={field.key.includes('date') ? 'date'
: field.key.startsWith('quiet_hours_') ? 'time'
: field.key.includes('times') ? 'text'
: 'number'}
bind:value={form[field.key]} min={field.min} max={field.max} bind:value={form[field.key]} min={field.min} max={field.max}
placeholder={field.key.includes('times') ? String(field.defaultValue ?? '') : ''} placeholder={field.key.includes('times') || field.key.startsWith('quiet_hours_') ? String(field.defaultValue ?? '') : ''}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if} {/if}
</div> </div>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-core" name = "notify-bridge-core"
version = "0.2.0" version = "0.3.2"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates" description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -46,6 +46,7 @@ from .receiver import (
from .telegram.cache import TelegramFileCache from .telegram.cache import TelegramFileCache
from .telegram.client import TelegramClient from .telegram.client import TelegramClient
from .telegram.media import ( from .telegram.media import (
build_telegram_asset_entry,
extract_asset_id_from_url, extract_asset_id_from_url,
is_asset_cache_key, is_asset_cache_key,
is_asset_id, is_asset_id,
@@ -266,23 +267,19 @@ class NotificationDispatcher:
# Prefer internal URL for fetching (LAN speed vs public internet) # Prefer internal URL for fetching (LAN speed vs public internet)
internal_url = (target.provider_internal_url or "").rstrip("/") internal_url = (target.provider_internal_url or "").rstrip("/")
external_url = (target.provider_external_url or "").rstrip("/") external_url = (target.provider_external_url or "").rstrip("/")
provider_urls = [u for u in (internal_url, external_url) if u]
assets = [] assets = []
media_assets: list[Any] = [] # aligned with `assets` for preload media_assets: list[Any] = [] # aligned with `assets` for preload
for asset in event.added_assets[:max_media]: for asset in event.added_assets[:max_media]:
url = asset.preview_url or asset.thumbnail_url or asset.full_url url = asset.preview_url or asset.thumbnail_url or asset.full_url
if url: asset_entry = build_telegram_asset_entry(
# Rewrite external URL to internal for faster LAN fetching url=url or "",
if internal_url and external_url and url.startswith(external_url): media_type=asset.type.value,
url = internal_url + url[len(external_url):] api_key=target.provider_api_key,
asset_type = "video" if asset.type.value == "video" else "photo" internal_url=internal_url,
asset_headers = {} external_url=external_url,
if target.provider_api_key and any(url.startswith(u) for u in provider_urls): cache_key=asset.extra.get("cache_key"),
asset_headers["x-api-key"] = target.provider_api_key )
asset_entry: dict[str, Any] = {"url": url, "type": asset_type, "headers": asset_headers} if asset_entry is not None:
# Pass explicit cache_key if set by provider (e.g. Google Photos)
if asset.extra.get("cache_key"):
asset_entry["cache_key"] = asset.extra["cache_key"]
assets.append(asset_entry) assets.append(asset_entry)
media_assets.append(asset) media_assets.append(asset)
@@ -294,10 +291,24 @@ class NotificationDispatcher:
await self._preload_asset_data(assets, media_assets, session, max_size) await self._preload_asset_data(assets, media_assets, session, max_size)
default_message = self._render_message(event, target, target.locale) 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( client = TelegramClient(
session, bot_token, session, bot_token,
url_cache=self._url_cache, url_cache=self._url_cache,
asset_cache=self._asset_cache, asset_cache=self._asset_cache,
thumbhash_resolver=thumbhash_resolver,
) )
for receiver in target.receivers: for receiver in target.receivers:
@@ -11,56 +11,69 @@ from notify_bridge_core.storage import StorageBackend
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60 DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
DEFAULT_MAX_ENTRIES = 5000
class TelegramFileCache: class TelegramFileCache:
"""Cache for Telegram file_ids to avoid re-uploading media. """Cache for Telegram file_ids to avoid re-uploading media.
Supports two validation modes: Two complementary invalidation strategies, usable together or separately:
- TTL mode (default): entries expire after a configured time-to-live - TTL: entries expire after ``ttl_seconds``. Set to 0 to disable TTL
- Thumbhash mode: entries validated by comparing stored thumbhash with current (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__( def __init__(
self, self,
backend: StorageBackend, backend: StorageBackend,
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL, ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
use_thumbhash: bool = False, use_thumbhash: bool = False,
max_entries: int = DEFAULT_MAX_ENTRIES,
) -> None: ) -> None:
self._backend = backend self._backend = backend
self._data: dict[str, Any] | None = None self._data: dict[str, Any] | None = None
self._ttl_seconds = ttl_seconds self._ttl_seconds = ttl_seconds
self._use_thumbhash = use_thumbhash self._use_thumbhash = use_thumbhash
self._max_entries = max_entries
async def async_load(self) -> None: async def async_load(self) -> None:
self._data = await self._backend.load() or {"files": {}} self._data = await self._backend.load() or {"files": {}}
await self._cleanup_expired() await self._cleanup_expired()
async def _cleanup_expired(self) -> None: 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: if not self._data or "files" not in self._data:
return return
files = self._data["files"]
changed = False
now = datetime.now(timezone.utc) # TTL sweep — only when TTL validation is active (i.e. no thumbhash
expired = [ # mode and a positive TTL). In thumbhash mode we rely entirely on
url for url, entry in self._data["files"].items() # content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
if entry.get("cached_at") and # cache forever, subject only to the size cap.
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds if not self._use_thumbhash and self._ttl_seconds > 0:
] now = datetime.now(timezone.utc)
expired = [
if 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: 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) await self._backend.save(self._data)
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None: def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
@@ -77,7 +90,7 @@ class TelegramFileCache:
if stored and stored != thumbhash: if stored and stored != thumbhash:
del self._data["files"][key] del self._data["files"][key]
return None return None
else: elif self._ttl_seconds > 0:
cached_at_str = entry.get("cached_at") cached_at_str = entry.get("cached_at")
if cached_at_str: if cached_at_str:
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds() age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
@@ -152,3 +165,32 @@ class TelegramFileCache:
async def async_remove(self) -> None: async def async_remove(self) -> None:
await self._backend.remove() await self._backend.remove()
self._data = None 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,
}
@@ -89,6 +89,18 @@ class TelegramClient:
self, url: str | None, cache_key: str | None = None, self, url: str | None, cache_key: str | None = None,
) -> tuple[TelegramFileCache | None, str | None, str | None]: ) -> tuple[TelegramFileCache | None, str | None, str | None]:
if cache_key: if cache_key:
# Route asset-UUID cache keys to the asset cache so single-item
# sends hit the same cache the media-group path uses. Without
# this, a command returning one photo stored file_ids in the
# URL cache and a command returning multiple stored them in
# the asset cache — repeated sends never hit.
if is_asset_cache_key(cache_key):
bare_id = asset_id_from_cache_key(cache_key)
thumbhash = (
self._thumbhash_resolver(bare_id)
if self._thumbhash_resolver else None
)
return self._asset_cache, cache_key, thumbhash
return self._url_cache, cache_key, None return self._url_cache, cache_key, None
if url: if url:
if is_asset_id(url): if is_asset_id(url):
@@ -217,7 +229,7 @@ class TelegramClient:
typing_task = None typing_task = None
if chat_action: if chat_action:
typing_task = self._start_typing_indicator(chat_id, chat_action) typing_task = self.start_chat_action_keepalive(chat_id, chat_action)
try: try:
if len(assets) == 1 and assets[0].get("type") == "photo": if len(assets) == 1 and assets[0].get("type") == "photo":
@@ -300,6 +312,16 @@ class TelegramClient:
# Retry without parse_mode on parse errors # Retry without parse_mode on parse errors
desc = str(result.get("description", "")) desc = str(result.get("description", ""))
if "parse" in desc.lower(): if "parse" in desc.lower():
# Log loudly: a parse failure means the template author (or
# an asset field) is producing malformed HTML. Silent
# fallback hides bugs and makes XSS-via-unescaped-field
# harder to spot. Do not log the full payload — it may
# contain secrets.
_LOGGER.warning(
"Telegram rejected parse_mode=%s (%r); retrying as plain text. "
"Check template output for unescaped characters.",
payload.get("parse_mode"), desc,
)
payload.pop("parse_mode", None) payload.pop("parse_mode", None)
async with self._session.post(telegram_url, json=payload) as retry_resp: async with self._session.post(telegram_url, json=payload) as retry_resp:
retry_result = await retry_resp.json() retry_result = await retry_resp.json()
@@ -318,7 +340,13 @@ class TelegramClient:
except aiohttp.ClientError: except aiohttp.ClientError:
return False return False
def _start_typing_indicator(self, chat_id: str, action: str = "typing") -> asyncio.Task: def start_chat_action_keepalive(self, chat_id: str, action: str = "typing") -> asyncio.Task:
"""Repeatedly post ``action`` every 4s until the returned task is cancelled.
Telegram chat actions expire after ~5s, so callers that want the hint
to persist through longer work (fetching assets, multi-chunk uploads)
need a keep-alive. Cancel the task in a ``finally`` to stop it.
"""
async def action_loop() -> None: async def action_loop() -> None:
try: try:
while True: while True:
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from typing import Final from typing import Any, Final
from urllib.parse import urlparse from urllib.parse import urlparse
# Telegram constants # Telegram constants
@@ -52,6 +52,65 @@ def extract_asset_id_from_url(url: str) -> str | None:
return None return None
def build_telegram_asset_entry(
*,
url: str,
media_type: str,
api_key: str | None = None,
internal_url: str = "",
external_url: str = "",
cache_key: str | None = None,
) -> dict[str, Any] | None:
"""Build a ``TelegramClient.send_notification`` asset dict from raw fields.
Shared by the notification dispatcher and provider command handlers so
both paths agree on media typing, URL rewriting, and auth headers. In
particular: video assets MUST be typed ``"video"`` and point at a real
video endpoint (e.g. Immich ``/video/playback``) if they are sent as
``"photo"`` pointing at a thumbnail URL, Telegram delivers a still image
for every video in a media group and the user sees a dead poster frame
instead of a playable clip.
Args:
url: Source URL for the asset bytes. Prefer a transcoded/preview
URL for videos (``/video/playback``) and a preview-sized
thumbnail for photos.
media_type: Case-insensitive type token. Accepts ``"video"``/
``"VIDEO"``/``MediaType.VIDEO`` or any photo-like string.
api_key: Optional API key. Attached as ``x-api-key`` iff the URL is
served by one of the provider hosts in ``internal_url`` /
``external_url`` (prevents leaking the key to unrelated hosts).
internal_url: LAN-facing provider URL. Used to rewrite
``external_url`` prefixes so Docker-host downloads stay on the
LAN instead of egressing to the public domain.
external_url: Public provider URL the notification URL was built
from. Only used for the LAN rewrite and the api-key scope check.
cache_key: Optional explicit cache key. Providers whose URLs don't
embed a stable asset id (Google Photos) pass one through so the
file_id cache still works.
Returns ``None`` iff ``url`` is empty.
"""
if not url:
return None
if internal_url and external_url and url.startswith(external_url):
url = internal_url + url[len(external_url):]
normalized_type = str(media_type or "").lower()
entry_type = "video" if normalized_type == "video" else "photo"
headers: dict[str, str] = {}
provider_urls = [u for u in (internal_url, external_url) if u]
if api_key and (not provider_urls or any(url.startswith(u) for u in provider_urls)):
headers["x-api-key"] = api_key
entry: dict[str, Any] = {"url": url, "type": entry_type, "headers": headers}
if cache_key:
entry["cache_key"] = cache_key
return entry
def split_media_by_upload_size( def split_media_by_upload_size(
media_items: list[tuple], max_upload_size: int media_items: list[tuple], max_upload_size: int
) -> list[list[tuple]]: ) -> list[list[tuple]]:
@@ -177,7 +177,9 @@ class ImmichActionExecutor(ActionExecutor):
needs_thumbnail = album_id in album_created_now needs_thumbnail = album_id in album_created_now
if album_id and album_id != "__dry_run_new__": if album_id and album_id != "__dry_run_new__":
album = await self._client.get_album(album_id) # Actions diff the current album state to decide what to
# add — must observe fresh data, not a cached view.
album = await self._client.get_album(album_id, use_cache=False)
if album is None and create_if_missing and create_album_name: if album is None and create_if_missing and create_album_name:
if not dry_run: if not dry_run:
created = await self._client.create_album(create_album_name) created = await self._client.create_album(create_album_name)
@@ -193,6 +193,27 @@ def get_asset_video_url(
return None return None
def build_asset_media_urls(
external_url: str, asset_id: str, asset_type: str,
) -> tuple[str, str]:
"""Return ``(preview_url, full_url)`` for an Immich asset.
Single source of truth for the photo-vs-video endpoint rule. Used by
``asset_to_media`` (notification path) and the bot command handlers
(command path) so both always pick the transcoded ``/video/playback``
for videos and the preview-sized thumbnail for photos if they
diverge, Telegram ends up delivering a still JPEG for videos in a
media group.
"""
is_video = asset_type == ASSET_TYPE_VIDEO
if is_video:
preview_url = f"{external_url}/api/assets/{asset_id}/video/playback"
else:
preview_url = f"{external_url}/api/assets/{asset_id}/thumbnail?size=preview"
full_url = f"{external_url}/api/assets/{asset_id}/original"
return preview_url, full_url
def build_asset_detail( def build_asset_detail(
asset: ImmichAssetInfo, asset: ImmichAssetInfo,
external_url: str, external_url: str,
@@ -246,12 +267,7 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
# preview_url is what the notification dispatcher feeds to Telegram as the # preview_url is what the notification dispatcher feeds to Telegram as the
# actual media bytes — for videos it must be the transcoded playback (mp4), # 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. # not the JPEG thumbnail, or Telegram receives a JPEG labeled as video/mp4.
if asset.type == ASSET_TYPE_VIDEO: preview_url, full_url = build_asset_media_urls(external_url, asset.id, asset.type)
preview_url = f"{external_url}/api/assets/{asset.id}/video/playback"
full_url = f"{external_url}/api/assets/{asset.id}/original"
else:
preview_url = f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview"
full_url = f"{external_url}/api/assets/{asset.id}/original"
return MediaAsset( return MediaAsset(
id=asset.id, id=asset.id,
@@ -321,6 +337,12 @@ def collect_scheduled_assets(
asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url) asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url)
collections_extra: list[dict[str, Any]] = [] collections_extra: list[dict[str, Any]] = []
# limit=0 is the periodic-summary test path — the caller only needs
# per-album stats (name/url/counts), not a sample of assets. Skip the
# expensive ``filter_assets`` + sampling loop entirely; on a 50k-asset
# album the serial scan-then-discard pattern wasted seconds per test.
stats_only = limit <= 0
for album_id, album in albums.items(): for album_id, album in albums.items():
links = shared_links.get(album_id, []) links = shared_links.get(album_id, [])
album_public_url = get_public_url(external_url, links) or "" album_public_url = get_public_url(external_url, links) or ""
@@ -336,6 +358,9 @@ def collect_scheduled_assets(
"owner": album.owner, "owner": album.owner,
}) })
if stats_only:
continue
filtered = filter_assets( filtered = filter_assets(
list(album.assets.values()), list(album.assets.values()),
favorite_only=favorite_only, favorite_only=favorite_only,
@@ -348,6 +373,9 @@ def collect_scheduled_assets(
asset_album_map[asset.id] = (album_id, album_public_url) asset_album_map[asset.id] = (album_id, album_public_url)
all_eligible.append(asset) all_eligible.append(asset)
if stats_only:
return [], collections_extra
# Random sample # Random sample
if len(all_eligible) > limit: if len(all_eligible) > limit:
selected = random.sample(all_eligible, limit) selected = random.sample(all_eligible, limit)
@@ -13,6 +13,18 @@ from .models import ImmichAlbumData, ImmichAssetInfo
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Guard against runaway payloads when a bulk import lands in one poll tick.
# Templates iterate every entry in ``added_assets`` / ``removed_asset_ids``
# in Jinja for-loops (see defaults/*/assets_added.jinja2), and Telegram's
# media group has a hard cap of its own — sending 200k entries would both
# crash rendering and produce a message that no transport can deliver.
#
# ``added_count`` / ``removed_count`` on the event always carry the true
# totals so templates can show an accurate "N added" number even when the
# per-asset list is truncated.
_MAX_ASSETS_PER_EVENT = 50
_MAX_REMOVALS_PER_EVENT = 200
def _make_base_extra(new_album: ImmichAlbumData, external_url: str) -> dict: def _make_base_extra(new_album: ImmichAlbumData, external_url: str) -> dict:
"""Build the common extra dict for album events.""" """Build the common extra dict for album events."""
@@ -85,7 +97,17 @@ def detect_album_changes(
# Emit one event per change type detected # Emit one event per change type detected
if added_assets: if added_assets:
media_assets = [asset_to_media(a, external_url) for a in added_assets] total_added = len(added_assets)
truncated_added = added_assets[:_MAX_ASSETS_PER_EVENT]
media_assets = [asset_to_media(a, external_url) for a in truncated_added]
event_extra = dict(extra)
if total_added > _MAX_ASSETS_PER_EVENT:
event_extra["truncated"] = True
event_extra["shown_count"] = _MAX_ASSETS_PER_EVENT
_LOGGER.info(
"Truncated assets_added event for album %s: %d%d",
new_album.id, total_added, _MAX_ASSETS_PER_EVENT,
)
events.append(ServiceEvent( events.append(ServiceEvent(
event_type=EventType.ASSETS_ADDED, event_type=EventType.ASSETS_ADDED,
provider_type=ServiceProviderType.IMMICH, provider_type=ServiceProviderType.IMMICH,
@@ -95,12 +117,22 @@ def detect_album_changes(
timestamp=now, timestamp=now,
added_assets=media_assets, added_assets=media_assets,
removed_asset_ids=[], removed_asset_ids=[],
added_count=len(added_assets), added_count=total_added,
removed_count=0, removed_count=0,
extra=dict(extra), extra=event_extra,
)) ))
if removed_ids: if removed_ids:
total_removed = len(removed_ids)
truncated_removed = list(removed_ids)[:_MAX_REMOVALS_PER_EVENT]
event_extra = dict(extra)
if total_removed > _MAX_REMOVALS_PER_EVENT:
event_extra["truncated"] = True
event_extra["shown_count"] = _MAX_REMOVALS_PER_EVENT
_LOGGER.info(
"Truncated assets_removed event for album %s: %d%d",
new_album.id, total_removed, _MAX_REMOVALS_PER_EVENT,
)
events.append(ServiceEvent( events.append(ServiceEvent(
event_type=EventType.ASSETS_REMOVED, event_type=EventType.ASSETS_REMOVED,
provider_type=ServiceProviderType.IMMICH, provider_type=ServiceProviderType.IMMICH,
@@ -109,10 +141,10 @@ def detect_album_changes(
collection_name=new_album.name, collection_name=new_album.name,
timestamp=now, timestamp=now,
added_assets=[], added_assets=[],
removed_asset_ids=list(removed_ids), removed_asset_ids=truncated_removed,
added_count=0, added_count=0,
removed_count=len(removed_ids), removed_count=total_removed,
extra=dict(extra), extra=event_extra,
)) ))
if name_changed: if name_changed:
@@ -2,15 +2,96 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import hashlib
import logging import logging
import re
import time
from typing import Any from typing import Any
import aiohttp import aiohttp
from .models import ImmichAlbumData, SharedLinkInfo from ...notifications.ssrf import UnsafeURLError, validate_outbound_url
from .models import ImmichAlbumData, ImmichAlbumMeta, SharedLinkInfo
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Cap user-controlled Immich search parameters so a low-privileged command
# listener (e.g. an Immich ``/search`` command) cannot DoS the upstream.
MAX_SEARCH_QUERY_LEN = 256
MAX_SEARCH_PERSON_IDS = 50
# Module-level TTL caches for album bodies and shared-link listings. The
# Immich ``GET /api/albums/{id}`` response can be tens or hundreds of MB on a
# large album, and bot commands like /random, /latest, /memory all refetch
# the same album in quick succession. A short TTL makes repeat runs nearly
# instant and deduplicates concurrent fetches so a burst of commands issues
# one HTTP call instead of N.
#
# Caches are module-scoped (not instance-scoped) because ``ImmichClient`` is
# constructed fresh per request in several places (api/providers.py,
# services/action_runner.py, command handlers), so an instance cache would
# never survive to serve a second caller. This mirrors ``_users_cache`` in
# ``provider.py``.
_ALBUM_CACHE_TTL_SECONDS = 60
_SHARED_LINKS_CACHE_TTL_SECONDS = 60
# Guard rail against runaway memory — a 200k-asset album response can be
# ~150 MB, so even modest caps bound the worst case.
_ALBUM_CACHE_MAX_ENTRIES = 32
_album_cache_lock = asyncio.Lock()
# key = (server_digest, album_id); value = (monotonic_ts, raw_api_dict)
# Store the raw dict rather than the parsed ``ImmichAlbumData`` so callers
# that pass a ``users_cache`` still get owner-name enrichment on cache hits.
_album_cache: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {}
_shared_links_cache_lock = asyncio.Lock()
# key = server_digest; value = (monotonic_ts, {album_id: [SharedLinkInfo, ...]})
# The underlying ``/api/shared-links`` endpoint has no per-album filter, so
# every call was already paying for the full server-wide list. Caching the
# bucketed result once per server turns N per-album calls into one fetch.
_shared_links_cache: dict[str, tuple[float, dict[str, list[SharedLinkInfo]]]] = {}
def _server_digest(url: str, api_key: str) -> str:
"""Hashed key that avoids putting raw api_key into cache dict keys."""
return hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()[:32]
def invalidate_album_cache() -> None:
"""Drop every cached album body. Call after mutations that invalidate
the cached view (e.g. integration tests, manual /refresh commands)."""
_album_cache.clear()
def invalidate_shared_links_cache() -> None:
"""Drop every cached shared-link listing."""
_shared_links_cache.clear()
# User-facing error bodies — Immich responses may leak internal paths,
# hostnames, or headers injected by intermediary proxies. These helpers keep
# only a short, scrubbed summary; full bodies are logged server-side only.
_REDACTED_BODY_MAX = 120
_SECRET_PATTERN = re.compile(
r"(?i)(bearer\s+\S+|x-api-key[:=]\s*\S+|authorization[:=]\s*\S+|cookie[:=]\s*\S+|"
r"password[:=]?\s*\S+|token[:=]?\s*[A-Za-z0-9._\-]+)"
)
def _redact_body(text: str) -> str:
"""Return a short, credential-scrubbed snippet safe to surface to UI callers.
Immich error responses are admin-configurable (via reverse proxies, custom
error pages) and may echo request headers or environment leak. Stripping
anything that looks like a credential + capping length keeps us from
persisting secrets into ``ActionExecution.error`` / ``EventLog.details``
(both of which are returned through the dashboard API).
"""
if not text:
return ""
cleaned = _SECRET_PATTERN.sub("[redacted]", text)
if len(cleaned) > _REDACTED_BODY_MAX:
return cleaned[:_REDACTED_BODY_MAX] + "..."
return cleaned
class ImmichClient: class ImmichClient:
"""Async client for the Immich API.""" """Async client for the Immich API."""
@@ -25,6 +106,19 @@ class ImmichClient:
self._url = url.rstrip("/") self._url = url.rstrip("/")
self._api_key = api_key self._api_key = api_key
self._external_domain: str | None = None self._external_domain: str | None = None
# SSRF guard — admin-set Immich URLs are loaded from provider config
# which can be mutated via PATCH /api/providers or imported via
# prepare-restore, so we revalidate at construction time rather than
# trusting DB state. 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 @property
def url(self) -> str: def url(self) -> str:
@@ -36,6 +130,14 @@ class ImmichClient:
@external_domain.setter @external_domain.setter
def external_domain(self, value: str | None) -> None: def external_domain(self, value: str | None) -> None:
# Mirror the constructor's SSRF guard. 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 self._external_domain = value
@property @property
@@ -130,28 +232,100 @@ class ImmichClient:
return {} return {}
async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]: async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]:
links: list[SharedLinkInfo] = [] bucketed = await self._get_shared_links_bucketed()
return list(bucketed.get(album_id, []))
async def _get_shared_links_bucketed(self) -> dict[str, list[SharedLinkInfo]]:
"""Return ``{album_id: [SharedLinkInfo, ...]}`` for the server, hitting
the module-level TTL cache first. Underlying Immich endpoint has no
per-album filter, so one server-wide fetch serves every caller until
the TTL elapses.
"""
digest = _server_digest(self._url, self._api_key)
now = time.monotonic()
entry = _shared_links_cache.get(digest)
if entry is not None and (now - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
return entry[1]
async with _shared_links_cache_lock:
# Re-check under the lock — another coroutine may have refreshed
# while we waited.
entry = _shared_links_cache.get(digest)
if entry is not None and (time.monotonic() - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
return entry[1]
fresh = await self.get_all_shared_links_by_album()
_shared_links_cache[digest] = (time.monotonic(), fresh)
return fresh
async def get_all_shared_links_by_album(self) -> dict[str, list[SharedLinkInfo]]:
"""Fetch every shared link on the server, bucketed by album id.
Immich's ``/api/shared-links`` endpoint is server-wide — there's no
per-album filter server-side so every call that wanted the links
for a single album was already paying the cost of the full listing
and then discarding most of the response. Callers that need links
for multiple albums in one tick should use this method and index
into the returned dict instead of hitting ``get_shared_links`` in
a loop.
Returns an empty dict on any error (matches the silent-failure
contract of ``get_shared_links`` so callers don't need to branch
on transient outages).
"""
result: dict[str, list[SharedLinkInfo]] = {}
try: try:
async with self._session.get( async with self._session.get(
f"{self._url}/api/shared-links", f"{self._url}/api/shared-links",
headers=self._headers, headers=self._headers,
) as response: ) as response:
if response.status == 200: if response.status != 200:
data = await response.json() _LOGGER.warning(
for link in data: "get_all_shared_links non-200: HTTP %s", response.status
album = link.get("album") )
key = link.get("key") return result
if album and key and album.get("id") == album_id: data = await response.json()
links.append(SharedLinkInfo.from_api_response(link)) for link in data:
album = link.get("album")
key = link.get("key")
if not (album and key):
continue
aid = album.get("id")
if not aid:
continue
result.setdefault(aid, []).append(
SharedLinkInfo.from_api_response(link)
)
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch shared links: %s", err) _LOGGER.warning("Failed to fetch all shared links: %s", err)
return links return result
async def get_album( async def get_album(
self, self,
album_id: str, album_id: str,
users_cache: dict[str, str] | None = None, users_cache: dict[str, str] | None = None,
*,
use_cache: bool = True,
) -> ImmichAlbumData | None: ) -> ImmichAlbumData | None:
"""Fetch an album by id, optionally serving from the module-level
TTL cache. Pass ``use_cache=False`` from paths that must observe the
current server state (e.g. the notification poll loop's full-fetch
path, where a stale cached entry would delay asset-removal events).
Non-cached fetches still populate the cache for subsequent readers.
"""
cache_key = (_server_digest(self._url, self._api_key), album_id)
if use_cache:
entry = _album_cache.get(cache_key)
if entry is not None and (time.monotonic() - entry[0]) < _ALBUM_CACHE_TTL_SECONDS:
# Rehydrate per-call so ``users_cache`` enrichment is applied
# with the caller's dict, not whichever one was live when the
# cache was populated.
return ImmichAlbumData.from_api_response(entry[1], users_cache)
# Deliberately fetch without holding a lock so concurrent calls for
# *different* album_ids (the common case from asyncio.gather in
# fetch_albums_with_links) stay parallel. The worst case is a small
# duplicate-fetch stampede when two requests miss the same album at
# the same instant — acceptable for our scale.
try: try:
async with self._session.get( async with self._session.get(
f"{self._url}/api/albums/{album_id}", f"{self._url}/api/albums/{album_id}",
@@ -164,10 +338,132 @@ class ImmichClient:
f"Error fetching album {album_id}: HTTP {response.status}" f"Error fetching album {album_id}: HTTP {response.status}"
) )
data = await response.json() data = await response.json()
return ImmichAlbumData.from_api_response(data, users_cache)
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise ImmichApiError(f"Error communicating with Immich: {err}") from err raise ImmichApiError(f"Error communicating with Immich: {err}") from err
async with _album_cache_lock:
# Evict the oldest entry if we're at the cap — simple FIFO is fine
# for our access pattern (commands touch a small working set).
if len(_album_cache) >= _ALBUM_CACHE_MAX_ENTRIES and cache_key not in _album_cache:
oldest = min(_album_cache.items(), key=lambda kv: kv[1][0])[0]
_album_cache.pop(oldest, None)
_album_cache[cache_key] = (time.monotonic(), data)
return ImmichAlbumData.from_api_response(data, users_cache)
async def get_album_meta(self, album_id: str) -> ImmichAlbumMeta | None:
"""Fetch album metadata without the assets array.
Uses Immich's ``?withoutAssets=true`` query param, which skips the
(potentially huge) ``assets`` field. A 200k-asset album response
drops from ~150 MB to a few hundred bytes, so this is cheap enough
to run on every poll as a change-detection probe.
"""
try:
async with self._session.get(
f"{self._url}/api/albums/{album_id}",
params={"withoutAssets": "true"},
headers=self._headers,
) as response:
if response.status == 404:
return None
if response.status != 200:
raise ImmichApiError(
f"Error fetching album meta {album_id}: HTTP {response.status}"
)
data = await response.json()
return ImmichAlbumMeta.from_api_response(data)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error communicating with Immich: {err}") from err
async def search_album_assets_updated_after(
self,
album_id: str,
updated_after: str,
*,
page_size: int = 1000,
max_pages: int = 50,
) -> list[dict[str, Any]]:
"""Fetch assets in ``album_id`` whose ``updatedAt`` is after ``updated_after``.
Uses ``POST /api/search/metadata`` with ``albumIds=[album_id]`` and
``updatedAfter=<iso>``. Paginates through up to ``max_pages`` pages
the cap exists so a clock-skew or upstream bug cannot produce an
infinite loop that exhausts memory on a 200k-asset album. In practice
an active album sees a few hundred updated assets per tick and
terminates after one page.
Returns raw Immich asset dicts (same shape as ``album.assets[*]``
from ``get_album``), so callers can feed them into
``ImmichAssetInfo.from_api_response`` directly.
"""
if not updated_after:
return []
page_size = max(1, min(page_size, 1000))
results: list[dict[str, Any]] = []
for page in range(1, max_pages + 1):
payload: dict[str, Any] = {
"albumIds": [album_id],
"updatedAfter": updated_after,
"page": page,
"size": page_size,
# ``withExif`` keeps location/description parity with
# ``get_album`` so downstream ``ImmichAssetInfo.from_api_response``
# populates city/country/rating on the delta path too.
"withExif": True,
"withPeople": True,
}
try:
async with self._session.post(
f"{self._url}/api/search/metadata",
headers=self._json_headers,
json=payload,
) as response:
if response.status != 200:
body_snip = await response.text()
_LOGGER.warning(
"Immich delta search non-200: HTTP %s body=%s",
response.status, _redact_body(body_snip),
)
break
data = await response.json()
assets_block = data.get("assets")
if isinstance(assets_block, dict):
items = assets_block.get("items", []) or []
next_page = assets_block.get("nextPage")
elif isinstance(assets_block, list):
items = assets_block
next_page = None
else:
_LOGGER.warning(
"Immich delta search returned unexpected shape: keys=%s",
list(data.keys())[:5],
)
break
results.extend(items)
# Stop early on the last page. Immich returns nextPage as
# the next page number (string or int) or None/empty when
# exhausted. Fall back to page-fullness heuristic if the
# server omits the pagination hint.
if next_page is None or next_page == "" or next_page == 0:
break
if len(items) < page_size:
break
except aiohttp.ClientError as err:
_LOGGER.warning("Immich delta search transport error: %s", err)
break
except Exception as err: # noqa: BLE001 — resilience over correctness
_LOGGER.warning("Immich delta search parse error: %s", err)
break
else:
_LOGGER.warning(
"Immich delta search for album %s hit max_pages=%d cap",
album_id, max_pages,
)
return results
async def get_albums(self) -> list[dict[str, Any]]: async def get_albums(self) -> list[dict[str, Any]]:
try: try:
async with self._session.get( async with self._session.get(
@@ -237,22 +533,15 @@ class ImmichClient:
limit: int = 10, limit: int = 10,
page: int = 1, page: int = 1,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": limit} # Cap user-controlled inputs — a low-privileged Telegram listener can
# craft arbitrarily long queries to DoS the upstream Immich.
query = (query or "")[:MAX_SEARCH_QUERY_LEN]
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids: if album_ids:
payload["albumIds"] = album_ids payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
try: return await self._search_items(
async with self._session.post( f"{self._url}/api/search/smart", payload, limit, "smart",
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 []
async def search_metadata( async def search_metadata(
self, self,
@@ -261,21 +550,61 @@ class ImmichClient:
limit: int = 10, limit: int = 10,
page: int = 1, page: int = 1,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": limit} query = (query or "")[:MAX_SEARCH_QUERY_LEN]
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids: if album_ids:
payload["albumIds"] = album_ids payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
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: try:
async with self._session.post( async with self._session.post(
f"{self._url}/api/search/metadata", url,
headers=self._json_headers, headers=self._json_headers,
json=payload, json=payload,
) as response: ) as response:
if response.status == 200: if response.status != 200:
data = await response.json() body_snip = await response.text()
items = data.get("assets", {}).get("items", []) _LOGGER.warning(
return items[:limit] "Immich %s search non-200: HTTP %s body=%s",
except aiohttp.ClientError: kind, response.status, _redact_body(body_snip),
pass )
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 [] return []
async def search_by_person( async def search_by_person(
@@ -289,7 +618,7 @@ class ImmichClient:
to return an empty list on current servers. to return an empty list on current servers.
""" """
payload: dict[str, Any] = { payload: dict[str, Any] = {
"personIds": [person_id], "personIds": [person_id][:MAX_SEARCH_PERSON_IDS],
"page": 1, "page": 1,
"size": max(1, min(limit, 100)), "size": max(1, min(limit, 100)),
} }
@@ -373,9 +702,17 @@ class ImmichClient:
if isinstance(parsed, dict): if isinstance(parsed, dict):
return parsed return parsed
return {"raw": body_text} return {"raw": body_text}
# Log full body server-side (for operators), surface only a
# redacted snippet to the caller — this string ends up in
# ActionExecution.error / EventLog.details which are returned
# through the dashboard API.
_LOGGER.warning(
"add_assets_to_album failed: HTTP %s body=%s",
response.status, body_text[:512],
)
raise ImmichApiError( raise ImmichApiError(
f"Failed to add assets to album {album_id}: " f"Failed to add assets to album {album_id}: "
f"HTTP {response.status} body={body_text[:512]}" f"HTTP {response.status} {_redact_body(body_text)}"
) )
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise ImmichApiError(f"Error adding assets to album: {err}") from err raise ImmichApiError(f"Error adding assets to album: {err}") from err
@@ -399,9 +736,13 @@ class ImmichClient:
if response.status in (200, 201, 204): if response.status in (200, 201, 204):
return return
body_text = await response.text() body_text = await response.text()
_LOGGER.warning(
"set_album_thumbnail failed: HTTP %s body=%s",
response.status, body_text[:512],
)
raise ImmichApiError( raise ImmichApiError(
f"Failed to set album thumbnail for {album_id}: " f"Failed to set album thumbnail for {album_id}: "
f"HTTP {response.status} body={body_text[:512]}" f"HTTP {response.status} {_redact_body(body_text)}"
) )
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise ImmichApiError(f"Error setting album thumbnail: {err}") from err raise ImmichApiError(f"Error setting album thumbnail: {err}") from err
@@ -146,6 +146,49 @@ class ImmichAssetInfo:
return bool(thumbhash) return bool(thumbhash)
@dataclass(frozen=True)
class ImmichAlbumMeta:
"""Lightweight album metadata from ``GET /api/albums/{id}?withoutAssets=true``.
Used as a cheap change-detection probe so we can skip the multi-MB
full-asset fetch when nothing interesting has changed. Large albums
(tens to hundreds of thousands of assets) would otherwise re-serialize
the entire asset list on every poll interval.
"""
id: str
name: str
asset_count: int
updated_at: str
shared: bool
thumbnail_asset_id: str | None = None
@classmethod
def from_api_response(cls, data: dict[str, Any]) -> ImmichAlbumMeta:
return cls(
id=data["id"],
name=data.get("albumName", "Unnamed"),
asset_count=int(data.get("assetCount", 0) or 0),
updated_at=data.get("updatedAt", "") or "",
shared=bool(data.get("shared", False)),
thumbnail_asset_id=data.get("albumThumbnailAssetId"),
)
def fingerprint(self) -> dict[str, Any]:
"""Return a minimal serializable dict for persistence + equality checks.
We purposefully exclude ``id`` (known from the state row) and keep the
dict flat so JSON round-trips are cheap and stable for equality.
"""
return {
"updated_at": self.updated_at,
"asset_count": self.asset_count,
"shared": self.shared,
"name": self.name,
"thumbnail_asset_id": self.thumbnail_asset_id or "",
}
@dataclass @dataclass
class ImmichAlbumData: class ImmichAlbumData:
"""Full album data from Immich API.""" """Full album data from Immich API."""
@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import hashlib
import logging import logging
import time
from typing import Any from typing import Any
import aiohttp import aiohttp
@@ -11,13 +14,62 @@ from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
from notify_bridge_core.templates.variables import TemplateVariableDefinition from notify_bridge_core.templates.variables import TemplateVariableDefinition
from .change_detector import detect_album_changes from .asset_utils import asset_to_media
from .change_detector import _MAX_ASSETS_PER_EVENT, detect_album_changes
from .client import ImmichClient from .client import ImmichClient
from .models import ImmichAlbumData from .models import ImmichAlbumData, ImmichAlbumMeta, ImmichAssetInfo
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Module-level users cache shared across ImmichServiceProvider instances.
# Users change rarely (new people joining the server, display-name edits), so
# refetching on every tracker's ``connect()`` is wasteful — a fleet of 10
# trackers on the same Immich server otherwise issues 10 ``GET /api/users``
# calls per poll cycle. TTL is conservative (1h) and a hashed key keeps the
# raw api_key out of dict keys in case of a memory dump.
_USERS_CACHE_TTL_SECONDS = 3600
_users_cache_lock = asyncio.Lock()
_users_cache: dict[str, tuple[float, dict[str, str]]] = {}
def _users_cache_key(url: str, api_key: str) -> str:
digest = hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()
return digest[:32]
async def _get_cached_users(
client: ImmichClient, url: str, api_key: str
) -> dict[str, str]:
"""Return ``{user_id: display_name}`` for the server, reusing cache entries
whose TTL has not elapsed. Misses and stale hits fall through to a real
fetch under a single lock so concurrent polls don't stampede the server.
"""
key = _users_cache_key(url, api_key)
now = time.monotonic()
entry = _users_cache.get(key)
if entry is not None and (now - entry[0]) < _USERS_CACHE_TTL_SECONDS:
return entry[1]
async with _users_cache_lock:
# Re-check after acquiring the lock — another coroutine may have
# refreshed the entry while we waited.
entry = _users_cache.get(key)
if entry is not None and (time.monotonic() - entry[0]) < _USERS_CACHE_TTL_SECONDS:
return entry[1]
fresh = await client.get_users()
_users_cache[key] = (time.monotonic(), fresh)
return fresh
def invalidate_users_cache() -> None:
"""Drop every cached users dict. Exposed for callers that mutate users
(e.g. provider config changes, integration tests) and need the next
``connect()`` to re-fetch.
"""
_users_cache.clear()
# Immich-specific template variables # Immich-specific template variables
IMMICH_VARIABLES: list[TemplateVariableDefinition] = [ IMMICH_VARIABLES: list[TemplateVariableDefinition] = [
TemplateVariableDefinition( TemplateVariableDefinition(
@@ -135,7 +187,9 @@ class ImmichServiceProvider(ServiceProvider):
await self._client.get_server_config() await self._client.get_server_config()
if self._external_domain: if self._external_domain:
self._client.external_domain = self._external_domain self._client.external_domain = self._external_domain
self._users_cache = await self._client.get_users() self._users_cache = await _get_cached_users(
self._client, self._client.url, self._client.api_key,
)
return ok return ok
async def disconnect(self) -> None: async def disconnect(self) -> None:
@@ -150,9 +204,32 @@ class ImmichServiceProvider(ServiceProvider):
new_state = dict(tracker_state) new_state = dict(tracker_state)
external_url = self._client.external_url external_url = self._client.external_url
for album_id in collection_ids: # Tick-scoped share-link cache. Populated lazily on first enrichment;
album = await self._client.get_album(album_id, self._users_cache) # a tracker watching 5 albums with changes now issues 1 ``/api/shared-links``
if album is None: # request per tick instead of 5 (and the endpoint is server-wide — each
# call was already fetching all links and discarding most of them).
self._tick_shared_links: dict[str, list] | None = None
# Fan out the cheap meta probes in parallel. For a tracker that
# watches 20 albums on the same Immich server this turns a 20-hop
# serial wait into ~1 round-trip's worth of latency. aiohttp's
# connection pool caps concurrency per host, so this can't stampede.
meta_results = await asyncio.gather(
*(self._client.get_album_meta(aid) for aid in collection_ids),
return_exceptions=True,
)
for album_id, meta_or_exc in zip(collection_ids, meta_results):
if isinstance(meta_or_exc, BaseException):
# Transient failure on this album — preserve existing state
# and move on. Logging at warning so flaky albums surface in
# the log without flooding on hard outages.
_LOGGER.warning(
"Meta probe failed for album %s: %s", album_id, meta_or_exc,
)
continue
meta = meta_or_exc
if meta is None:
# Album deleted # Album deleted
if album_id in new_state: if album_id in new_state:
from notify_bridge_core.models.events import EventType from notify_bridge_core.models.events import EventType
@@ -168,11 +245,80 @@ class ImmichServiceProvider(ServiceProvider):
del new_state[album_id] del new_state[album_id]
continue continue
# Get previous state
prev = new_state.get(album_id) prev = new_state.get(album_id)
prev_fingerprint = prev.get("meta_fingerprint") if prev else None
has_pending = bool(prev and prev.get("pending_asset_ids"))
# 2) Fast-path: fingerprint match and no pending assets → no work.
# We still refresh the fingerprint slot (no-op if identical) and
# leave asset_ids untouched on disk.
if (
prev is not None
and prev_fingerprint == meta.fingerprint()
and not has_pending
):
continue
# 3) Decide: delta fetch (cheap, active-album case) or full
# fetch (first tick + reconciliation for removals).
old_fp = prev.get("meta_fingerprint") if prev else None
old_asset_count = (old_fp or {}).get("asset_count", 0)
old_updated_at = (old_fp or {}).get("updated_at", "")
# Gate for the delta path:
# - must be tracked already (prev exists, has asset_ids)
# - must have a prior timestamp (empty ⇒ migrated DB row)
# - asset_count must not have decreased (removals need full fetch)
can_delta = (
prev is not None
and bool(prev.get("asset_ids"))
and bool(old_updated_at)
and meta.asset_count >= old_asset_count
)
if can_delta:
delta_events = await self._poll_delta(
album_id=album_id,
prev=prev,
new_meta=meta,
old_updated_at=old_updated_at,
)
if delta_events is not None:
events.extend(delta_events["events"])
new_state[album_id] = delta_events["new_state"]
continue
# delta_events is None ⇒ delta saw more additions than the
# net count increase (mixed add+remove) ⇒ fall through to
# the full-fetch path so removals get detected.
# Full fetch: first tick, or count-decreased, or delta-unsafe.
# Bypass the module-level album cache — this path runs when we
# specifically need the current server state (e.g. to detect
# asset removals), so a stale cached entry would silently delay
# the event.
album = await self._client.get_album(
album_id, self._users_cache, use_cache=False,
)
if album is None:
# Album was deleted between meta probe and full fetch — handle
# the deletion the same way as above.
if album_id in new_state:
from notify_bridge_core.models.events import EventType
from datetime import datetime, timezone
events.append(ServiceEvent(
event_type=EventType.COLLECTION_DELETED,
provider_type=ServiceProviderType.IMMICH,
provider_name=self._name,
collection_id=album_id,
collection_name=new_state.get(album_id, {}).get("name", "Unknown"),
timestamp=datetime.now(timezone.utc),
))
del new_state[album_id]
continue
if prev is None: if prev is None:
# First time seeing this album — store state, no event # First time seeing this album — store state, no event
new_state[album_id] = _serialize_album_state(album) new_state[album_id] = _serialize_album_state(album, meta)
continue continue
# Reconstruct previous album data for comparison # Reconstruct previous album data for comparison
@@ -184,34 +330,233 @@ class ImmichServiceProvider(ServiceProvider):
) )
if detected_events: if detected_events:
# Fetch shared links to enrich events with public_url await self._enrich_with_shared_links(album_id, detected_events)
shared_links = await self._client.get_shared_links(album_id)
public_link = None
protected_link = None
for link in shared_links:
if link.is_accessible and not link.is_expired:
if link.has_password:
protected_link = link
else:
public_link = link
break # prefer non-password link
ext_domain = self._external_domain or self._client.external_url
for evt in detected_events:
if public_link:
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
elif protected_link:
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
events.extend(detected_events) events.extend(detected_events)
# Update state # Update state
state = _serialize_album_state(album) state = _serialize_album_state(album, meta)
state["pending_asset_ids"] = list(updated_pending) state["pending_asset_ids"] = list(updated_pending)
new_state[album_id] = state new_state[album_id] = state
return events, new_state return events, new_state
async def _poll_delta(
self,
*,
album_id: str,
prev: dict[str, Any],
new_meta: ImmichAlbumMeta,
old_updated_at: str,
) -> dict[str, Any] | None:
"""Delta-fetch path for an active album.
Calls ``search/metadata`` with ``updatedAfter`` instead of pulling
the full asset list. Returns a dict with ``events`` and ``new_state``
on success, or ``None`` to signal the caller to retry via full fetch
(used when a mixed add+remove is detected the delta endpoint can't
tell us *what* was removed, only that additions alone don't account
for the net count change).
Trades strict detection of removals-during-mixed-changes for a
drastic reduction in bytes fetched per tick. On a 200k-asset album
where 50 were just added, we fetch ~50 asset records instead of
200 000.
"""
from datetime import datetime, timezone
from notify_bridge_core.models.events import EventType
prev_asset_ids: set[str] = set(prev.get("asset_ids", []))
prev_pending: set[str] = set(prev.get("pending_asset_ids", []))
raw_assets = await self._client.search_album_assets_updated_after(
album_id, old_updated_at
)
# Parse everything that came back. We need unprocessed entries too
# (they feed the ``pending_asset_ids`` list used by the original
# change detector's processed-later logic).
delta_assets: list[ImmichAssetInfo] = []
for raw in raw_assets:
try:
delta_assets.append(
ImmichAssetInfo.from_api_response(raw, self._users_cache)
)
except Exception as err: # noqa: BLE001 — one bad record ≠ abort tick
_LOGGER.warning(
"Skipping malformed asset record in delta response: %s", err
)
newly_added: list[ImmichAssetInfo] = []
still_pending: set[str] = set()
for asset in delta_assets:
if asset.is_processed:
if asset.id not in prev_asset_ids:
newly_added.append(asset)
else:
still_pending.add(asset.id)
old_asset_count = int((prev.get("meta_fingerprint") or {}).get("asset_count", 0))
net_change = new_meta.asset_count - old_asset_count
# If delta found more "added" assets than the net count change,
# a concurrent removal happened. Full fetch is the only way to
# know what was removed — bail out so the caller retries.
if net_change >= 0 and len(newly_added) > net_change:
_LOGGER.info(
"Delta for album %s found %d additions but net change is %d "
"— falling back to full fetch for removal reconciliation",
album_id, len(newly_added), net_change,
)
return None
# Mirror case: positive net change we couldn't account for with the
# delta results (possibly clock skew on ``updated_at``, or an asset
# whose timestamp is before ``old_updated_at`` yet the album's
# ``updatedAt`` bumped). Full fetch to avoid silently missing adds.
if net_change > 0 and len(newly_added) < net_change:
_LOGGER.info(
"Delta for album %s found %d additions but net change is %d "
"— falling back to full fetch to avoid missing assets",
album_id, len(newly_added), net_change,
)
return None
events: list[ServiceEvent] = []
now = datetime.now(timezone.utc)
external_url = self._external_domain or self._client.external_url
album_url = f"{external_url}/albums/{album_id}"
# Carry album-level attributes we know from the cheap meta probe.
# Shared-link enrichment happens further down only if we emitted
# any asset events.
base_extra = {
"album_url": album_url,
"shared": new_meta.shared,
"asset_count": new_meta.asset_count,
"photo_count": 0, # unknown without per-asset scan; templates tolerate 0
"video_count": 0,
"people": [],
"owner": "",
}
# Metadata-only events (no asset fetch needed)
old_fp = prev.get("meta_fingerprint") or {}
if old_fp.get("name") and old_fp["name"] != new_meta.name:
events.append(ServiceEvent(
event_type=EventType.COLLECTION_RENAMED,
provider_type=ServiceProviderType.IMMICH,
provider_name=self._name,
collection_id=album_id,
collection_name=new_meta.name,
timestamp=now,
added_assets=[],
removed_asset_ids=[],
added_count=0,
removed_count=0,
old_name=old_fp["name"],
new_name=new_meta.name,
extra=dict(base_extra),
))
if "shared" in old_fp and bool(old_fp["shared"]) != bool(new_meta.shared):
events.append(ServiceEvent(
event_type=EventType.SHARING_CHANGED,
provider_type=ServiceProviderType.IMMICH,
provider_name=self._name,
collection_id=album_id,
collection_name=new_meta.name,
timestamp=now,
added_assets=[],
removed_asset_ids=[],
added_count=0,
removed_count=0,
old_shared=bool(old_fp["shared"]),
new_shared=bool(new_meta.shared),
extra=dict(base_extra),
))
if newly_added:
total_added = len(newly_added)
truncated = newly_added[:_MAX_ASSETS_PER_EVENT]
media_assets = [
asset_to_media(a, self._client.external_url) for a in truncated
]
extra = dict(base_extra)
if total_added > _MAX_ASSETS_PER_EVENT:
extra["truncated"] = True
extra["shown_count"] = _MAX_ASSETS_PER_EVENT
_LOGGER.info(
"Delta-path truncated assets_added event for album %s: %d%d",
album_id, total_added, _MAX_ASSETS_PER_EVENT,
)
events.append(ServiceEvent(
event_type=EventType.ASSETS_ADDED,
provider_type=ServiceProviderType.IMMICH,
provider_name=self._name,
collection_id=album_id,
collection_name=new_meta.name,
timestamp=now,
added_assets=media_assets,
removed_asset_ids=[],
added_count=total_added,
removed_count=0,
extra=extra,
))
if events:
await self._enrich_with_shared_links(album_id, events)
# Rebuild state. asset_ids grows by the newly-added processed set.
# pending is the union of the prior pending list (things still in
# flight) and anything the delta confirmed as not-yet-processed.
# When net_change is 0 or negative we trust the meta count over
# our bookkeeping — skip-path will fix drift on the next full fetch.
new_asset_ids = prev_asset_ids | {a.id for a in newly_added}
# Discard any previously-pending IDs that just landed as processed.
new_pending = (prev_pending | still_pending) - {a.id for a in newly_added}
return {
"events": events,
"new_state": {
"name": new_meta.name,
"asset_ids": list(new_asset_ids),
"shared": new_meta.shared,
"pending_asset_ids": list(new_pending),
"meta_fingerprint": new_meta.fingerprint(),
},
}
async def _enrich_with_shared_links(
self, album_id: str, events_to_enrich: list[ServiceEvent]
) -> None:
"""Attach public/protected share link URLs to events for this album.
Uses the tick-scoped bulk cache populated lazily on first call, so a
tracker with changes across N albums makes one ``/api/shared-links``
request per tick instead of N.
"""
if self._tick_shared_links is None:
self._tick_shared_links = await self._client.get_all_shared_links_by_album()
shared_links = self._tick_shared_links.get(album_id, [])
public_link = None
protected_link = None
for link in shared_links:
if link.is_accessible and not link.is_expired:
if link.has_password:
protected_link = link
else:
public_link = link
break # prefer non-password link
ext_domain = self._external_domain or self._client.external_url
for evt in events_to_enrich:
if public_link:
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
elif protected_link:
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
def get_available_variables(self) -> list[TemplateVariableDefinition]: def get_available_variables(self) -> list[TemplateVariableDefinition]:
return list(IMMICH_VARIABLES) return list(IMMICH_VARIABLES)
@@ -262,13 +607,33 @@ class ImmichServiceProvider(ServiceProvider):
return {"ok": False, "message": "Failed to connect to Immich"} return {"ok": False, "message": "Failed to connect to Immich"}
def _serialize_album_state(album: ImmichAlbumData) -> dict[str, Any]: def _serialize_album_state(
"""Serialize album state for persistence.""" album: ImmichAlbumData,
meta: ImmichAlbumMeta | None = None,
) -> dict[str, Any]:
"""Serialize album state for persistence.
``meta`` carries the fingerprint used for cheap no-change detection on
subsequent polls. When omitted (legacy callers, tests) we synthesize a
best-effort fingerprint from the full album it will still match on the
next tick if nothing changed, which is what matters.
"""
if meta is None:
fingerprint = {
"updated_at": album.updated_at,
"asset_count": len(album.asset_ids),
"shared": album.shared,
"name": album.name,
"thumbnail_asset_id": album.thumbnail_asset_id or "",
}
else:
fingerprint = meta.fingerprint()
return { return {
"name": album.name, "name": album.name,
"asset_ids": list(album.asset_ids), "asset_ids": list(album.asset_ids),
"shared": album.shared, "shared": album.shared,
"pending_asset_ids": [], "pending_asset_ids": [],
"meta_fingerprint": fingerprint,
} }
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from notify_bridge_core.models.events import EventType, ServiceEvent from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
@@ -57,6 +58,13 @@ SCHEDULER_VARIABLES: list[TemplateVariableDefinition] = [
example="Monday", example="Monday",
provider_type=ServiceProviderType.SCHEDULER, provider_type=ServiceProviderType.SCHEDULER,
), ),
TemplateVariableDefinition(
name="timezone",
type="string",
description="IANA timezone used to compute current_date/time",
example="Europe/Warsaw",
provider_type=ServiceProviderType.SCHEDULER,
),
TemplateVariableDefinition( TemplateVariableDefinition(
name="custom_vars", name="custom_vars",
type="dict", type="dict",
@@ -83,7 +91,8 @@ class SchedulerServiceProvider(ServiceProvider):
custom_variables: dict[str, str] | None = None, custom_variables: dict[str, str] | None = None,
date_format: str = "%d.%m.%Y", date_format: str = "%d.%m.%Y",
time_format: str = "%H:%M", time_format: str = "%H:%M",
datetime_format: str = "%d.%m.%Y, %H:%M UTC", datetime_format: str = "%d.%m.%Y, %H:%M %Z",
timezone_name: str | None = None,
) -> None: ) -> None:
self._name = name self._name = name
self._tracker_name = tracker_name self._tracker_name = tracker_name
@@ -91,6 +100,18 @@ class SchedulerServiceProvider(ServiceProvider):
self._date_format = date_format self._date_format = date_format
self._time_format = time_format self._time_format = time_format
self._datetime_format = datetime_format self._datetime_format = datetime_format
# Resolve a timezone for date/time rendering. Falls back to UTC on
# invalid IANA names so a typo in app settings doesn't break polls.
tz: ZoneInfo
if timezone_name:
try:
tz = ZoneInfo(timezone_name)
except (ZoneInfoNotFoundError, ValueError):
_LOGGER.warning("Unknown timezone %r; falling back to UTC", timezone_name)
tz = ZoneInfo("UTC")
else:
tz = ZoneInfo("UTC")
self._tz = tz
async def connect(self) -> bool: async def connect(self) -> bool:
return True # virtual provider — always connected return True # virtual provider — always connected
@@ -103,7 +124,8 @@ class SchedulerServiceProvider(ServiceProvider):
collection_ids: list[str], collection_ids: list[str],
tracker_state: dict[str, Any], tracker_state: dict[str, Any],
) -> tuple[list[ServiceEvent], dict[str, Any]]: ) -> tuple[list[ServiceEvent], dict[str, Any]]:
now = datetime.now(timezone.utc) now_utc = datetime.now(timezone.utc)
now = now_utc.astimezone(self._tz)
# State uses {collection_id: {dict}} convention like other providers # State uses {collection_id: {dict}} convention like other providers
sched_state = tracker_state.get("scheduler", {}) sched_state = tracker_state.get("scheduler", {})
fire_count = sched_state.get("fire_count", 0) + 1 fire_count = sched_state.get("fire_count", 0) + 1
@@ -115,6 +137,7 @@ class SchedulerServiceProvider(ServiceProvider):
"current_time": now.strftime(self._time_format), "current_time": now.strftime(self._time_format),
"current_datetime": now.strftime(self._datetime_format), "current_datetime": now.strftime(self._datetime_format),
"weekday": _WEEKDAYS[now.weekday()], "weekday": _WEEKDAYS[now.weekday()],
"timezone": self._tz.key,
"custom_vars": dict(self._custom_variables), "custom_vars": dict(self._custom_variables),
} }
# Flatten custom variables at top level for easy template access # Flatten custom variables at top level for easy template access
@@ -1,4 +1,3 @@
📊 Status 📊 Status
Trackers: {{ trackers_active }}/{{ trackers_total }} active
Albums: {{ total_albums }} Albums: {{ total_albums }}
Last event: {{ last_event }} Last event: {{ last_event }}
@@ -1,4 +1,3 @@
📊 Статус 📊 Статус
Трекеры: {{ trackers_active }}/{{ trackers_total }} активных
Альбомы: {{ total_albums }} Альбомы: {{ total_albums }}
Последнее событие: {{ last_event }} Последнее событие: {{ last_event }}
@@ -2,16 +2,67 @@
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from notify_bridge_core.models.events import ServiceEvent from notify_bridge_core.models.events import ServiceEvent
_LOGGER = logging.getLogger(__name__)
# Per-target maximum video size (bytes). None = no limit. # Per-target maximum video size (bytes). None = no limit.
_MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = { _MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = {
"telegram": 50 * 1024 * 1024, # 50 MB — Telegram Bot API hard limit "telegram": 50 * 1024 * 1024, # 50 MB — Telegram Bot API hard limit
} }
# Keys that must NEVER flow into the Jinja2 template context, even if a
# provider stuffs them into ``event.extra`` (webhooks, Immich metadata, etc.).
# Templates that could reach a Telegram/Discord/etc. chat would otherwise
# expose operator credentials if a template author simply did ``{{ api_key }}``.
# Case-insensitive substring match — any ``extra`` key containing one of these
# tokens is dropped before the merge.
_SENSITIVE_EXTRA_TOKENS: tuple[str, ...] = (
"api_key",
"apikey",
"token",
"secret",
"password",
"passwd",
"hashed_",
"authorization",
"cookie",
"session_id",
"bearer",
"private_key",
"access_key",
)
def _is_sensitive_key(key: str) -> bool:
lowered = str(key).lower()
return any(tok in lowered for tok in _SENSITIVE_EXTRA_TOKENS)
def _safe_merge_extras(ctx: dict[str, Any], extras: dict[str, Any]) -> None:
"""Merge provider ``extras`` into ``ctx``, dropping sensitive keys.
Dropped keys are logged once per event (DEBUG) so operators can spot
leaking providers without flooding the log.
"""
if not extras:
return
dropped: list[str] = []
for key, value in extras.items():
if _is_sensitive_key(key):
dropped.append(key)
continue
ctx[key] = value
if dropped:
_LOGGER.debug(
"Dropped %d sensitive key(s) from template context: %s",
len(dropped), ", ".join(sorted(dropped)),
)
def build_template_context( def build_template_context(
event: ServiceEvent, event: ServiceEvent,
@@ -61,8 +112,9 @@ def build_template_context(
"preview_url": asset.preview_url or "", "preview_url": asset.preview_url or "",
"full_url": asset.full_url or "", "full_url": asset.full_url or "",
} }
# Flatten extras into asset dict for template access # Flatten extras into asset dict for template access — same
asset_dict.update(asset.extra) # sensitive-key filtering applied as the top-level merge.
_safe_merge_extras(asset_dict, asset.extra)
asset_dict.setdefault("oversized", False) asset_dict.setdefault("oversized", False)
asset_dict.setdefault("file_size", None) asset_dict.setdefault("file_size", None)
asset_dict.setdefault("playback_size", None) asset_dict.setdefault("playback_size", None)
@@ -138,8 +190,11 @@ def build_template_context(
if len(locations) == 1 and "" not in locations: if len(locations) == 1 and "" not in locations:
ctx["common_location"] = locations.pop() ctx["common_location"] = locations.pop()
# Provider-specific extras merged at top level # Provider-specific extras merged at top level. Sensitive keys (tokens,
ctx.update(event.extra) # secrets, auth headers) are dropped — see ``_SENSITIVE_EXTRA_TOKENS``.
# Without this, a template author could exfiltrate provider credentials
# via ``{{ api_key }}`` in an outgoing notification body.
_safe_merge_extras(ctx, event.extra)
# Ensure URL variables always exist (avoid Jinja2 undefined errors) # Ensure URL variables always exist (avoid Jinja2 undefined errors)
ctx.setdefault("public_url", "") ctx.setdefault("public_url", "")
@@ -169,6 +224,7 @@ def build_template_context(
ctx.setdefault("current_time", event.extra.get("current_time", "")) ctx.setdefault("current_time", event.extra.get("current_time", ""))
ctx.setdefault("current_datetime", event.extra.get("current_datetime", "")) ctx.setdefault("current_datetime", event.extra.get("current_datetime", ""))
ctx.setdefault("weekday", event.extra.get("weekday", "")) ctx.setdefault("weekday", event.extra.get("weekday", ""))
ctx.setdefault("timezone", event.extra.get("timezone", "UTC"))
ctx.setdefault("custom_vars", event.extra.get("custom_vars", {})) ctx.setdefault("custom_vars", event.extra.get("custom_vars", {}))
return ctx return ctx
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-server" name = "notify-bridge-server"
version = "0.2.0" version = "0.3.2"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database" description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -20,17 +20,27 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
_SETTING_KEYS = { _SETTING_KEYS = {
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL", "external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET", "telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
"telegram_cache_ttl_hours": None, # no env fallback, default 48 "telegram_cache_ttl_hours": None, # URL cache TTL; 0 disables TTL
"telegram_asset_cache_max_entries": None, # LRU cap for both caches
"supported_locales": None, # comma-separated locale codes "supported_locales": None, # comma-separated locale codes
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
} }
_DEFAULTS = { _DEFAULTS = {
"external_url": "", "external_url": "",
"telegram_webhook_secret": "", "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", "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: async def get_setting(session: AsyncSession, key: str) -> str:
"""Read a setting from DB, falling back to env var then default.""" """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): 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 external_url: str | None = None
telegram_webhook_secret: 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 supported_locales: str | None = None
timezone: str | None = None
@router.get("") @router.get("")
@@ -77,21 +93,50 @@ async def update_settings(
"""Update app settings (admin). Re-registers webhooks when base URL changes.""" """Update app settings (admin). Re-registers webhooks when base URL changes."""
old_base_url = await get_setting(session, "external_url") old_base_url = await get_setting(session, "external_url")
old_secret = await get_setting(session, "telegram_webhook_secret") old_secret = await get_setting(session, "telegram_webhook_secret")
old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
old_timezone = await get_setting(session, "timezone")
for key in _SETTING_KEYS: for key in _SETTING_KEYS:
value = getattr(body, key, None) value = getattr(body, key, None)
if value is None: if value is None:
continue continue
value_str = str(value)
# GET masks the webhook secret as "***<last4>" so the real value is
# never exposed to the frontend. If the client sends the mask back
# (which happens on every save, since bind:value holds whatever GET
# returned), treat it as "unchanged" — otherwise we'd overwrite the
# real secret with its mask, silently breaking webhook HMAC.
if key == "telegram_webhook_secret" and value_str.startswith("***"):
continue
row = await session.get(AppSetting, key) row = await session.get(AppSetting, key)
if row: if row:
row.value = value row.value = value_str
else: else:
row = AppSetting(key=key, value=value) row = AppSetting(key=key, value=value_str)
session.add(row) session.add(row)
await session.commit() 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_base_url = await get_setting(session, "external_url")
new_secret = await get_setting(session, "telegram_webhook_secret") new_secret = await get_setting(session, "telegram_webhook_secret")
new_timezone = await get_setting(session, "timezone")
# Cron triggers freeze their timezone at construction time, so a tz change
# has no effect until the jobs are rebuilt — do that here, before we
# return success, so the UI reflects the actual schedule immediately.
if new_timezone != old_timezone:
from ..services.scheduler import reschedule_cron_jobs_for_timezone_change
await reschedule_cron_jobs_for_timezone_change()
# Update webhook secret in the webhook handler module # Update webhook secret in the webhook handler module
if new_secret != old_secret: if new_secret != old_secret:
@@ -108,6 +153,25 @@ async def update_settings(
return result 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") @router.get("/locales")
async def get_supported_locales( async def get_supported_locales(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
@@ -1,13 +1,15 @@
"""Configuration backup/restore API (admin only).""" """Configuration backup/restore API (admin only)."""
import asyncio import asyncio
import hashlib
import json import json
import logging import logging
import os import os
import signal import signal
from datetime import datetime, timezone from datetime import datetime, timezone
from urllib.parse import urlparse
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Query from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile, File, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -28,6 +30,11 @@ PENDING_RESTORE_PATH_KEY = "pending_restore_path"
PENDING_RESTORE_CONFLICT_KEY = "pending_restore_conflict_mode" PENDING_RESTORE_CONFLICT_KEY = "pending_restore_conflict_mode"
PENDING_RESTORE_UPLOADED_AT_KEY = "pending_restore_uploaded_at" PENDING_RESTORE_UPLOADED_AT_KEY = "pending_restore_uploaded_at"
PENDING_RESTORE_UPLOADED_BY_KEY = "pending_restore_uploaded_by" 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(): def _pending_restore_path():
@@ -44,6 +51,69 @@ router = APIRouter(prefix="/api/backup", tags=["backup"])
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
async def _read_upload_bounded(file: UploadFile, max_bytes: int = MAX_UPLOAD_SIZE) -> bytes:
"""Read an UploadFile into memory, failing fast if it exceeds ``max_bytes``.
Rejects on ``content_length`` header up-front when available; always
stream-reads with a running byte counter so we never allocate more than
the limit even when the header is missing or lies.
"""
# Fast path: reject on header before we allocate anything.
cl = file.headers.get("content-length") if hasattr(file, "headers") else None
if cl:
try:
if int(cl) > max_bytes:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
except ValueError:
pass
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(64 * 1024)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
chunks.append(chunk)
return b"".join(chunks)
def _check_same_origin(request: Request) -> None:
"""Reject cross-origin admin-write POSTs (CSRF defense).
Bearer tokens in ``localStorage`` plus cookie-less CORS mean a malicious
page cannot technically submit our Authorization header from a victim's
session, BUT browser extensions and misconfigured CORS policies routinely
break this assumption. For endpoints whose blast radius is restart/RCE-
equivalent (restore apply), we additionally require the request to come
from our own origin.
"""
host = request.headers.get("host", "").lower()
if not host:
raise HTTPException(status_code=400, detail="Missing Host header")
def _host_of(u: str | None) -> str:
if not u:
return ""
try:
return (urlparse(u).netloc or "").lower()
except Exception: # noqa: BLE001
return ""
origin_host = _host_of(request.headers.get("origin"))
referer_host = _host_of(request.headers.get("referer"))
# At least one of Origin/Referer must be present and match Host.
# Legitimate browser requests to this endpoint always ship Origin.
same = (origin_host and origin_host == host) or (referer_host and referer_host == host)
if not same:
raise HTTPException(
status_code=403,
detail="Cross-origin request rejected",
)
def _backup_dir(): def _backup_dir():
return app_config.data_dir / "backups" return app_config.data_dir / "backups"
@@ -104,9 +174,7 @@ async def validate_config(
user: User = Depends(require_admin), user: User = Depends(require_admin),
): ):
"""Validate a backup file without importing.""" """Validate a backup file without importing."""
content = await file.read() content = await _read_upload_bounded(file)
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
try: try:
raw = json.loads(content) raw = json.loads(content)
@@ -129,9 +197,7 @@ async def import_config(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""Import configuration from a backup file.""" """Import configuration from a backup file."""
content = await file.read() content = await _read_upload_bounded(file)
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
try: try:
raw = json.loads(content) raw = json.loads(content)
@@ -167,6 +233,7 @@ async def _clear_pending_restore_markers(session: AsyncSession) -> None:
PENDING_RESTORE_CONFLICT_KEY, PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY, PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY, PENDING_RESTORE_UPLOADED_BY_KEY,
PENDING_RESTORE_SHA256_KEY,
): ):
row = await session.get(AppSetting, key) row = await session.get(AppSetting, key)
if row: if row:
@@ -185,9 +252,7 @@ async def prepare_restore(
Validates the uploaded file, writes it to ``data/pending_restore.json``, Validates the uploaded file, writes it to ``data/pending_restore.json``,
and persists marker settings so startup will apply it atomically. and persists marker settings so startup will apply it atomically.
""" """
content = await file.read() content = await _read_upload_bounded(file)
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
try: try:
raw = json.loads(content) raw = json.loads(content)
@@ -205,15 +270,25 @@ async def prepare_restore(
pending_path.parent.mkdir(parents=True, exist_ok=True) pending_path.parent.mkdir(parents=True, exist_ok=True)
# Atomic write: write to tmp then rename, so a crash mid-write never # Atomic write: write to tmp then rename, so a crash mid-write never
# leaves a truncated pending_restore.json that would break startup apply. # 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 = pending_path.with_suffix(pending_path.suffix + ".tmp")
tmp_path.write_text(json.dumps(raw), encoding="utf-8") tmp_path.write_bytes(payload)
os.replace(tmp_path, pending_path) 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() 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_PATH_KEY, str(pending_path))
await _set_app_setting(session, PENDING_RESTORE_CONFLICT_KEY, conflict_mode.value) 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_AT_KEY, now_iso)
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_BY_KEY, user.username) 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() await session.commit()
return { return {
@@ -292,6 +367,7 @@ def _is_supervised() -> bool:
@router.post("/apply-restart") @router.post("/apply-restart")
async def apply_and_restart( async def apply_and_restart(
request: Request,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
user: User = Depends(require_admin), user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@@ -299,7 +375,11 @@ async def apply_and_restart(
"""Trigger a graceful exit so the supervisor respawns and applies the pending restore. """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. 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) path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
if not path_row or not path_row.value: if not path_row or not path_row.value:
raise HTTPException(status_code=409, detail="No pending restore to apply") raise HTTPException(status_code=409, detail="No pending restore to apply")
@@ -138,13 +138,11 @@ async def get_command_variables(
# --- Immich-specific --- # --- Immich-specific ---
immich = { immich = {
"status": { "status": {
"description": "/status tracker summary", "description": "/status tracker summary (scoped to this chat)",
"variables": { "variables": {
**common_vars, **common_vars,
"trackers_active": "Number of active trackers", "total_albums": "Tracked albums visible to this chat",
"trackers_total": "Total tracker count", "last_event": "Last event timestamp string (scoped to this chat's albums)",
"total_albums": "Total tracked albums",
"last_event": "Last event timestamp string",
}, },
}, },
"albums": { "albums": {
@@ -242,6 +242,8 @@ async def get_template_variables(
"current_date": "Current date (formatted)", "current_date": "Current date (formatted)",
"current_time": "Current time (formatted)", "current_time": "Current time (formatted)",
"current_datetime": "Current date and time (formatted)", "current_datetime": "Current date and time (formatted)",
"weekday": "Day of the week (Monday..Sunday)",
"timezone": "IANA timezone used for current_date/time",
}, },
}, },
} }
@@ -54,6 +54,9 @@ class TrackingConfigCreate(BaseModel):
memory_favorite_only: bool = False memory_favorite_only: bool = False
memory_asset_type: str = "all" memory_asset_type: str = "all"
memory_min_rating: int = 0 memory_min_rating: int = 0
quiet_hours_enabled: bool = False
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
class TrackingConfigUpdate(BaseModel): class TrackingConfigUpdate(BaseModel):
@@ -93,6 +96,9 @@ class TrackingConfigUpdate(BaseModel):
memory_favorite_only: bool | None = None memory_favorite_only: bool | None = None
memory_asset_type: str | None = None memory_asset_type: str | None = None
memory_min_rating: int | None = None memory_min_rating: int | None = None
quiet_hours_enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
@router.get("") @router.get("")
@@ -4,6 +4,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -81,6 +82,12 @@ async def update_user(
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") 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: if body.username is not None and body.username != user.username:
new_username = body.username.strip() new_username = body.username.strip()
if not new_username: if not new_username:
@@ -89,21 +96,51 @@ async def update_user(
if dup.first(): if dup.first():
raise HTTPException(status_code=409, detail="Username already exists") raise HTTPException(status_code=409, detail="Username already exists")
user.username = new_username user.username = new_username
identity_changed = True
if body.role is not None and body.role != user.role: if body.role is not None and body.role != user.role:
if body.role not in ("admin", "user"): if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Invalid role") raise HTTPException(status_code=400, detail="Invalid role")
# Prevent demoting the last admin # 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": if user.role == "admin" and body.role != "admin":
admins = (await session.exec( admin_count = (await session.exec(
select(User).where(User.role == "admin") select(func.count(User.id)).where(User.role == "admin")
)).all() )).one()
if len(admins) <= 1: 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") raise HTTPException(status_code=400, detail="Cannot demote the last admin")
user.role = body.role user.role = body.role
identity_changed = True
if identity_changed:
user.token_version = (user.token_version or 1) + 1
session.add(user) session.add(user)
await session.commit() 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) await session.refresh(user)
return {"id": user.id, "username": user.username, "role": user.role} return {"id": user.id, "username": user.username, "role": user.role}
@@ -126,6 +163,9 @@ async def reset_user_password(
if len(body.new_password) < 8: if len(body.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters") raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
user.hashed_password = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode() user.hashed_password = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode()
# Invalidate all prior JWTs issued for this user — matches the self-serve
# password-change path in auth/routes.py.
user.token_version = (user.token_version or 1) + 1
session.add(user) session.add(user)
await session.commit() await session.commit()
return {"success": True} return {"success": True}
@@ -27,7 +27,11 @@ from ..database.models import (
ServiceProvider, ServiceProvider,
WebhookPayloadLog, WebhookPayloadLog,
) )
from ..services.dispatch_helpers import event_allowed_by_config, load_link_data from ..services.dispatch_helpers import (
event_allowed_by_config,
get_app_timezone,
load_link_data,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -144,6 +148,8 @@ async def _dispatch_webhook_event(
if not link_data: if not link_data:
continue continue
app_tz = await get_app_timezone(session)
# Log event # Log event
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys} extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
session.add(EventLog( session.add(EventLog(
@@ -164,7 +170,7 @@ async def _dispatch_webhook_event(
# Dispatch to targets # Dispatch to targets
dispatcher = NotificationDispatcher() dispatcher = NotificationDispatcher()
target_configs = _build_target_configs(event, link_data, provider_config) target_configs = _build_target_configs(event, link_data, provider_config, app_tz)
if target_configs: if target_configs:
results = await dispatcher.dispatch(event, target_configs) results = await dispatcher.dispatch(event, target_configs)
for r in results: for r in results:
@@ -513,12 +519,13 @@ def _build_target_configs(
event: ServiceEvent, event: ServiceEvent,
link_data: list[dict[str, Any]], link_data: list[dict[str, Any]],
provider_config: dict[str, Any], provider_config: dict[str, Any],
app_tz: str = "UTC",
) -> list[TargetConfig]: ) -> list[TargetConfig]:
"""Build TargetConfig objects for dispatch, applying tracking config filters.""" """Build TargetConfig objects for dispatch, applying tracking config filters."""
target_configs: list[TargetConfig] = [] target_configs: list[TargetConfig] = []
for ld in link_data: for ld in link_data:
tc = ld["tracking_config"] tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc): if tc and not event_allowed_by_config(event, tc, app_tz):
continue continue
tmpl = ld["template_config"] tmpl = ld["template_config"]
@@ -56,6 +56,7 @@ class ProviderCommandHandler(ABC):
config: CommandConfig, config: CommandConfig,
*, *,
listener: CommandTrackerListener | None = None, listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None,
page: int = 1, page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
"""Handle a provider-specific command for a single tracker. """Handle a provider-specific command for a single tracker.
@@ -71,6 +72,13 @@ class ProviderCommandHandler(ABC):
bot: The Telegram bot instance. bot: The Telegram bot instance.
tracker: The command tracker being dispatched. tracker: The command tracker being dispatched.
config: The command config for this tracker. 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: Returns:
A CommandResponse, or None if unhandled. A CommandResponse, or None if unhandled.
@@ -8,7 +8,14 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine 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__) _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}) 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. """Get formatted timestamp of most recent event for given trackers.
Returns a 'YYYY-MM-DD HH:MM' string, or '-' if no events exist. 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: if not tracker_ids:
return "-" return "-"
engine = get_engine() engine = get_engine()
async with AsyncSession(engine) as session: async with AsyncSession(engine) as session:
result = await session.exec( query = (
select(EventLog) select(EventLog)
.where(EventLog.tracker_id.in_(tracker_ids)) .where(EventLog.tracker_id.in_(tracker_ids))
.order_by(EventLog.created_at.desc()) .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() last_event = result.first()
return last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-" 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( def get_tracked_collection_ids(
provider: ServiceProvider, provider: ServiceProvider,
trackers: list[NotificationTracker], trackers: list[NotificationTracker],
@@ -79,6 +79,7 @@ class GiteaCommandHandler(ProviderCommandHandler):
config: CommandConfig, config: CommandConfig,
*, *,
listener: Any = None, listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Gitea has no album model)
page: int = 1, page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd) fn = _TEXT_COMMANDS.get(cmd)
@@ -41,6 +41,36 @@ _rate_limits: TTLCache = TTLCache(maxsize=10000, ttl=3600)
# Maximum responses per command to avoid Telegram rate limits # Maximum responses per command to avoid Telegram rate limits
_MAX_RESPONSES_PER_COMMAND = 5 _MAX_RESPONSES_PER_COMMAND = 5
# Commands that fetch assets from the service provider and usually reply
# with media — "uploading photo" is the accurate UX hint while we wait on
# the provider API + Telegram upload.
_UPLOAD_PHOTO_COMMANDS = frozenset({
"latest", "random", "favorites", "memory",
"search", "find", "person", "place",
})
# Commands that fetch from the provider but reply with text only.
# "typing" is accurate; we still want an indicator because the fetch is slow.
_TYPING_COMMANDS = frozenset({"summary"})
def classify_command_chat_action(text: str) -> str | None:
"""Return the Telegram chat-action hint to show for this command, or None.
The classification is by command name alone good enough for the
cases where a chat action is worthwhile (slow provider fetches). Fast
DB-only commands (``/status``, ``/albums``, ``/events``, ``/people``)
return ``None`` and skip the indicator entirely.
"""
cmd, _, _ = parse_command(text)
if not cmd:
return None
if cmd in _UPLOAD_PHOTO_COMMANDS:
return "upload_photo"
if cmd in _TYPING_COMMANDS:
return "typing"
return None
def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int]) -> int | None: def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int]) -> int | None:
"""Check rate limit. Returns seconds to wait, or None if OK.""" """Check rate limit. Returns seconds to wait, or None if OK."""
@@ -286,6 +316,8 @@ async def handle_command(
page = max(1, count_override) page = max(1, count_override)
count_override = None count_override = None
from .command_utils import resolve_chat_album_scope
responses: list[CommandResponse] = [] responses: list[CommandResponse] = []
for tracker, config, provider, listener in ctx_tuples: for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND: if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
@@ -303,10 +335,27 @@ async def handle_command(
count = min(count_override or config.default_count or 5, 20) count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media" 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( result = await handler.handle(
cmd, args, count, locale, response_mode, cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config, provider, tracker_templates, bot, tracker, config,
listener=listener, page=page, listener=listener,
allowed_album_ids=allowed_album_ids,
page=page,
) )
if result is not None: if result is not None:
responses.append(result) responses.append(result)
@@ -348,12 +397,26 @@ async def send_reply(
bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None, bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None, session: aiohttp.ClientSession | None = None,
) -> None: ) -> None:
"""Send a text reply via TelegramClient.""" """Send a text reply to a chat.
if session is None:
from ..services.http_session import get_http_session Thin wrapper that goes through the single ``services.telegram_send``
session = await get_http_session() entry point so commands and notifications share one routine same
client = TelegramClient(session, bot_token) HTTP session pool, same file_id caches.
result = await client.send_message(chat_id, text, reply_to_message_id=reply_to_message_id)
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.
"""
from ..services.telegram_send import send_telegram_message
result = await send_telegram_message(
bot_token, chat_id, text,
reply_to_message_id=reply_to_message_id,
disable_web_page_preview=True,
)
if not result.get("success"): if not result.get("success"):
_LOGGER.warning("Telegram reply failed: %s", result.get("error")) _LOGGER.warning("Telegram reply failed: %s", result.get("error"))
@@ -363,30 +426,28 @@ async def send_media_group(
reply_to_message_id: int | None = None, reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None, session: aiohttp.ClientSession | None = None,
) -> None: ) -> None:
"""Send media items via TelegramClient.send_notification.""" """Send media items via the shared Telegram routine.
``media_items`` must already be in TelegramClient asset format each
entry contains ``type`` (``"photo"``/``"video"``/``"document"``),
``url``, optional ``cache_key``, and optional ``headers``. Provider
command handlers build this format via
``build_telegram_asset_entry`` the same helper the notification
dispatcher uses so videos keep their ``"video"`` type and point at
a real video URL instead of a still thumbnail.
Uses ``services.telegram_send.send_telegram_media`` so the URL cache
and asset cache are wired in exactly like the notification path.
Repeated ``/latest`` / ``/random`` commands that match previously-sent
assets hit the cache and skip the re-upload.
"""
if not media_items: if not media_items:
return return
# Convert command handler media format to TelegramClient asset format from ..services.telegram_send import send_telegram_media
assets = []
for item in media_items:
assets.append({
"type": "photo",
"url": item.get("thumbnail_url", ""),
"cache_key": item.get("asset_id", ""),
"headers": {"x-api-key": item.get("api_key", "")},
})
# Build caption from first item result = await send_telegram_media(
captions = [item.get("caption", "") for item in media_items if item.get("caption")] bot_token, chat_id, media_items,
caption = "\n".join(captions) if captions else None
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_notification(
chat_id, assets=assets, caption=caption,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
chat_action=None, chat_action=None,
) )
@@ -17,7 +17,10 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_albums( async def _cmd_albums(
provider: ServiceProvider, locale: str, provider: ServiceProvider,
locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id) trackers = await get_trackers_for_provider(provider.id)
if not trackers: if not trackers:
@@ -31,6 +34,13 @@ async def _cmd_albums(
if aid not in seen: if aid not in seen:
seen.add(aid) seen.add(aid)
album_ids.append(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: if not album_ids:
return {"albums": []} return {"albums": []}
@@ -6,7 +6,11 @@ import asyncio
import logging import logging
from typing import Any from typing import Any
from notify_bridge_core.providers.immich.asset_utils import get_public_url from notify_bridge_core.notifications.telegram.media import build_telegram_asset_entry
from notify_bridge_core.providers.immich.asset_utils import (
build_asset_media_urls,
get_public_url,
)
from ..handler import _render_cmd_template from ..handler import _render_cmd_template
@@ -74,13 +78,16 @@ def build_asset_dict(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict.""" """Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict."""
if isinstance(asset, dict): if isinstance(asset, dict):
# Immich raw search responses nest geo under exifInfo — pull it out so
# templates can use flat asset.city / asset.country.
exif = asset.get("exifInfo") or {}
d = { d = {
"id": asset.get("id", ""), "id": asset.get("id", ""),
"originalFileName": asset.get("originalFileName", asset.get("filename", "")), "originalFileName": asset.get("originalFileName", asset.get("filename", "")),
"type": asset.get("type", "IMAGE"), "type": asset.get("type", "IMAGE"),
"createdAt": asset.get("createdAt", asset.get("created_at", asset.get("fileCreatedAt", ""))), "createdAt": asset.get("createdAt", asset.get("created_at", asset.get("fileCreatedAt", ""))),
"city": asset.get("city", ""), "city": asset.get("city") or exif.get("city") or "",
"country": asset.get("country", ""), "country": asset.get("country") or exif.get("country") or "",
"is_favorite": asset.get("is_favorite", asset.get("isFavorite", False)), "is_favorite": asset.get("is_favorite", asset.get("isFavorite", False)),
"public_url": asset.get("public_url", public_url), "public_url": asset.get("public_url", public_url),
} }
@@ -123,16 +130,32 @@ def _format_assets(
}) })
if response_mode == "media": if response_mode == "media":
# Reuse the same URL rule (build_asset_media_urls) and entry builder
# (build_telegram_asset_entry) as the notification dispatcher so both
# paths agree on video → /video/playback and photo → thumbnail. When
# these diverged, Telegram rendered a still JPEG for each video in
# the media group instead of the real clip.
#
# We deliberately do NOT pass ``cache_key`` here. TelegramClient
# derives it from the URL as ``<host>:<uuid>`` — identical to what
# the notification dispatcher produces via extract_asset_id_from_url.
# Passing the bare UUID would put command writes in a separate
# namespace from notification writes, so neither path could hit the
# other's cached file_ids (which is what made the cache look empty
# from the WebUI after running /random).
media_items: list[dict[str, Any]] = [] media_items: list[dict[str, Any]] = []
for asset in assets: for asset in assets:
asset_id = asset.get("id", "") asset_id = asset.get("id", "")
media_items.append({ asset_type = (asset.get("type") or "").upper()
"type": "photo", preview_url, _ = build_asset_media_urls(client.url, asset_id, asset_type)
"asset_id": asset_id, entry = build_telegram_asset_entry(
"caption": "", url=preview_url,
"thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview", media_type="video" if asset_type == "VIDEO" else "image",
"api_key": client.api_key, api_key=client.api_key,
}) internal_url=client.url,
)
if entry is not None:
media_items.append(entry)
# Return text message + media items — text is sent first, media as reply # Return text message + media items — text is sent first, media as reply
return {"text": text, "media": media_items} return {"text": text, "media": media_items}
@@ -27,6 +27,8 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_events( async def _cmd_events(
provider: ServiceProvider, provider: ServiceProvider,
count: int, locale: str, count: int, locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id) trackers = await get_trackers_for_provider(provider.id)
tracker_ids = [t.id for t in trackers] tracker_ids = [t.id for t in trackers]
@@ -35,12 +37,14 @@ async def _cmd_events(
engine = get_engine() engine = get_engine()
async with AsyncSession(engine) as session: async with AsyncSession(engine) as session:
result = await session.exec( query = (
select(EventLog) select(EventLog)
.where(EventLog.tracker_id.in_(tracker_ids)) .where(EventLog.tracker_id.in_(tracker_ids))
.order_by(EventLog.created_at.desc()) .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 = result.all()
events_data = [ events_data = [
@@ -25,17 +25,31 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_status( async def _cmd_status(
provider: ServiceProvider, locale: str, provider: ServiceProvider, locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id) 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] 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 { return {
"trackers_active": active, "trackers_total": total,
"total_albums": total_albums, "last_event": last_str, "total_albums": total_albums, "last_event": last_str,
} }
@@ -80,16 +94,17 @@ class ImmichCommandHandler(ProviderCommandHandler):
config: CommandConfig, config: CommandConfig,
*, *,
listener: CommandTrackerListener | None = None, listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None,
page: int = 1, page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
if cmd == "status": if cmd == "status":
ctx = await _cmd_status(provider, locale) ctx = await _cmd_status(provider, locale, allowed_album_ids=allowed_album_ids)
return CommandResponse(text=_render_cmd_template(cmd_templates, "status", locale, ctx)) return CommandResponse(text=_render_cmd_template(cmd_templates, "status", locale, ctx))
if cmd == "albums": 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)) return CommandResponse(text=_render_cmd_template(cmd_templates, "albums", locale, ctx))
if cmd == "events": 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)) return CommandResponse(text=_render_cmd_template(cmd_templates, "events", locale, ctx))
if cmd == "people": if cmd == "people":
ctx = await _cmd_people(provider, locale) ctx = await _cmd_people(provider, locale)
@@ -99,7 +114,7 @@ class ImmichCommandHandler(ProviderCommandHandler):
return await _cmd_immich( return await _cmd_immich(
cmd, args, count, locale, response_mode, cmd, args, count, locale, response_mode,
provider, cmd_templates, provider, cmd_templates,
listener=listener, page=page, allowed_album_ids=allowed_album_ids, page=page,
) )
return None return None
@@ -109,7 +124,7 @@ async def _cmd_immich(
response_mode: str, provider: ServiceProvider, response_mode: str, provider: ServiceProvider,
cmd_templates: dict[str, dict[str, str]], cmd_templates: dict[str, dict[str, str]],
*, *,
listener: CommandTrackerListener | None = None, allowed_album_ids: set[str] | None = None,
page: int = 1, page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
"""Handle commands that need Immich API access and may return media.""" """Handle commands that need Immich API access and may return media."""
@@ -123,10 +138,12 @@ async def _cmd_immich(
seen.add(aid) seen.add(aid)
all_album_ids.append(aid) all_album_ids.append(aid)
# Per-chat album scope: intersect with listener.allowed_album_ids when set. # Intersect with the scope resolved by the dispatcher (from the listener
if listener is not None and listener.allowed_album_ids is not None: # override if set, otherwise from the notification-routing graph for this
allowed = set(listener.allowed_album_ids) # chat). ``None`` = no filter (rare); empty set = show nothing (common
all_album_ids = [aid for aid in all_album_ids if aid in allowed] # 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("/") ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/")
@@ -5,17 +5,14 @@ from __future__ import annotations
from typing import Any from typing import Any
from ..handler import _render_cmd_template from ..handler import _render_cmd_template
from .common import _format_assets from .common import _format_assets, build_asset_dict
def _enrich_assets(assets: list[dict[str, Any]], asset_public_urls: dict[str, str]) -> list[dict[str, Any]]: def _enrich_assets(assets: list[dict[str, Any]], asset_public_urls: dict[str, str]) -> list[dict[str, Any]]:
"""Add public_url to assets from the pre-built map. Returns new list without mutating inputs.""" """Normalize raw Immich assets and attach public_url from the pre-built map."""
if not asset_public_urls: pub = asset_public_urls or {}
return assets
return [ return [
{**asset, "public_url": asset_public_urls.get(asset.get("id", ""), "")} build_asset_dict(asset, public_url=pub.get(asset.get("id", ""), ""))
if asset.get("id", "") in asset_public_urls and not asset.get("public_url")
else asset
for asset in assets for asset in assets
] ]
@@ -31,7 +28,7 @@ async def cmd_search(
if not args: if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""}) return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""})
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count, page=page) assets = await client.search_smart(args, album_ids=all_album_ids, limit=count, page=page)
_enrich_assets(assets, asset_public_urls or {}) assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates) return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates)
@@ -46,7 +43,7 @@ async def cmd_find(
if not args: if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""}) return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""})
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count, page=page) assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count, page=page)
_enrich_assets(assets, asset_public_urls or {}) assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates) return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates)
@@ -68,7 +65,7 @@ async def cmd_person(
if not person_id: if not person_id:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args}) return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
assets = await client.search_by_person(person_id, limit=count) 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) return _format_assets(assets, "person", args, locale, response_mode, client, cmd_templates)
@@ -84,5 +81,5 @@ async def cmd_place(
assets = await client.search_smart( assets = await client.search_smart(
f"photos taken in {args}", album_ids=all_album_ids, limit=count 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) return _format_assets(assets, "place", args, locale, response_mode, client, cmd_templates)
@@ -54,6 +54,7 @@ class NutCommandHandler(ProviderCommandHandler):
config: CommandConfig, config: CommandConfig,
*, *,
listener: Any = None, listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (NUT has no album model)
page: int = 1, page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd) fn = _TEXT_COMMANDS.get(cmd)
@@ -71,6 +71,7 @@ class PlankaCommandHandler(ProviderCommandHandler):
config: CommandConfig, config: CommandConfig,
*, *,
listener: Any = None, listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Planka has no album model)
page: int = 1, page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd) fn = _TEXT_COMMANDS.get(cmd)
@@ -15,8 +15,9 @@ from notify_bridge_core.notifications.telegram.client import TelegramClient
from ..database.engine import get_session from ..database.engine import get_session
from ..database.models import TelegramBot, TelegramChat from ..database.models import TelegramBot, TelegramChat
from ..services.telegram import save_chat_from_webhook from ..services.telegram import save_chat_from_webhook
from ..services.telegram_send import telegram_chat_action
from .base import CommandResponse from .base import CommandResponse
from .handler import handle_command, send_media_group, send_reply from .handler import classify_command_chat_action, handle_command, send_media_group, send_reply
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -95,14 +96,17 @@ async def telegram_webhook(
return {"ok": True, "skipped": "commands_disabled"} return {"ok": True, "skipped": "commands_disabled"}
effective_lang = chat_row.language_override or msg_language effective_lang = chat_row.language_override or msg_language
message_id = message.get("message_id") message_id = message.get("message_id")
responses = await handle_command(bot, chat_id, text, language_code=effective_lang) async with telegram_chat_action(
if responses: bot_token, chat_id, classify_command_chat_action(text),
for resp in responses: ):
if resp.text: responses = await handle_command(bot, chat_id, text, language_code=effective_lang)
await send_reply(bot_token, chat_id, resp.text, reply_to_message_id=message_id) if responses:
if resp.media: for resp in responses:
await send_media_group(bot_token, chat_id, resp.media, reply_to_message_id=message_id) if resp.text:
return {"ok": True} await send_reply(bot_token, chat_id, resp.text, reply_to_message_id=message_id)
if resp.media:
await send_media_group(bot_token, chat_id, resp.media, reply_to_message_id=message_id)
return {"ok": True}
return {"ok": True, "skipped": "not_a_command"} return {"ok": True, "skipped": "not_a_command"}
@@ -92,6 +92,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
await conn.execute(text(sql)) await conn.execute(text(sql))
logger.info("Added %s column to event_log table", col) logger.info("Added %s column to event_log table", col)
# Explicit indexes on the dashboard-query columns. SQLModel's
# ``index=True`` is emitted by ``create_all`` on *new* installs,
# but ALTER TABLE ADD COLUMN doesn't create them on upgrades —
# so the first boot after upgrade would leave these unindexed
# and status.py ``WHERE user_id=...`` would table-scan. The
# indexes are redundant-but-safe once create_all also runs.
for idx_name, col in [
("ix_event_log_user_id", "user_id"),
("ix_event_log_action_id", "action_id"),
("ix_event_log_provider_id", "provider_id"),
]:
await conn.execute(
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON event_log ({col})")
)
# Backfill user_id from notification_tracker for legacy rows. # Backfill user_id from notification_tracker for legacy rows.
# Safe to run repeatedly: only touches rows where user_id is still NULL. # Safe to run repeatedly: only touches rows where user_id is still NULL.
await conn.execute(text(""" await conn.execute(text("""
@@ -250,6 +265,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
) )
logger.info("Added track_webhook_received column to tracking_config table") logger.info("Added track_webhook_received column to tracking_config table")
# Add quiet hours to tracking_config if missing.
# Start/end are nullable HH:MM strings; quiet_hours_enabled gates them.
if await _has_table(conn, "tracking_config"):
if not await _has_column(conn, "tracking_config", "quiet_hours_enabled"):
await conn.execute(
text("ALTER TABLE tracking_config ADD COLUMN quiet_hours_enabled INTEGER DEFAULT 0")
)
logger.info("Added quiet_hours_enabled column to tracking_config table")
for col_name in ("quiet_hours_start", "quiet_hours_end"):
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} TEXT")
)
logger.info("Added %s column to tracking_config table", col_name)
# Drop legacy template content columns from template_config # Drop legacy template content columns from template_config
# (template content moved to template_slot child rows) # (template content moved to template_slot child rows)
if await _has_table(conn, "template_config"): if await _has_table(conn, "template_config"):
@@ -279,6 +309,14 @@ async def migrate_schema(engine: AsyncEngine) -> None:
text(f"ALTER TABLE {state_table} ADD COLUMN shared INTEGER DEFAULT 0") text(f"ALTER TABLE {state_table} ADD COLUMN shared INTEGER DEFAULT 0")
) )
logger.info("Added shared column to %s table", state_table) logger.info("Added shared column to %s table", state_table)
# meta_fingerprint — small JSON blob captured from the provider's
# cheap meta probe. An empty default means "unknown, do a full
# fetch next tick" so existing rows don't wrongly skip detection.
if not await _has_column(conn, state_table, "meta_fingerprint"):
await conn.execute(
text(f"ALTER TABLE {state_table} ADD COLUMN meta_fingerprint TEXT DEFAULT '{{}}'")
)
logger.info("Added meta_fingerprint column to %s table", state_table)
# Add language_code to telegram_chat if missing # Add language_code to telegram_chat if missing
if await _has_table(conn, "telegram_chat"): if await _has_table(conn, "telegram_chat"):
@@ -204,6 +204,13 @@ class TrackingConfig(SQLModel, table=True):
memory_asset_type: str = Field(default="all") memory_asset_type: str = Field(default="all")
memory_min_rating: int = Field(default=0) memory_min_rating: int = Field(default=0)
# Quiet hours — HH:MM strings interpreted in the app-level timezone
# (AppSetting "timezone"). Gated by quiet_hours_enabled so an empty window
# still represents "explicitly disabled" vs "not yet configured".
quiet_hours_enabled: bool = Field(default=False)
quiet_hours_start: str | None = Field(default=None)
quiet_hours_end: str | None = Field(default=None)
created_at: datetime = Field(default_factory=_utcnow) created_at: datetime = Field(default_factory=_utcnow)
@@ -369,6 +376,13 @@ class NotificationTrackerState(SQLModel, table=True):
shared: bool = Field(default=False) shared: bool = Field(default=False)
asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON)) asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
pending_asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON)) pending_asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
# Lightweight fingerprint ({updated_at, asset_count, shared, name, ...})
# captured from the provider's cheap meta probe. Letting this differ from
# the current provider response is what tells the watcher a full fetch is
# actually required — letting it match lets the watcher skip the big read.
meta_fingerprint: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSON)
)
last_updated: datetime = Field(default_factory=_utcnow) last_updated: datetime = Field(default_factory=_utcnow)
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, time, timezone from datetime import datetime, time, timezone
from typing import Any from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -29,12 +30,32 @@ from ..database.models import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def in_quiet_hours(start: str | None, end: str | None) -> bool: def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
"""Check if the current UTC time is within the quiet hours window.""" """Resolve an IANA tz string to a ZoneInfo, falling back to UTC on any error."""
if not tz_name:
return ZoneInfo("UTC")
try:
return ZoneInfo(tz_name)
except (ZoneInfoNotFoundError, ValueError):
_LOGGER.warning("Unknown timezone %r; falling back to UTC", tz_name)
return ZoneInfo("UTC")
def in_quiet_hours(
start: str | None,
end: str | None,
tz_name: str | None = "UTC",
) -> bool:
"""Check if the current time (in the given timezone) is within the quiet window.
HH:MM strings are interpreted in the supplied timezone. If either bound is
missing, quiet hours are disabled.
"""
if not start or not end: if not start or not end:
return False return False
try: try:
now = datetime.now(timezone.utc).time() tz = _resolve_zoneinfo(tz_name)
now = datetime.now(timezone.utc).astimezone(tz).time()
t_start = time.fromisoformat(start) t_start = time.fromisoformat(start)
t_end = time.fromisoformat(end) t_end = time.fromisoformat(end)
if t_start <= t_end: if t_start <= t_end:
@@ -46,8 +67,25 @@ def in_quiet_hours(start: str | None, end: str | None) -> bool:
return False return False
def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool: async def get_app_timezone(session: AsyncSession) -> str:
"""Check if an event type is allowed by the tracking config's flags.""" """Load the app-level timezone from AppSetting (falls back to UTC)."""
from ..api.app_settings import get_setting
value = await get_setting(session, "timezone")
return value or "UTC"
def event_allowed_by_config(
event: ServiceEvent,
tc: TrackingConfig,
tz_name: str | None = "UTC",
) -> bool:
"""Check if an event is allowed by the tracking config's flags + quiet hours."""
# Quiet hours gate every event type when enabled.
if tc.quiet_hours_enabled and in_quiet_hours(
tc.quiet_hours_start, tc.quiet_hours_end, tz_name
):
return False
event_type = event.event_type.value event_type = event.event_type.value
flag_map = { flag_map = {
# Immich events # Immich events
@@ -1,5 +1,6 @@
"""Notification sender — unified send logic for all paths (dispatch + test).""" """Notification sender — unified send logic for all paths (dispatch + test)."""
import asyncio
import logging import logging
from typing import Any from typing import Any
@@ -11,6 +12,10 @@ from ..database.models import NotificationTarget, TargetReceiver
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Cap on concurrent per-receiver test sends. Keeps us under Telegram's per-bot
# rate limit (~30 msg/s) while still saving ~N×RTT on multi-chat broadcasts.
_TEST_SEND_CONCURRENCY = 5
_TEST_MESSAGES: dict[str, dict[str, str]] = { _TEST_MESSAGES: dict[str, dict[str, str]] = {
"en": { "en": {
"telegram": "\u2705 Test message from <b>Notify Bridge</b>", "telegram": "\u2705 Test message from <b>Notify Bridge</b>",
@@ -358,19 +363,29 @@ async def _send_telegram_test_per_receiver(
http = await get_http_session() http = await get_http_session()
client = TelegramClient(http, bot_token) client = TelegramClient(http, bot_token)
results: list[dict] = []
for r in recv_rows: # 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", "")) chat_id = str(r.config.get("chat_id", ""))
if not chat_id: if not chat_id:
continue return None
explicit = getattr(r, "locale", "") or "" explicit = getattr(r, "locale", "") or ""
locale = explicit or chat_locale_map.get(chat_id) or default_locale locale = explicit or chat_locale_map.get(chat_id) or default_locale
message = _get_test_message(locale[:2].lower(), "telegram") message = _get_test_message(locale[:2].lower(), "telegram")
results.append(await client.send_message( async with sem:
chat_id=chat_id, return await client.send_message(
text=message, chat_id=chat_id,
disable_web_page_preview=bool(disable_preview), 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) return _aggregate(results)
@@ -10,10 +10,19 @@ 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 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 is archived under data/applied_restores/<timestamp>.json and markers are
cleared. 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 from __future__ import annotations
import asyncio
import hashlib
import json import json
import logging import logging
import shutil import shutil
@@ -24,11 +33,13 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..api.backup import ( from ..api.backup import (
PENDING_RESTORE_CONFLICT_KEY, PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_PATH_KEY, PENDING_RESTORE_PATH_KEY,
PENDING_RESTORE_SHA256_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY, PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY, PENDING_RESTORE_UPLOADED_BY_KEY,
_applied_restores_dir, _applied_restores_dir,
_pending_restore_path, _pending_restore_path,
) )
from ..config import settings as app_config
from ..database.engine import get_engine from ..database.engine import get_engine
from ..database.models import AppSetting from ..database.models import AppSetting
from .backup_schema import BackupFile, ConflictMode from .backup_schema import BackupFile, ConflictMode
@@ -49,6 +60,23 @@ async def apply_pending_restore_if_any() -> None:
return return
pending_path = _pending_restore_path() 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(): if not pending_path.exists():
_LOGGER.warning( _LOGGER.warning(
"Pending-restore marker present but file missing at %s — clearing marker", "Pending-restore marker present but file missing at %s — clearing marker",
@@ -62,9 +90,42 @@ async def apply_pending_restore_if_any() -> None:
conflict_mode = ConflictMode(conflict_row.value) if conflict_row and conflict_row.value else ConflictMode.SKIP 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_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_BY_KEY)
uploaded_by = uploaded_by_row.value if uploaded_by_row else "admin" 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: try:
raw = json.loads(pending_path.read_text(encoding="utf-8")) 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) backup = BackupFile.model_validate(raw)
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
_LOGGER.exception("Pending-restore file unreadable") _LOGGER.exception("Pending-restore file unreadable")
@@ -88,8 +149,14 @@ async def apply_pending_restore_if_any() -> None:
result = await import_backup(session, admin_row.id, backup, conflict_mode) result = await import_backup(session, admin_row.id, backup, conflict_mode)
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
_LOGGER.exception("Pending-restore apply failed") _LOGGER.exception("Pending-restore apply failed")
await _record_error(session, str(err)) # Discard any partial inserts the importer made before raising —
await session.commit() # 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 return
# Archive the file # Archive the file
@@ -136,6 +203,7 @@ async def _clear_markers(session: AsyncSession) -> None:
PENDING_RESTORE_CONFLICT_KEY, PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY, PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY, PENDING_RESTORE_UPLOADED_BY_KEY,
PENDING_RESTORE_SHA256_KEY,
): ):
row = await session.get(AppSetting, key) row = await session.get(AppSetting, key)
if row: if row:
@@ -188,6 +188,7 @@ _SAMPLE_CONTEXT = {
"current_time": "09:00", "current_time": "09:00",
"current_datetime": "22.03.2026, 09:00 UTC", "current_datetime": "22.03.2026, 09:00 UTC",
"weekday": "Monday", "weekday": "Monday",
"timezone": "UTC",
"custom_vars": {"team": "Engineering", "message": "Time for standup!"}, "custom_vars": {"team": "Engineering", "message": "Time for standup!"},
"team": "Engineering", "team": "Engineering",
"message": "Time for standup!", "message": "Time for standup!",
@@ -3,13 +3,84 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
"""Resolve an IANA tz string to a ZoneInfo, falling back to UTC on any error.
Kept local to avoid importing from api/dispatch layers inside the scheduler
module (which is loaded at startup, before the API routers).
"""
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")
async def _load_app_timezone() -> ZoneInfo:
"""Load the admin-configured app timezone from AppSetting (falls back to UTC)."""
from sqlmodel.ext.asyncio.session import AsyncSession
from ..api.app_settings import get_setting
from ..database.engine import get_engine
async with AsyncSession(get_engine()) as session:
tz_name = await get_setting(session, "timezone")
return _resolve_zoneinfo(tz_name)
_scheduler: AsyncIOScheduler | None = None _scheduler: AsyncIOScheduler | None = None
# ---------------------------------------------------------------------------
# Adaptive polling (Tier 6 of the big-album optimization plan).
#
# We don't touch the user-configured ``scan_interval`` — that's still the
# authoritative cadence. Instead, we *skip* a growing fraction of scheduled
# ticks when a tracker is idle, and reset to 1:1 as soon as it detects
# anything. The scheduler keeps running on the user's chosen period, so
# response time to the *first* change after an idle stretch is never worse
# than one tick — but the steady-state HTTP cost for a fleet of idle
# trackers drops by ~75%.
#
# Thresholds are intentionally conservative: a tracker polling every 30 s
# needs 5 min of silence before we halve its effective rate, and 15 min
# before we quarter it. Any caller can disable adaptive behavior by passing
# ``adaptive=False`` in the tracker filters dict (checked in ``_poll_tracker``).
# ---------------------------------------------------------------------------
_ADAPTIVE_HALVE_THRESHOLD = 10 # consecutive empty ticks → 1-in-2
_ADAPTIVE_QUARTER_THRESHOLD = 30 # consecutive empty ticks → 1-in-4
_ADAPTIVE_MAX_SKIP = 4 # hard cap on skip factor
# Per-tracker adaptive state, keyed by tracker_id. Rebuilt on process
# restart — a short warmup period is fine and avoids persisting what is
# effectively a performance heuristic.
_adaptive_state: dict[int, dict[str, int]] = {}
def _compute_jitter(interval_seconds: int) -> int:
"""Return a jitter bound (in seconds) suitable for an IntervalTrigger.
Without jitter, a fleet of N trackers all on ``scan_interval=60`` wake up
at the same wall-clock second every minute that creates a thundering-
herd on the upstream Immich/Gitea/etc. server. APScheduler's ``jitter``
randomizes each tick's firing time by ±jitter seconds.
We use a quarter of the interval up to a 30 s cap. For short intervals
(8 s) jitter would round to 0 that's fine, at those cadences a
bursty pattern is what the user implicitly opted into.
"""
if interval_seconds <= 0:
return 0
return min(interval_seconds // 4, 30)
def get_scheduler() -> AsyncIOScheduler: def get_scheduler() -> AsyncIOScheduler:
global _scheduler global _scheduler
@@ -166,30 +237,41 @@ async def _refresh_telegram_chat_titles() -> None:
refreshed = 0 refreshed = 0
errors = 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: async with AsyncSession(engine) as session:
for chat_id, info, err in results: if successes:
if err is not None or info is None: rows = (await session.exec(
errors += 1 select(TelegramChat).where(TelegramChat.id.in_(list(successes.keys())))
if err: )).all()
_LOGGER.debug("getChat failed for chat row %s: %s", chat_id, err) for merged in rows:
continue info = successes.get(merged.id)
merged = await session.get(TelegramChat, chat_id) if not info:
if not merged: continue
continue title = info.get("title") or (
title = info.get("title") or ( (info.get("first_name", "") + " " + info.get("last_name", "")).strip()
(info.get("first_name", "") + " " + info.get("last_name", "")).strip() )
) changed = False
changed = False if title and merged.title != title:
if title and merged.title != title: merged.title = title
merged.title = title changed = True
changed = True new_username = info.get("username")
new_username = info.get("username") if new_username is not None and merged.username != new_username:
if new_username is not None and merged.username != new_username: merged.username = new_username
merged.username = new_username changed = True
changed = True if changed:
if changed: session.add(merged)
session.add(merged) refreshed += 1
refreshed += 1
await session.commit() await session.commit()
_LOGGER.info( _LOGGER.info(
"Telegram chat title refresh: %s updated, %s errors", refreshed, errors "Telegram chat title refresh: %s updated, %s errors", refreshed, errors
@@ -239,6 +321,8 @@ async def _load_tracker_jobs() -> None:
) )
provider_types = {p.id: p.type for p in provider_result.all()} provider_types = {p.id: p.type for p in provider_result.all()}
tz = await _load_app_timezone()
for tracker in trackers: for tracker in trackers:
job_id = f"tracker_{tracker.id}" job_id = f"tracker_{tracker.id}"
if scheduler.get_job(job_id): if scheduler.get_job(job_id):
@@ -252,7 +336,7 @@ async def _load_tracker_jobs() -> None:
cron_expr = filters.get("cron_expression", "") cron_expr = filters.get("cron_expression", "")
if cron_expr: if cron_expr:
try: try:
_add_cron_job(scheduler, job_id, tracker.id, cron_expr, tracker.name) _add_cron_job(scheduler, job_id, tracker.id, cron_expr, tracker.name, tz)
continue continue
except Exception as e: except Exception as e:
_LOGGER.error( _LOGGER.error(
@@ -260,16 +344,21 @@ async def _load_tracker_jobs() -> None:
tracker.id, tracker.name, e, tracker.id, tracker.name, e,
) )
jitter = _compute_jitter(tracker.scan_interval)
scheduler.add_job( scheduler.add_job(
_poll_tracker, _poll_tracker,
"interval", "interval",
seconds=tracker.scan_interval, seconds=tracker.scan_interval,
jitter=jitter or None,
id=job_id, id=job_id,
args=[tracker.id], args=[tracker.id],
replace_existing=True, replace_existing=True,
max_instances=1, max_instances=1,
) )
_LOGGER.info("Scheduled tracker %d (%s) every %ds", tracker.id, tracker.name, tracker.scan_interval) _LOGGER.info(
"Scheduled tracker %d (%s) every %ds (jitter ±%ds)",
tracker.id, tracker.name, tracker.scan_interval, jitter,
)
def _add_cron_job( def _add_cron_job(
@@ -278,10 +367,18 @@ def _add_cron_job(
tracker_id: int, tracker_id: int,
cron_expression: str, cron_expression: str,
tracker_name: str, tracker_name: str,
tz: ZoneInfo,
) -> None: ) -> None:
"""Add a cron-triggered job for a scheduler-type tracker.""" """Add a cron-triggered job for a scheduler-type tracker.
``tz`` is the user-configured app timezone; without it APScheduler
interprets the crontab in the host's local timezone, which surfaces as
events firing at the "wrong" wall-clock time for operators in a non-UTC
zone (see the companion fix in ``update_settings`` which reschedules on
timezone changes).
"""
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
trigger = CronTrigger.from_crontab(cron_expression) trigger = CronTrigger.from_crontab(cron_expression, timezone=tz)
scheduler.add_job( scheduler.add_job(
_poll_tracker, _poll_tracker,
trigger, trigger,
@@ -290,7 +387,10 @@ def _add_cron_job(
replace_existing=True, replace_existing=True,
max_instances=1, max_instances=1,
) )
_LOGGER.info("Scheduled tracker %d (%s) with cron: %s", tracker_id, tracker_name, cron_expression) _LOGGER.info(
"Scheduled tracker %d (%s) with cron: %s [tz=%s]",
tracker_id, tracker_name, cron_expression, tz.key,
)
async def schedule_tracker( async def schedule_tracker(
@@ -302,44 +402,129 @@ async def schedule_tracker(
scheduler = get_scheduler() scheduler = get_scheduler()
job_id = f"tracker_{tracker_id}" job_id = f"tracker_{tracker_id}"
# A reschedule typically follows a config edit or enable/disable flip —
# drop adaptive back-off so the first tick after the change runs promptly.
reset_adaptive_state(tracker_id)
# Remove existing job first to allow trigger type changes # Remove existing job first to allow trigger type changes
if scheduler.get_job(job_id): if scheduler.get_job(job_id):
scheduler.remove_job(job_id) scheduler.remove_job(job_id)
if cron_expression: if cron_expression:
try: try:
_add_cron_job(scheduler, job_id, tracker_id, cron_expression, f"tracker-{tracker_id}") tz = await _load_app_timezone()
_add_cron_job(scheduler, job_id, tracker_id, cron_expression, f"tracker-{tracker_id}", tz)
return return
except Exception as e: except Exception as e:
_LOGGER.error("Invalid cron for tracker %d: %s — using interval", tracker_id, e) _LOGGER.error("Invalid cron for tracker %d: %s — using interval", tracker_id, e)
jitter = _compute_jitter(interval)
scheduler.add_job( scheduler.add_job(
_poll_tracker, _poll_tracker,
"interval", "interval",
seconds=interval, seconds=interval,
jitter=jitter or None,
id=job_id, id=job_id,
args=[tracker_id], args=[tracker_id],
replace_existing=True, replace_existing=True,
) )
_LOGGER.info("Scheduled tracker %d every %ds", tracker_id, interval) _LOGGER.info(
"Scheduled tracker %d every %ds (jitter ±%ds)", tracker_id, interval, jitter,
)
async def unschedule_tracker(tracker_id: int) -> None: async def unschedule_tracker(tracker_id: int) -> None:
"""Remove a scheduler job for a tracker.""" """Remove a scheduler job for a tracker."""
scheduler = get_scheduler() scheduler = get_scheduler()
job_id = f"tracker_{tracker_id}" job_id = f"tracker_{tracker_id}"
reset_adaptive_state(tracker_id)
if scheduler.get_job(job_id): if scheduler.get_job(job_id):
scheduler.remove_job(job_id) scheduler.remove_job(job_id)
_LOGGER.info("Unscheduled tracker %d", tracker_id) _LOGGER.info("Unscheduled tracker %d", tracker_id)
def _adaptive_should_skip(tracker_id: int) -> bool:
"""Return True when the adaptive heuristic says to skip this tick.
Run-length skip: if we're in 1-in-K mode, skip (K-1) ticks between each
real poll. Stateless about the *current* tick counter except for the
``tick_counter`` we bump here.
"""
state = _adaptive_state.get(tracker_id)
if not state:
return False
skip_every = state.get("skip_every", 1)
if skip_every <= 1:
return False
state["tick_counter"] = state.get("tick_counter", 0) + 1
# Fire on ticks where counter % skip_every == 0; skip the rest.
return (state["tick_counter"] % skip_every) != 0
def _adaptive_update(tracker_id: int, events_detected: int) -> None:
"""Update the adaptive counter after a real tick ran."""
state = _adaptive_state.setdefault(
tracker_id, {"empty_count": 0, "skip_every": 1, "tick_counter": 0}
)
if events_detected > 0:
if state["skip_every"] > 1:
_LOGGER.info(
"Adaptive polling: tracker %d saw activity, restoring base rate",
tracker_id,
)
state["empty_count"] = 0
state["skip_every"] = 1
state["tick_counter"] = 0
return
state["empty_count"] = state.get("empty_count", 0) + 1
if (
state["empty_count"] >= _ADAPTIVE_QUARTER_THRESHOLD
and state["skip_every"] < _ADAPTIVE_MAX_SKIP
):
state["skip_every"] = _ADAPTIVE_MAX_SKIP
_LOGGER.info(
"Adaptive polling: tracker %d idle for %d ticks, skipping 3 of 4",
tracker_id, state["empty_count"],
)
elif (
state["empty_count"] >= _ADAPTIVE_HALVE_THRESHOLD
and state["skip_every"] < 2
):
state["skip_every"] = 2
_LOGGER.info(
"Adaptive polling: tracker %d idle for %d ticks, skipping every other",
tracker_id, state["empty_count"],
)
def reset_adaptive_state(tracker_id: int) -> None:
"""Drop cached adaptive counters for a tracker.
Used by API callers that make changes requiring the tracker to run
promptly on the next scheduled tick (enable/disable, config edits,
manual "check now" actions).
"""
_adaptive_state.pop(tracker_id, None)
async def _poll_tracker(tracker_id: int) -> None: async def _poll_tracker(tracker_id: int) -> None:
"""Poll a tracker for changes.""" """Poll a tracker for changes."""
from .watcher import check_tracker from .watcher import check_tracker
if _adaptive_should_skip(tracker_id):
return
try: try:
await check_tracker(tracker_id) result = await check_tracker(tracker_id)
except Exception as e: except Exception as e:
_LOGGER.error("Error polling tracker %d: %s", tracker_id, e) _LOGGER.error("Error polling tracker %d: %s", tracker_id, e)
return
# Treat the "error" / "skipped" statuses as inconclusive — don't let
# a transient upstream failure trick the heuristic into backing off.
if isinstance(result, dict) and result.get("status") == "ok":
_adaptive_update(tracker_id, int(result.get("events_detected", 0) or 0))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -363,6 +548,8 @@ async def _load_action_jobs() -> None:
) )
actions = result.all() actions = result.all()
tz = await _load_app_timezone()
for action in actions: for action in actions:
job_id = f"action_{action.id}" job_id = f"action_{action.id}"
if scheduler.get_job(job_id): if scheduler.get_job(job_id):
@@ -371,7 +558,7 @@ async def _load_action_jobs() -> None:
if action.schedule_type == "cron" and action.schedule_cron: if action.schedule_type == "cron" and action.schedule_cron:
try: try:
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
trigger = CronTrigger.from_crontab(action.schedule_cron) trigger = CronTrigger.from_crontab(action.schedule_cron, timezone=tz)
scheduler.add_job( scheduler.add_job(
_run_action, _run_action,
trigger, trigger,
@@ -380,8 +567,8 @@ async def _load_action_jobs() -> None:
replace_existing=True, replace_existing=True,
) )
_LOGGER.info( _LOGGER.info(
"Scheduled action %d (%s) with cron: %s", "Scheduled action %d (%s) with cron: %s [tz=%s]",
action.id, action.name, action.schedule_cron, action.id, action.name, action.schedule_cron, tz.key,
) )
continue continue
except Exception as e: except Exception as e:
@@ -420,7 +607,8 @@ async def schedule_action(
if schedule_type == "cron" and cron_expression: if schedule_type == "cron" and cron_expression:
try: try:
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
trigger = CronTrigger.from_crontab(cron_expression) tz = await _load_app_timezone()
trigger = CronTrigger.from_crontab(cron_expression, timezone=tz)
scheduler.add_job( scheduler.add_job(
_run_action, _run_action,
trigger, trigger,
@@ -428,7 +616,10 @@ async def schedule_action(
args=[action_id], args=[action_id],
replace_existing=True, replace_existing=True,
) )
_LOGGER.info("Scheduled action %d with cron: %s", action_id, cron_expression) _LOGGER.info(
"Scheduled action %d with cron: %s [tz=%s]",
action_id, cron_expression, tz.key,
)
return return
except Exception as e: except Exception as e:
_LOGGER.error("Invalid cron for action %d: %s — using interval", action_id, e) _LOGGER.error("Invalid cron for action %d: %s — using interval", action_id, e)
@@ -453,6 +644,92 @@ async def unschedule_action(action_id: int) -> None:
_LOGGER.info("Unscheduled action %d", action_id) _LOGGER.info("Unscheduled action %d", action_id)
async def reschedule_cron_jobs_for_timezone_change() -> None:
"""Re-add every cron-triggered tracker/action job under the new app timezone.
Called by the admin settings endpoint after the ``timezone`` AppSetting is
updated. APScheduler's ``CronTrigger`` freezes its timezone at construction
time, so a timezone change has no effect on jobs already in the scheduler
we have to rebuild those jobs. Interval-triggered jobs are tz-agnostic
and are left alone.
"""
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import Action, NotificationTracker, ServiceProvider as ServiceProviderModel
engine = get_engine()
scheduler = get_scheduler()
tz = await _load_app_timezone()
rescheduled = 0
async with AsyncSession(engine) as session:
# Trackers with cron scheduling (scheduler provider + schedule_type=cron).
trackers = (await session.exec(
select(NotificationTracker).where(NotificationTracker.enabled == True) # noqa: E712
)).all()
provider_ids = list({t.provider_id for t in trackers})
provider_types: dict[int, str] = {}
if provider_ids:
rows = await session.exec(
select(ServiceProviderModel).where(ServiceProviderModel.id.in_(provider_ids))
)
provider_types = {p.id: p.type for p in rows.all()}
for tracker in trackers:
if provider_types.get(tracker.provider_id) != "scheduler":
continue
filters = tracker.filters or {}
if filters.get("schedule_type") != "cron":
continue
cron_expr = filters.get("cron_expression", "")
if not cron_expr:
continue
job_id = f"tracker_{tracker.id}"
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
try:
_add_cron_job(scheduler, job_id, tracker.id, cron_expr, tracker.name, tz)
rescheduled += 1
except Exception as e: # noqa: BLE001
_LOGGER.error(
"Failed to re-apply cron for tracker %d on tz change: %s",
tracker.id, e,
)
# Actions with cron schedules.
actions = (await session.exec(
select(Action).where(Action.enabled == True) # noqa: E712
)).all()
from apscheduler.triggers.cron import CronTrigger
for action in actions:
if action.schedule_type != "cron" or not action.schedule_cron:
continue
job_id = f"action_{action.id}"
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
try:
scheduler.add_job(
_run_action,
CronTrigger.from_crontab(action.schedule_cron, timezone=tz),
id=job_id,
args=[action.id],
replace_existing=True,
)
rescheduled += 1
except Exception as e: # noqa: BLE001
_LOGGER.error(
"Failed to re-apply cron for action %d on tz change: %s",
action.id, e,
)
_LOGGER.info(
"Rescheduled %d cron job(s) for new app timezone %s", rescheduled, tz.key,
)
async def _run_action(action_id: int) -> None: async def _run_action(action_id: int) -> None:
"""Run an action (called by APScheduler).""" """Run an action (called by APScheduler)."""
from .action_runner import run_action from .action_runner import run_action
@@ -257,7 +257,13 @@ async def _poll_bot(bot_id: int) -> None:
_last_update_id[bot_id] = updates[-1]["update_id"] _last_update_id[bot_id] = updates[-1]["update_id"]
# Process each update # Process each update
from ..commands.handler import handle_command, send_media_group, send_reply from ..commands.handler import (
classify_command_chat_action,
handle_command,
send_media_group,
send_reply,
)
from .telegram_send import telegram_chat_action
for update in updates: for update in updates:
message = update.get("message") message = update.get("message")
@@ -295,13 +301,16 @@ async def _poll_bot(bot_id: int) -> None:
continue continue
effective_lang = chat_row.language_override or msg_language effective_lang = chat_row.language_override or msg_language
message_id = message.get("message_id") message_id = message.get("message_id")
responses = await handle_command(bot_obj, chat_id, text, language_code=effective_lang) async with telegram_chat_action(
if responses: bot_token, chat_id, classify_command_chat_action(text),
for resp in responses: ):
if resp.text: responses = await handle_command(bot_obj, chat_id, text, language_code=effective_lang)
await send_reply(bot_token, chat_id, resp.text, reply_to_message_id=message_id) if responses:
if resp.media: for resp in responses:
await send_media_group(bot_token, chat_id, resp.media, reply_to_message_id=message_id) if resp.text:
await send_reply(bot_token, chat_id, resp.text, reply_to_message_id=message_id)
if resp.media:
await send_media_group(bot_token, chat_id, resp.media, reply_to_message_id=message_id)
except Exception: except Exception:
_LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True) _LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True)
@@ -0,0 +1,149 @@
"""Single entry point for all Telegram send operations.
Both the notification dispatcher (event-driven) and the bot command
handlers (user-driven) funnel their Telegram API calls through this
module. Keeping construction in one place means:
* The shared aiohttp session is always reused (one TCP pool for the
whole process).
* The Telegram file_id caches (URL cache + asset cache) are always
wired in, so repeated sends whether from a scheduled tracker or
a ``/latest`` command reuse cached file_ids instead of re-uploading
the same bytes.
* Future cross-cutting concerns (rate limiting, telemetry, retries)
have exactly one place to live.
The actual Telegram API routine is still ``TelegramClient`` in core
this module just guarantees every caller gets a properly-wired client.
"""
from __future__ import annotations
import asyncio
import contextlib
from typing import Any, AsyncIterator, Callable
import aiohttp
from notify_bridge_core.notifications.telegram.client import (
NotificationResult,
TelegramClient,
)
from .http_session import get_http_session
from .watcher import _get_telegram_caches
async def get_telegram_client(
bot_token: str,
*,
session: aiohttp.ClientSession | None = None,
thumbhash_resolver: Callable[[str], str | None] | None = None,
) -> TelegramClient:
"""Return a ``TelegramClient`` wired to shared session + shared caches.
Every Telegram send in the process should acquire its client from
here constructing ``TelegramClient`` directly skips the caches and
silently halves cache hit rate.
Args:
bot_token: The bot's API token.
session: Optional explicit aiohttp session. Defaults to the
process-wide shared session.
thumbhash_resolver: Optional asset-id thumbhash lookup. The
notification dispatcher passes one so asset-cache entries
invalidate on visual change; the command path doesn't need it
(commands always ask for a fresh result).
"""
if session is None:
session = await get_http_session()
url_cache, asset_cache = await _get_telegram_caches()
return TelegramClient(
session, bot_token,
url_cache=url_cache,
asset_cache=asset_cache,
thumbhash_resolver=thumbhash_resolver,
)
async def send_telegram_message(
bot_token: str,
chat_id: str,
text: str,
*,
reply_to_message_id: int | None = None,
disable_web_page_preview: bool = True,
parse_mode: str = "HTML",
) -> NotificationResult:
"""Send a plain-text Telegram message with caches wired in."""
client = await get_telegram_client(bot_token)
return await client.send_message(
chat_id, text,
reply_to_message_id=reply_to_message_id,
disable_web_page_preview=disable_web_page_preview,
parse_mode=parse_mode,
)
async def send_telegram_media(
bot_token: str,
chat_id: str,
assets: list[dict[str, Any]],
*,
caption: str | None = None,
reply_to_message_id: int | None = None,
max_group_size: int = 10,
chunk_delay: int = 0,
max_asset_data_size: int | None = None,
send_large_photos_as_documents: bool = False,
chat_action: str | None = "typing",
thumbhash_resolver: Callable[[str], str | None] | None = None,
) -> NotificationResult:
"""Send a Telegram media group (or single asset) with caches wired in.
``assets`` must be in ``TelegramClient`` format see
``notify_bridge_core.notifications.telegram.media.build_telegram_asset_entry``
for the canonical builder.
"""
client = await get_telegram_client(
bot_token, thumbhash_resolver=thumbhash_resolver,
)
return await client.send_notification(
chat_id,
assets=assets,
caption=caption,
reply_to_message_id=reply_to_message_id,
max_group_size=max_group_size,
chunk_delay=chunk_delay,
max_asset_data_size=max_asset_data_size,
send_large_photos_as_documents=send_large_photos_as_documents,
chat_action=chat_action,
)
@contextlib.asynccontextmanager
async def telegram_chat_action(
bot_token: str,
chat_id: str,
action: str | None,
) -> AsyncIterator[None]:
"""Hold a Telegram chat action (e.g. ``upload_photo``) for the block's duration.
Used by the command path to show ``typing`` / ``uploading photo`` while
the bot fetches assets from the service (Immich, etc.) AND uploads them
to Telegram i.e. for the whole user-visible wait, not just the upload.
A ``None`` action makes this a no-op so callers don't have to branch.
"""
if not action:
yield
return
client = await get_telegram_client(bot_token)
task = client.start_chat_action_keepalive(chat_id, action)
try:
yield
finally:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
@@ -250,14 +250,23 @@ async def _build_immich_event(
collection_ids, limit, asset_type, favorite_only, min_rating, collection_ids, limit, asset_type, favorite_only, min_rating,
) )
# Album-based path: use shared collect_scheduled_assets # Album-based path: use shared collect_scheduled_assets.
# Fetch albums + shared links in parallel — on a 20-album tracker the old
# serial ``await`` loop took ~2 × 20 × RTT, now it's one round-trip.
import asyncio as _asyncio
album_tasks = [immich.client.get_album(aid) for aid in collection_ids]
link_tasks = [immich.client.get_shared_links(aid) for aid in collection_ids]
album_results, link_results = await _asyncio.gather(
_asyncio.gather(*album_tasks, return_exceptions=True),
_asyncio.gather(*link_tasks, return_exceptions=True),
)
albums: dict[str, ImmichAlbumData] = {} albums: dict[str, ImmichAlbumData] = {}
shared_links: dict[str, list[SharedLinkInfo]] = {} shared_links: dict[str, list[SharedLinkInfo]] = {}
for album_id in collection_ids: for album_id, album, links in zip(collection_ids, album_results, link_results):
album = await immich.client.get_album(album_id) if isinstance(album, Exception) or album is None:
if album: continue
albums[album_id] = album albums[album_id] = album
shared_links[album_id] = await immich.client.get_shared_links(album_id) shared_links[album_id] = links if not isinstance(links, Exception) else []
assets, collections_extra = collect_scheduled_assets( assets, collections_extra = collect_scheduled_assets(
albums, shared_links, ext_domain, albums, shared_links, ext_domain,
@@ -320,13 +329,21 @@ async def _build_immich_periodic_event(
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "") 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] = {} albums: dict[str, ImmichAlbumData] = {}
shared_links: dict[str, list[SharedLinkInfo]] = {} shared_links: dict[str, list[SharedLinkInfo]] = {}
for album_id in collection_ids: for album_id, album, links in zip(collection_ids, album_results, link_results):
album = await immich.client.get_album(album_id) if isinstance(album, Exception) or album is None:
if album: continue
albums[album_id] = album albums[album_id] = album
shared_links[album_id] = await immich.client.get_shared_links(album_id) shared_links[album_id] = links if not isinstance(links, Exception) else []
# limit=0 → returns ([], collections_extra) with full per-album stats. # limit=0 → returns ([], collections_extra) with full per-album stats.
_assets, collections_extra = collect_scheduled_assets( _assets, collections_extra = collect_scheduled_assets(
@@ -21,7 +21,11 @@ from ..database.models import (
NotificationTrackerState, NotificationTrackerState,
ServiceProvider, ServiceProvider,
) )
from .dispatch_helpers import event_allowed_by_config, load_link_data from .dispatch_helpers import (
event_allowed_by_config,
get_app_timezone,
load_link_data,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -31,8 +35,34 @@ _asset_cache: TelegramFileCache | None = None
_cache_lock = asyncio.Lock() _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]: 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 global _url_cache, _asset_cache
if _url_cache is not None: if _url_cache is not None:
return _url_cache, _asset_cache return _url_cache, _asset_cache
@@ -46,16 +76,91 @@ async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFile
if not data_dir: if not data_dir:
return None, None return None, None
cache_dir = Path(data_dir) / "cache" cache_dir = Path(data_dir) / "cache"
url_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_url_cache.json")) ttl_seconds, max_entries = await _load_cache_settings()
asset_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_asset_cache.json")) 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 url_cache.async_load()
await asset_cache.async_load() await asset_cache.async_load()
_url_cache = url_cache _url_cache = url_cache
_asset_cache = asset_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 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]: async def check_tracker(tracker_id: int) -> dict[str, Any]:
"""Poll a tracker's provider for changes and dispatch notifications.""" """Poll a tracker's provider for changes and dispatch notifications."""
engine = get_engine() engine = get_engine()
@@ -82,10 +187,22 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
"asset_ids": s.asset_ids, "asset_ids": s.asset_ids,
"pending_asset_ids": s.pending_asset_ids, "pending_asset_ids": s.pending_asset_ids,
"shared": bool(s.shared), "shared": bool(s.shared),
"meta_fingerprint": s.meta_fingerprint or {},
} }
# Snapshot the original fingerprint per collection so we can skip the
# (expensive) asset_ids rewrite when nothing changed. For a 200k-asset
# album this avoids a ~7 MB JSON write to the state row every tick.
original_fingerprints: dict[str, dict[str, Any]] = {
cid: dict(cstate.get("meta_fingerprint") or {})
for cid, cstate in state_dict.items()
}
# Load tracker-target links # Load tracker-target links
link_data = await load_link_data(session, tracker_id, check_quiet_hours=True) link_data = await load_link_data(session, tracker_id)
# Load app-level timezone for quiet-hours evaluation.
app_tz = await get_app_timezone(session)
# Snapshot the data we need # Snapshot the data we need
provider_type = provider.type provider_type = provider.type
@@ -129,6 +246,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
name=provider_name, name=provider_name,
tracker_name=tracker_name, tracker_name=tracker_name,
custom_variables=custom_vars, custom_variables=custom_vars,
timezone_name=app_tz,
) )
events, new_state = await sched.poll(collection_ids, state_dict) events, new_state = await sched.poll(collection_ids, state_dict)
elif provider_type == "nut": elif provider_type == "nut":
@@ -171,11 +289,20 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
existing = s existing = s
break break
current_fingerprint = dict(cstate.get("meta_fingerprint") or {})
prior_fingerprint = original_fingerprints.get(cid, {})
# Skip the DB update when the provider reported no meaningful
# change. ``existing`` is None on first-ever fetch for a
# collection — that path always writes so the row gets created.
if existing is not None and current_fingerprint == prior_fingerprint:
continue
if existing: if existing:
existing.asset_ids = cstate.get("asset_ids", []) existing.asset_ids = cstate.get("asset_ids", [])
existing.pending_asset_ids = cstate.get("pending_asset_ids", []) existing.pending_asset_ids = cstate.get("pending_asset_ids", [])
existing.collection_name = cstate.get("name", "") existing.collection_name = cstate.get("name", "")
existing.shared = cstate.get("shared", False) existing.shared = cstate.get("shared", False)
existing.meta_fingerprint = current_fingerprint
session.add(existing) session.add(existing)
else: else:
new_ts = NotificationTrackerState( new_ts = NotificationTrackerState(
@@ -185,11 +312,32 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
shared=cstate.get("shared", False), shared=cstate.get("shared", False),
asset_ids=cstate.get("asset_ids", []), asset_ids=cstate.get("asset_ids", []),
pending_asset_ids=cstate.get("pending_asset_ids", []), pending_asset_ids=cstate.get("pending_asset_ids", []),
meta_fingerprint=current_fingerprint,
) )
session.add(new_ts) session.add(new_ts)
for event in events: for event in events:
assets_count = event.added_count or event.removed_count or 0 assets_count = event.added_count or event.removed_count or 0
details: dict[str, Any] = {
"added_count": event.added_count,
"removed_count": event.removed_count,
"provider_type": event.provider_type.value,
}
# Scheduler/periodic events carry the schedule context in ``extra``
# (cron expression, interval, timezone, fire count). Surface that
# in the event log so the dashboard and audit queries can show
# *why* the event fired, not just that it did.
if event.event_type.value == "scheduled_message":
sched_type = tracker_filters.get("schedule_type", "interval")
details["schedule_type"] = sched_type
if sched_type == "cron":
details["cron_expression"] = tracker_filters.get("cron_expression", "")
else:
details["interval_seconds"] = tracker.scan_interval
details["timezone"] = app_tz
fire_count = event.extra.get("fire_count") if event.extra else None
if fire_count is not None:
details["fire_count"] = fire_count
log = EventLog( log = EventLog(
user_id=tracker.user_id, user_id=tracker.user_id,
tracker_id=tracker_id, tracker_id=tracker_id,
@@ -200,11 +348,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
collection_id=event.collection_id, collection_id=event.collection_id,
collection_name=event.collection_name, collection_name=event.collection_name,
assets_count=assets_count, assets_count=assets_count,
details={ details=details,
"added_count": event.added_count,
"removed_count": event.removed_count,
"provider_type": event.provider_type.value,
},
) )
session.add(log) session.add(log)
@@ -236,7 +380,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
for ld in link_data: for ld in link_data:
# Apply per-link event filtering from tracking config # Apply per-link event filtering from tracking config
tc = ld["tracking_config"] tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc): if tc and not event_allowed_by_config(event, tc, app_tz):
_LOGGER.info(" Skipped by tracking config filter") _LOGGER.info(" Skipped by tracking config filter")
continue continue
+3
View File
@@ -25,6 +25,9 @@ fi
# Start backend # Start backend
export NOTIFY_BRIDGE_DATA_DIR=./test-data export NOTIFY_BRIDGE_DATA_DIR=./test-data
export NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32-chars export NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32-chars
# Dev targets (homelab Immich / Gitea / etc.) live on RFC1918 ranges; the SSRF
# guard rejects private addresses by default, which would make trackers fail.
export NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \ nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \
--host 0.0.0.0 --port 8420 > .backend.log 2>&1 & --host 0.0.0.0 --port 8420 > .backend.log 2>&1 &