Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5028f15f4f | |||
| 5a232f18b8 | |||
| 3b76a09759 | |||
| 4ff3876e49 |
+20
-15
@@ -1,27 +1,32 @@
|
|||||||
## v0.2.2 (2026-04-22)
|
## v0.2.3 (2026-04-22)
|
||||||
|
|
||||||
Patch release — homelab usability fixes on top of v0.2.1. The SSRF hardening
|
Bot-command scope hardening: commands now see only what their chat is wired to
|
||||||
introduced in v0.2.1 blocks outbound requests to RFC1918 / link-local hosts,
|
receive notifications about, closing a leak where a bot serving multiple chats
|
||||||
which breaks tracking of Immich / Gitea / etc. running on the same LAN.
|
exposed the whole provider catalog to every chat. Plus a handful of Immich
|
||||||
This release makes the workaround discoverable and enables it by default
|
command fixes (missing `public_url` enrichment, silently-swallowed search errors,
|
||||||
in the shipped `docker-compose.yml`.
|
always-on link previews).
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **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))
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- **Default `NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1` in `docker-compose.yml`** — the shipped compose is intended for homelab use. The flag is now hardcoded in the `environment:` block (not a `${...}` substitution) so it works correctly with Portainer's per-stack env panel, which only does compose-file substitution and not runtime container env. Operators running on a public-facing host can drop the line. ([4e23d2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4e23d2b))
|
- **`/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))
|
||||||
### Documentation
|
- **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))
|
||||||
- **Surface `NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS` hint in SSRF rejection errors** — the `UnsafeURLError` raised by `ImmichClient` now tells operators how to allow LAN targets, instead of leaving them to dig through source. ([58cba88](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/58cba88))
|
- **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>
|
<details>
|
||||||
<summary>All Commits</summary>
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
- [4e23d2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4e23d2b) — chore(compose): hardcode NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 in compose _(alexei.dolgolyov)_
|
- [5a232f1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5a232f1) — feat(commands): drop tracker counts from /status *(alexei.dolgolyov)*
|
||||||
- [f7d51b2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f7d51b2) — Revert "chore(compose): default NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 for homelab" _(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)*
|
||||||
- [3bb0585](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3bb0585) — chore(compose): default NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 for homelab _(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)*
|
||||||
- [58cba88](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/58cba88) — docs(immich-ssrf): surface NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS hint in error _(alexei.dolgolyov)_
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -802,11 +802,11 @@
|
|||||||
"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": {
|
||||||
|
|||||||
@@ -802,11 +802,11 @@
|
|||||||
"selectBot": "Выберите бота...",
|
"selectBot": "Выберите бота...",
|
||||||
"listenerType": "telegram_bot",
|
"listenerType": "telegram_bot",
|
||||||
"editScope": "Изменить область альбомов",
|
"editScope": "Изменить область альбомов",
|
||||||
"scopeAll": "все альбомы",
|
"scopeAll": "из маршрутизации уведомлений",
|
||||||
"albumsShort": "альбомов",
|
"albumsShort": "альбомов",
|
||||||
"scopeTitle": "Область альбомов для этого чата",
|
"scopeTitle": "Переопределение области альбомов для этого бота",
|
||||||
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.",
|
"scopeDescription": "По умолчанию команды этого бота видят только альбомы, уведомления которых приходят в его чаты (вычисляется из ваших трекеров уведомлений). Задайте явный список здесь, чтобы расширить или сузить этот набор для всех чатов данного бота.",
|
||||||
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы",
|
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
||||||
"noCollections": "Нет доступных альбомов."
|
"noCollections": "Нет доступных альбомов."
|
||||||
},
|
},
|
||||||
"snackbar": {
|
"snackbar": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-core"
|
name = "notify-bridge-core"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -297,19 +297,9 @@ class ImmichClient:
|
|||||||
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": min(max(1, limit), 100)}
|
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[:MAX_SEARCH_PERSON_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,
|
||||||
@@ -322,18 +312,57 @@ class ImmichClient:
|
|||||||
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": min(max(1, limit), 100)}
|
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[:MAX_SEARCH_PERSON_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(
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-server"
|
name = "notify-bridge-server"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -286,6 +286,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 +305,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 +367,23 @@ 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 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:
|
if session is None:
|
||||||
from ..services.http_session import get_http_session
|
from ..services.http_session import get_http_session
|
||||||
session = await get_http_session()
|
session = await get_http_session()
|
||||||
client = TelegramClient(session, bot_token)
|
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"):
|
if not result.get("success"):
|
||||||
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
|
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
|
||||||
|
|
||||||
|
|||||||
@@ -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": []}
|
||||||
|
|
||||||
|
|||||||
@@ -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("/")
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,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 +46,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 +68,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 +84,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)
|
||||||
|
|||||||
Reference in New Issue
Block a user