Compare commits

...

9 Commits

Author SHA1 Message Date
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
21 changed files with 300 additions and 101 deletions
+18 -27
View File
@@ -1,41 +1,32 @@
## v0.2.1 (2026-04-22)
## v0.2.3 (2026-04-22)
Security-focused release on top of v0.2.0. Hardens the restore/backup flow,
CSRF/SSRF surfaces, JWT revocation on role change, and template-context
leakage; adds a new **per-tracking-config quiet hours** feature with
app-level IANA timezone support; plus a handful of performance fixes.
Bot-command scope hardening: commands now see only what their chat is wired to
receive notifications about, closing a leak where a bot serving multiple chats
exposed the whole provider catalog to every chat. Plus a handful of Immich
command fixes (missing `public_url` enrichment, silently-swallowed search errors,
always-on link previews).
### Features
- **Per-tracking-config quiet hours with app-level IANA timezone** — new `Timezone` app setting (defaults to `UTC`) and a `Quiet Hours` section on the Immich tracking-config form. HH:MM windows (including overnight, e.g. `22:0007:00`) are interpreted in the configured timezone and suppress all notifications for that tracker. ([6c3dd67](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6c3dd67))
- **Per-chat album scope derived from notification routing** — for a `(provider, bot, chat_id)` triple, the allowed album set is now computed by walking `TargetReceiver → NotificationTarget → NotificationTrackerTarget → NotificationTracker` and unioning the collection IDs. `/albums`, `/random`, `/search`, `/find`, `/latest`, `/memory`, `/summary`, `/favorites`, `/place`, `/person`, `/status`, `/events` all intersect their results with the resolved scope. Chats with no notification routing for a tracker return nothing rather than leaking the provider's catalog. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
- **Scope modal relabeled** — the per-listener `allowed_album_ids` UI is now explicitly an *override for this bot* (escape hatch when you want a divergent scope for a whole bot); the default is *derive from notification routing*, which matches what operators have already configured elsewhere. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
- **Drop tracker counts from `/status`** — `trackers_active` / `trackers_total` were per-provider aggregates that would leak info about trackers a chat has no visibility into. Immich default `/status` templates (en, ru) now show only *Albums* + *Last event*; the template-editor variable catalog no longer suggests the removed vars for the Immich `/status` slot. **Note:** custom templates that reference `{{ trackers_active }}` / `{{ trackers_total }}` need to be updated. ([5a232f1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5a232f1))
### Security
### Bug Fixes
- **Signed & verified pending-restore bundles** — SHA256 stored in `AppSetting` and checked on startup apply; files outside `data_dir` are refused and permissions tightened to `0600`. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Same-origin check on `POST /api/backup/apply-restart`** — Bearer-in-localStorage was CSRF-reachable from any XSS'd admin tab; require matching `Origin`/`Referer`. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **JWT `token_version` bumps on demotion** — role/username change and admin password reset now bump `token_version` so already-issued tokens lose admin. Last-admin TOCTOU guarded by `COUNT` + post-commit recheck that rolls back on race. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **SSRF guard extended** to `ImmichClient.__init__` and the `external_domain` setter — admin-mutable URLs were bypassing the check that webhook / Slack / Discord paths already used. Dev `scripts/restart-backend.sh` now sets `NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1` so homelab Immich instances still work. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Redact & cap Immich error bodies** (~120 chars) before they flow into `ActionExecution.error` / `EventLog.details` (both UI-visible). ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Deny-list sensitive keys** (`api_key`, `token`, `secret`, `password`, `authorization`, `cookie`, …) in template-context merges so a rogue template cannot exfiltrate provider creds via `{{ api_key }}`. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Cap user-controlled Immich search params** — `query` ≤ 256, `person_ids` ≤ 50, `size` ≤ 100 — so a Telegram listener cannot DoS upstream. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Stream upload reads** with a running byte counter + `Content-Length` precheck instead of buffering the full body and then rejecting. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Log Telegram `parse_mode` fallbacks** instead of swallowing silently — template escape bugs now surface in server logs. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Rollback partial imports** on pending-restore failure (error recorded on a fresh session). ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
### Performance
- **Fix N+1** in `_refresh_telegram_chat_titles` — single `IN` query instead of `session.get` per chat. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Parallelize album & shared-link fetches** in `test_dispatch` via `asyncio.gather`, and per-receiver Telegram test sends in the notifier with a semaphore of 5. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Early-exit `collect_scheduled_assets(limit=0)`** so the periodic-summary test path skips the full per-album filter/sample (was O(album_assets)). ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Explicit `CREATE INDEX IF NOT EXISTS`** for `event_log` (`user_id` / `action_id` / `provider_id`) so the first boot after upgrade isn't left unindexed for the dashboard query. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **Add `AbortController` timeout (120s)** to `fetchAuth` so uploads/downloads don't hang indefinitely. ([56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2))
- **`/albums` honors per-chat scope** — previously ignored `CommandTrackerListener.allowed_album_ids` and listed every album tracked by the provider, so scoped chats saw neighbours' albums. Now applies the same intersect filter the `/_cmd_immich` media commands use. ([4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876))
- **Disable Telegram link previews on command text replies** — listings (`/albums`, `/events`, `/people`, …) embed multiple links and were rendering a preview for the first URL regardless of the operator's *Disable link previews* toggle. `send_reply` now always passes `disable_web_page_preview=True`. ([4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876))
- **Restore `public_url` enrichment on `/search`, `/find`, `/person`, `/place`** — `_enrich_assets`'s return value was being discarded, dropping the public URL populated on each asset. Now assigned properly. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
- **Surface Immich search errors instead of silently returning `[]`** — `search_smart` / `search_metadata` consolidated into a `_search_items` helper that logs non-200 responses and transport errors, and accepts the alternate `{"assets": [...]}` flat-list shape from older Immich versions. "Always no results" bugs are now diagnosable. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
- **Redact Immich search error bodies** before they land in server logs — credentials echoed by authenticating proxies no longer leak into logs. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
---
<details>
<summary>All Commits</summary>
- [6c3dd67](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6c3dd67) — feat(tracking): per-config quiet hours with app-level IANA timezone _(alexei.dolgolyov)_
- [56993d2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/56993d2) — fix(security,perf): harden restore, CSRF, token_version + perf pass _(alexei.dolgolyov)_
- [5a232f1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5a232f1) — feat(commands): drop tracker counts from /status *(alexei.dolgolyov)*
- [4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876) — fix(commands): /albums honors per-chat scope, disable link previews *(alexei.dolgolyov)*
- [3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09) — feat(commands): per-chat album scope derived from notification routing *(alexei.dolgolyov)*
</details>
+4
View File
@@ -12,6 +12,10 @@ services:
environment:
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-*}
# Homelab target: allow outbound requests to RFC1918 / link-local addresses.
# The SSRF guard otherwise rejects 10.*/172.16.*/192.168.*/169.254.* hosts,
# which breaks tracking of Immich / Gitea / etc. running on the same LAN.
- NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"]
interval: 30s
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.2.1",
"version": "0.2.3",
"type": "module",
"scripts": {
"dev": "vite dev",
+4 -4
View File
@@ -802,11 +802,11 @@
"selectBot": "Select bot...",
"listenerType": "telegram_bot",
"editScope": "Edit album scope",
"scopeAll": "all albums",
"scopeAll": "derived from notification routing",
"albumsShort": "albums",
"scopeTitle": "Album Scope for This Chat",
"scopeDescription": "Restrict which tracked albums this chat can query via commands. Leave on \"inherit\" to allow all albums from the tracker.",
"scopeInherit": "Inherit: allow all tracked albums",
"scopeTitle": "Album Scope Override for This Bot",
"scopeDescription": "By default this bot's commands see only the albums that actually deliver notifications to the chats it speaks to (computed from your notification trackers). Set an explicit override here to widen or narrow that set for every chat this bot serves.",
"scopeInherit": "Inherit: derive from notification routing",
"noCollections": "No albums available."
},
"snackbar": {
+4 -4
View File
@@ -802,11 +802,11 @@
"selectBot": "Выберите бота...",
"listenerType": "telegram_bot",
"editScope": "Изменить область альбомов",
"scopeAll": "все альбомы",
"scopeAll": "из маршрутизации уведомлений",
"albumsShort": "альбомов",
"scopeTitle": "Область альбомов для этого чата",
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.",
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы",
"scopeTitle": "Переопределение области альбомов для этого бота",
"scopeDescription": "По умолчанию команды этого бота видят только альбомы, уведомления которых приходят в его чаты (вычисляется из ваших трекеров уведомлений). Задайте явный список здесь, чтобы расширить или сузить этот набор для всех чатов данного бота.",
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
"noCollections": "Нет доступных альбомов."
},
"snackbar": {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.2.1"
version = "0.2.3"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -61,14 +61,15 @@ class ImmichClient:
# SSRF guard — admin-set Immich URLs are loaded from provider config
# which can be mutated via PATCH /api/providers or imported via
# prepare-restore, so we revalidate at construction time rather than
# trusting DB state. ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` bypasses
# for dev against localhost Immich.
# 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}"
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
@@ -81,9 +82,8 @@ class ImmichClient:
@external_domain.setter
def external_domain(self, value: str | None) -> None:
# Mirror the constructor's SSRF guard — external_domain is used to
# build URLs that leak into rendered notifications, but any code path
# that eventually fetches this URL would otherwise bypass the check.
# Mirror the constructor's SSRF guard. Set
# ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` for LAN/homelab targets.
if value:
try:
validate_outbound_url(value)
@@ -297,19 +297,9 @@ class ImmichClient:
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids:
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
try:
async with self._session.post(
f"{self._url}/api/search/smart",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
return items[:limit]
except aiohttp.ClientError:
pass
return []
return await self._search_items(
f"{self._url}/api/search/smart", payload, limit, "smart",
)
async def search_metadata(
self,
@@ -322,18 +312,57 @@ class ImmichClient:
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids:
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
return await self._search_items(
f"{self._url}/api/search/metadata", payload, limit, "metadata",
)
async def _search_items(
self,
url: str,
payload: dict[str, Any],
limit: int,
kind: str,
) -> list[dict[str, Any]]:
"""Shared POST-and-extract-items helper with error logging.
Returns an empty list on any error; previously these paths swallowed
non-200s silently, making "/search always returns no results" on
misbehaving Immich deployments impossible to diagnose without a
network trace. Logging keeps the empty-list contract but tells the
operator *why* it's empty.
"""
try:
async with self._session.post(
f"{self._url}/api/search/metadata",
url,
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
if response.status != 200:
body_snip = await response.text()
_LOGGER.warning(
"Immich %s search non-200: HTTP %s body=%s",
kind, response.status, _redact_body(body_snip),
)
return []
data = await response.json()
# Modern Immich: {"assets": {"items": [...], ...}}
assets_block = data.get("assets")
if isinstance(assets_block, dict):
items = assets_block.get("items", []) or []
elif isinstance(assets_block, list):
# Older/alternate shape — flat list of assets.
items = assets_block
else:
_LOGGER.warning(
"Immich %s search returned unexpected shape: keys=%s",
kind, list(data.keys())[:5],
)
items = []
return items[:limit]
except aiohttp.ClientError as err:
_LOGGER.warning("Immich %s search transport error: %s", kind, err)
except Exception as err: # noqa: BLE001 — don't crash caller on unexpected JSON
_LOGGER.warning("Immich %s search parse error: %s", kind, err)
return []
async def search_by_person(
@@ -1,4 +1,3 @@
📊 Status
Trackers: {{ trackers_active }}/{{ trackers_total }} active
Albums: {{ total_albums }}
Last event: {{ last_event }}
Last event: {{ last_event }}
@@ -1,4 +1,3 @@
📊 Статус
Трекеры: {{ trackers_active }}/{{ trackers_total }} активных
Альбомы: {{ total_albums }}
Последнее событие: {{ last_event }}
Последнее событие: {{ last_event }}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.2.1"
version = "0.2.3"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -138,13 +138,11 @@ async def get_command_variables(
# --- Immich-specific ---
immich = {
"status": {
"description": "/status tracker summary",
"description": "/status tracker summary (scoped to this chat)",
"variables": {
**common_vars,
"trackers_active": "Number of active trackers",
"trackers_total": "Total tracker count",
"total_albums": "Total tracked albums",
"last_event": "Last event timestamp string",
"total_albums": "Tracked albums visible to this chat",
"last_event": "Last event timestamp string (scoped to this chat's albums)",
},
},
"albums": {
@@ -56,6 +56,7 @@ class ProviderCommandHandler(ABC):
config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None,
page: int = 1,
) -> CommandResponse | None:
"""Handle a provider-specific command for a single tracker.
@@ -71,6 +72,13 @@ class ProviderCommandHandler(ABC):
bot: The Telegram bot instance.
tracker: The command tracker being dispatched.
config: The command config for this tracker.
listener: The listener row for this (tracker, bot) pair.
allowed_album_ids: Precomputed album scope for this (bot, chat)
pair. Resolved by the dispatcher from the listener override
(if set) or the notification-routing graph. ``None`` means
"no scope restriction" (rarely the right default for album
providers empty set is the common case).
page: 1-based page number for paginated commands (/search, /find).
Returns:
A CommandResponse, or None if unhandled.
@@ -8,7 +8,14 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import EventLog, NotificationTracker, ServiceProvider
from ..database.models import (
EventLog,
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
ServiceProvider,
TargetReceiver,
)
_LOGGER = logging.getLogger(__name__)
@@ -20,25 +27,125 @@ async def get_trackers_for_provider(provider_id: int) -> list[NotificationTracke
return await _get_notification_trackers_for_providers({provider_id})
async def get_last_event_str(tracker_ids: list[int]) -> str:
async def get_last_event_str(
tracker_ids: list[int],
*,
allowed_album_ids: set[str] | None = None,
) -> str:
"""Get formatted timestamp of most recent event for given trackers.
Returns a 'YYYY-MM-DD HH:MM' string, or '-' if no events exist.
When ``allowed_album_ids`` is provided, only events whose
``collection_id`` is in the set are considered matches the per-chat
scope applied via ``CommandTrackerListener.allowed_album_ids``.
"""
if not tracker_ids:
return "-"
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
query = (
select(EventLog)
.where(EventLog.tracker_id.in_(tracker_ids))
.order_by(EventLog.created_at.desc())
.limit(1)
)
if allowed_album_ids is not None:
query = query.where(EventLog.collection_id.in_(list(allowed_album_ids)))
result = await session.exec(query.limit(1))
last_event = result.first()
return last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
async def resolve_chat_album_scope(
*,
provider_id: int,
bot_id: int,
chat_id: str,
) -> set[str]:
"""Compute the album scope for a (provider, bot, chat) triple.
Walks the notification-routing graph: find every notification tracker for
``provider_id`` that ultimately delivers to a Telegram receiver matching
this ``(bot_id, chat_id)``, then union their ``collection_ids``. The
result is the set of albums this specific chat legitimately sees
notifications for which is the natural "allowed albums" for commands
issued in that chat.
Returns:
set of album ids. Empty set = "no tracker routes to this chat"
caller should treat as "show nothing" (defense in depth); otherwise
a bot's chats would leak the provider's full album catalog.
Notes:
- Only enabled ``TargetReceiver`` rows are considered.
- Both direct Telegram targets and broadcast targets that fan out
to a Telegram child target are resolved.
- Explicit ``CommandTrackerListener.allowed_album_ids`` override is
NOT applied here that's the dispatcher's job. This helper is
the "derived" fallback.
"""
engine = get_engine()
async with AsyncSession(engine) as session:
# 1. Telegram receivers in this chat (directly or via broadcast).
direct_rows = (await session.exec(
select(TargetReceiver, NotificationTarget)
.join(
NotificationTarget,
TargetReceiver.target_id == NotificationTarget.id,
)
.where(
TargetReceiver.enabled == True, # noqa: E712
NotificationTarget.type == "telegram",
)
)).all()
target_ids: set[int] = set()
for recv, target in direct_rows:
rc_chat = str(recv.config.get("chat_id", "") or "")
rc_bot = target.config.get("bot_id")
if rc_chat == str(chat_id) and rc_bot == bot_id:
target_ids.add(target.id)
# Follow broadcast parents: any broadcast target whose
# child_target_ids includes one of our direct Telegram target_ids
# also counts as "routes to this chat".
broadcast_rows = (await session.exec(
select(NotificationTarget).where(NotificationTarget.type == "broadcast")
)).all()
for b in broadcast_rows:
children = set(b.config.get("child_target_ids", []) or [])
disabled = set(b.config.get("disabled_child_ids", []) or [])
if (children - disabled) & target_ids:
target_ids.add(b.id)
if not target_ids:
return set()
# 2. Trackers pointing at those targets.
tracker_target_rows = (await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.target_id.in_(target_ids)
)
)).all()
tracker_ids = {tt.tracker_id for tt in tracker_target_rows}
if not tracker_ids:
return set()
# 3. Filter trackers by provider and collect collection_ids.
trackers = (await session.exec(
select(NotificationTracker).where(
NotificationTracker.id.in_(tracker_ids),
NotificationTracker.provider_id == provider_id,
)
)).all()
scope: set[str] = set()
for tr in trackers:
for aid in (tr.collection_ids or []):
if aid:
scope.add(aid)
return scope
def get_tracked_collection_ids(
provider: ServiceProvider,
trackers: list[NotificationTracker],
@@ -79,6 +79,7 @@ class GiteaCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Gitea has no album model)
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
@@ -286,6 +286,8 @@ async def handle_command(
page = max(1, count_override)
count_override = None
from .command_utils import resolve_chat_album_scope
responses: list[CommandResponse] = []
for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
@@ -303,10 +305,27 @@ async def handle_command(
count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media"
# Resolve the album scope for this (provider, bot, chat) triple.
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
# - Otherwise derive from notification routing: only albums that
# already deliver notifications to this chat are queryable from
# it. Prevents commands leaking the full album catalog into
# chats that were never set up to receive from those trackers.
if listener is not None and listener.allowed_album_ids is not None:
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
else:
allowed_album_ids = await resolve_chat_album_scope(
provider_id=provider.id,
bot_id=bot.id,
chat_id=chat_id,
)
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
listener=listener, page=page,
listener=listener,
allowed_album_ids=allowed_album_ids,
page=page,
)
if result is not None:
responses.append(result)
@@ -348,12 +367,23 @@ async def send_reply(
bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Send a text reply via TelegramClient."""
"""Send a text reply via TelegramClient.
Command responses are listings (albums, people, events, ...) that embed
multiple links; Telegram's default behavior of rendering a preview of
the first URL is almost never what the user wants and clashes with the
"Disable link previews" toggle operators set on their Telegram target.
We always pass ``disable_web_page_preview=True`` here.
"""
if session is None:
from ..services.http_session import get_http_session
session = await get_http_session()
client = TelegramClient(session, bot_token)
result = await client.send_message(chat_id, text, reply_to_message_id=reply_to_message_id)
result = await client.send_message(
chat_id, text,
reply_to_message_id=reply_to_message_id,
disable_web_page_preview=True,
)
if not result.get("success"):
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
@@ -17,7 +17,10 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_albums(
provider: ServiceProvider, locale: str,
provider: ServiceProvider,
locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
if not trackers:
@@ -31,6 +34,13 @@ async def _cmd_albums(
if aid not in seen:
seen.add(aid)
album_ids.append(aid)
# Intersect with the dispatcher-resolved scope (listener override, else
# derived from notification routing for this chat). Without this,
# /albums leaks the full tracked-album list into chats never wired up.
if allowed_album_ids is not None:
album_ids = [aid for aid in album_ids if aid in allowed_album_ids]
if not album_ids:
return {"albums": []}
@@ -27,6 +27,8 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_events(
provider: ServiceProvider,
count: int, locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
tracker_ids = [t.id for t in trackers]
@@ -35,12 +37,14 @@ async def _cmd_events(
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
query = (
select(EventLog)
.where(EventLog.tracker_id.in_(tracker_ids))
.order_by(EventLog.created_at.desc())
.limit(count)
)
if allowed_album_ids is not None:
query = query.where(EventLog.collection_id.in_(list(allowed_album_ids)))
result = await session.exec(query.limit(count))
events = result.all()
events_data = [
@@ -25,17 +25,31 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_status(
provider: ServiceProvider, locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
active = sum(1 for t in trackers if t.enabled)
total = len(trackers)
total_albums = sum(len(t.collection_ids or []) for t in trackers)
# Count only albums visible to this chat. Without the scope filter,
# /status in a restricted chat leaks the full album count across the
# provider. ``None`` = no filter; empty set = show nothing.
total_albums = 0
for t in trackers:
for aid in (t.collection_ids or []):
if allowed_album_ids is None or aid in allowed_album_ids:
total_albums += 1
# Last-event timestamp is already scoped — see get_last_event_str, which
# filters EventLog by collection_id against allowed_album_ids.
tracker_ids = [t.id for t in trackers]
last_str = await get_last_event_str(tracker_ids)
last_str = await get_last_event_str(
tracker_ids, allowed_album_ids=allowed_album_ids,
)
# Tracker counts (``trackers_active`` / ``trackers_total``) are a
# per-provider aggregate — they'd leak info about trackers this chat
# has no visibility into once we've scoped everything else. Omitted.
return {
"trackers_active": active, "trackers_total": total,
"total_albums": total_albums, "last_event": last_str,
}
@@ -80,16 +94,17 @@ class ImmichCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None,
page: int = 1,
) -> CommandResponse | None:
if cmd == "status":
ctx = await _cmd_status(provider, locale)
ctx = await _cmd_status(provider, locale, allowed_album_ids=allowed_album_ids)
return CommandResponse(text=_render_cmd_template(cmd_templates, "status", locale, ctx))
if cmd == "albums":
ctx = await _cmd_albums(provider, locale)
ctx = await _cmd_albums(provider, locale, allowed_album_ids=allowed_album_ids)
return CommandResponse(text=_render_cmd_template(cmd_templates, "albums", locale, ctx))
if cmd == "events":
ctx = await _cmd_events(provider, count, locale)
ctx = await _cmd_events(provider, count, locale, allowed_album_ids=allowed_album_ids)
return CommandResponse(text=_render_cmd_template(cmd_templates, "events", locale, ctx))
if cmd == "people":
ctx = await _cmd_people(provider, locale)
@@ -99,7 +114,7 @@ class ImmichCommandHandler(ProviderCommandHandler):
return await _cmd_immich(
cmd, args, count, locale, response_mode,
provider, cmd_templates,
listener=listener, page=page,
allowed_album_ids=allowed_album_ids, page=page,
)
return None
@@ -109,7 +124,7 @@ async def _cmd_immich(
response_mode: str, provider: ServiceProvider,
cmd_templates: dict[str, dict[str, str]],
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None,
page: int = 1,
) -> CommandResponse | None:
"""Handle commands that need Immich API access and may return media."""
@@ -123,10 +138,12 @@ async def _cmd_immich(
seen.add(aid)
all_album_ids.append(aid)
# Per-chat album scope: intersect with listener.allowed_album_ids when set.
if listener is not None and listener.allowed_album_ids is not None:
allowed = set(listener.allowed_album_ids)
all_album_ids = [aid for aid in all_album_ids if aid in allowed]
# Intersect with the scope resolved by the dispatcher (from the listener
# override if set, otherwise from the notification-routing graph for this
# chat). ``None`` = no filter (rare); empty set = show nothing (common
# when the chat has no tracker routing).
if allowed_album_ids is not None:
all_album_ids = [aid for aid in all_album_ids if aid in allowed_album_ids]
ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/")
@@ -31,7 +31,7 @@ async def cmd_search(
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""})
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count, 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)
@@ -46,7 +46,7 @@ async def cmd_find(
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""})
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count, 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)
@@ -68,7 +68,7 @@ async def cmd_person(
if not person_id:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
assets = await client.search_by_person(person_id, limit=count)
_enrich_assets(assets, asset_public_urls or {})
assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "person", args, locale, response_mode, client, cmd_templates)
@@ -84,5 +84,5 @@ async def cmd_place(
assets = await client.search_smart(
f"photos taken in {args}", album_ids=all_album_ids, limit=count
)
_enrich_assets(assets, asset_public_urls or {})
assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "place", args, locale, response_mode, client, cmd_templates)
@@ -54,6 +54,7 @@ class NutCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (NUT has no album model)
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
@@ -71,6 +71,7 @@ class PlankaCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Planka has no album model)
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)