3b76a09759
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.
98 lines
3.5 KiB
Python
98 lines
3.5 KiB
Python
"""Album-related Immich bot commands: albums, favorites, summary."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Any
|
|
|
|
from ...database.models import ServiceProvider
|
|
from ...services import make_immich_provider
|
|
from ...services.http_session import get_http_session
|
|
from ..command_utils import get_trackers_for_provider
|
|
from ..handler import _render_cmd_template
|
|
from .common import _format_assets, build_asset_dict, fetch_albums_with_links
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
async def _cmd_albums(
|
|
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:
|
|
return {"albums": []}
|
|
|
|
# Deduplicate album IDs while preserving order
|
|
seen: set[str] = set()
|
|
album_ids: list[str] = []
|
|
for tracker in trackers:
|
|
for aid in tracker.collection_ids or []:
|
|
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": []}
|
|
|
|
ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/")
|
|
http = await get_http_session()
|
|
immich = make_immich_provider(http, provider)
|
|
albums_data = await fetch_albums_with_links(immich.client, album_ids, ext_domain)
|
|
|
|
return {"albums": albums_data}
|
|
|
|
|
|
async def cmd_favorites(
|
|
providers_map: dict[int, ServiceProvider],
|
|
all_album_ids: list[str], count: int, locale: str,
|
|
response_mode: str, client: Any,
|
|
cmd_templates: dict[str, dict[str, str]],
|
|
) -> str | dict[str, Any]:
|
|
"""Handle /favorites command with concurrent album fetching."""
|
|
album_ids = all_album_ids[:10]
|
|
if not album_ids:
|
|
return _format_assets([], "favorites", "", locale, response_mode, client, cmd_templates)
|
|
|
|
results = await asyncio.gather(
|
|
*[client.get_album(aid) for aid in album_ids],
|
|
return_exceptions=True,
|
|
)
|
|
|
|
fav_assets: list[dict[str, Any]] = []
|
|
for album_id, result in zip(album_ids, results):
|
|
if isinstance(result, Exception):
|
|
_LOGGER.warning("Failed to fetch album %s: %s", album_id, result)
|
|
continue
|
|
if result:
|
|
for aid, asset in list(result.assets.items())[:50]:
|
|
if asset.is_favorite and len(fav_assets) < count:
|
|
fav_assets.append(build_asset_dict(asset))
|
|
if len(fav_assets) >= count:
|
|
break
|
|
|
|
return _format_assets(fav_assets, "favorites", "", locale, response_mode, client, cmd_templates)
|
|
|
|
|
|
async def cmd_summary(
|
|
client: Any, all_album_ids: list[str], locale: str,
|
|
cmd_templates: dict[str, dict[str, str]],
|
|
external_domain: str = "",
|
|
) -> str:
|
|
"""Handle /summary command with concurrent album fetching."""
|
|
if not all_album_ids:
|
|
return _render_cmd_template(cmd_templates, "summary", locale, {"albums": []})
|
|
|
|
ext = external_domain.rstrip("/")
|
|
albums_data = await fetch_albums_with_links(client, all_album_ids, ext, include_failed=False)
|
|
return _render_cmd_template(cmd_templates, "summary", locale, {"albums": albums_data})
|