"""Test dispatch — manual trigger through the real NotificationDispatcher. No separate logic — just builds a ServiceEvent + TargetConfig from DB objects and dispatches through the same path the watcher uses. """ import logging from typing import Any from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from notify_bridge_core.models.events import EventType, ServiceEvent from notify_bridge_core.models.media import MediaAsset from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig from notify_bridge_core.providers.base import ServiceProviderType from ..database.models import ( NotificationTarget, NotificationTracker, NotificationTrackerTarget, ServiceProvider, TemplateConfig, TemplateSlot, TrackingConfig, ) from .dispatch_helpers import _resolve_target from .watcher import _get_telegram_caches _LOGGER = logging.getLogger(__name__) # Maps test_type → DB template slot name _TEST_TYPE_SLOT_MAP = { "periodic": "periodic_summary_message", "scheduled": "scheduled_assets_message", "memory": "memory_mode_message", } async def dispatch_test_notification( *, session: AsyncSession, tracker: NotificationTracker, tt: NotificationTrackerTarget, target: NotificationTarget, test_type: str, locale: str = "en", ) -> dict[str, Any]: """Dispatch a test notification through the real NotificationDispatcher.""" # Load provider provider = await session.get(ServiceProvider, tracker.provider_id) if not provider: return {"success": False, "error": "Provider not found"} provider_config = dict(provider.config) collection_ids = list(tracker.collection_ids or []) # Load tracking config tracking_config = None if tt.tracking_config_id: tracking_config = await session.get(TrackingConfig, tt.tracking_config_id) # Load template slots keyed by EventType.SCHEDULED_MESSAGE.value template_config = None template_slots: dict[str, dict[str, str]] | None = None slot_name = _TEST_TYPE_SLOT_MAP.get(test_type, test_type) if tt.template_config_id: template_config = await session.get(TemplateConfig, tt.template_config_id) if template_config: slot_result = await session.exec( select(TemplateSlot).where( TemplateSlot.config_id == template_config.id, TemplateSlot.slot_name == slot_name, ) ) locale_map: dict[str, str] = {} for s in slot_result.all(): locale_map[s.locale] = s.template if locale_map: template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map} # Resolve target config + receivers (same as watcher) resolved = await _resolve_target(session, target) target_cfg = TargetConfig( type=resolved["target_type"], config=resolved["target_config"], template_slots=template_slots, locale=locale, date_format=template_config.date_format if template_config else "%d.%m.%Y, %H:%M UTC", date_only_format=template_config.date_only_format if template_config and template_config.date_only_format else "%d.%m.%Y", provider_api_key=provider_config.get("api_key"), provider_internal_url=provider_config.get("url", ""), provider_external_url=provider_config.get("external_domain", ""), receivers=resolved["receivers"], ) # Fetch assets and build event event = await _build_event( provider_type=provider.type, provider_config=provider_config, provider_name=provider.name or provider.type, tracker_name=tracker.name or "", tracker_filters=dict(tracker.filters) if tracker.filters else {}, collection_ids=collection_ids, test_type=test_type, tracking_config=tracking_config, ) if event is None: return {"success": False, "error": "No data returned from provider"} if not event.added_assets and test_type in ("scheduled", "memory"): return {"success": False, "error": "No matching assets found" + (" for today" if test_type == "memory" else "")} # Dispatch through the real NotificationDispatcher url_cache, asset_cache = await _get_telegram_caches() dispatcher = NotificationDispatcher(url_cache=url_cache, asset_cache=asset_cache) results = await dispatcher.dispatch(event, [target_cfg]) if not results: return {"success": False, "error": "No dispatch results"} return results[0] async def _build_event( *, provider_type: str, provider_config: dict, provider_name: str, tracker_name: str, tracker_filters: dict, collection_ids: list[str], test_type: str, tracking_config: TrackingConfig | None = None, ) -> ServiceEvent | None: """Build a ServiceEvent with real provider data.""" from datetime import datetime, timezone if provider_type == "immich": return await _build_immich_event( provider_config=provider_config, provider_name=provider_name, tracker_name=tracker_name, collection_ids=collection_ids, test_type=test_type, tracking_config=tracking_config, ) elif provider_type == "scheduler": from notify_bridge_core.providers.scheduler import SchedulerServiceProvider custom_vars = tracker_filters.get("custom_variables", {}) sched = SchedulerServiceProvider( name=provider_name, tracker_name=tracker_name, custom_variables=custom_vars, ) events, _ = await sched.poll(collection_ids, {}) return events[0] if events else None return None async def _build_immich_event( *, provider_config: dict, provider_name: str, tracker_name: str, collection_ids: list[str], test_type: str, tracking_config: TrackingConfig | None = None, ) -> ServiceEvent | None: """Build an Immich scheduled/memory event using shared core utilities.""" from datetime import datetime, timezone from notify_bridge_core.providers.immich import ImmichServiceProvider from notify_bridge_core.providers.immich.asset_utils import collect_scheduled_assets from notify_bridge_core.providers.immich.models import ImmichAlbumData, SharedLinkInfo ext_domain = provider_config.get("external_domain") or provider_config.get("url", "") prefix = "memory" if test_type == "memory" else "scheduled" limit = getattr(tracking_config, f"{prefix}_limit", 10) if tracking_config else 10 asset_type = getattr(tracking_config, f"{prefix}_asset_type", "all") if tracking_config else "all" favorite_only = getattr(tracking_config, f"{prefix}_favorite_only", False) if tracking_config else False min_rating = getattr(tracking_config, f"{prefix}_min_rating", 0) if tracking_config else 0 memory_source = getattr(tracking_config, "memory_source", "albums") if tracking_config else "albums" is_memory = test_type == "memory" from .http_session import get_http_session http_session = await get_http_session() immich = ImmichServiceProvider( http_session, provider_config.get("url", ""), provider_config.get("api_key", ""), provider_config.get("external_domain"), provider_name, ) if not await immich.connect(): return None # Native Immich memories API path if is_memory and memory_source == "native": return await _build_native_memory_event( immich, ext_domain, provider_name, tracker_name, collection_ids, limit, asset_type, favorite_only, min_rating, ) # Album-based path: use shared collect_scheduled_assets albums: dict[str, ImmichAlbumData] = {} shared_links: dict[str, list[SharedLinkInfo]] = {} for album_id in collection_ids: album = await immich.client.get_album(album_id) if album: albums[album_id] = album shared_links[album_id] = await immich.client.get_shared_links(album_id) assets, collections_extra = collect_scheduled_assets( albums, shared_links, ext_domain, limit=limit, asset_type=asset_type, favorite_only=favorite_only, min_rating=min_rating, is_memory=is_memory, ) first_col = collections_extra[0] if collections_extra else {} return ServiceEvent( event_type=EventType.SCHEDULED_MESSAGE, provider_type=ServiceProviderType.IMMICH, provider_name=provider_name, collection_id=collection_ids[0] if collection_ids else "", collection_name=first_col.get("name", tracker_name), timestamp=datetime.now(timezone.utc), added_assets=assets, added_count=len(assets), extra={ "collections": collections_extra, "albums": collections_extra, **(first_col if first_col else {}), }, ) async def _build_native_memory_event( immich, ext_domain: str, provider_name: str, tracker_name: str, collection_ids: list[str], limit: int, asset_type: str, favorite_only: bool, min_rating: int, ) -> ServiceEvent | None: """Build event from Immich native memories API.""" import random from datetime import datetime, timezone from notify_bridge_core.models.media import MediaAsset, MediaType from notify_bridge_core.providers.immich.asset_utils import filter_assets from notify_bridge_core.providers.immich.models import ImmichAssetInfo memories = await immich.client.get_memories() tracked_ids = set(collection_ids) if collection_ids else None # Collect raw assets, convert to ImmichAssetInfo for unified filtering raw_assets: list[ImmichAssetInfo] = [] year_map: dict[str, int | None] = {} # asset_id → memory year for mem in memories: mem_year = mem.get("data", {}).get("year") for raw in mem.get("assets", []): asset_id = raw.get("id", "") if tracked_ids: asset_albums = raw.get("albums", []) if not any(a.get("id") in tracked_ids for a in asset_albums): continue asset = ImmichAssetInfo.from_api_response(raw) if not asset.is_processed: continue raw_assets.append(asset) year_map[asset_id] = mem_year # Apply standard filters (no memory_date — native API already filters by date) filtered = filter_assets( raw_assets, favorite_only=favorite_only, min_rating=min_rating, asset_type=asset_type, ) # Random sample if len(filtered) > limit: selected = random.sample(filtered, limit) else: random.shuffle(filtered) selected = filtered from notify_bridge_core.providers.immich.asset_utils import asset_to_media all_assets = [] for asset in selected: media = asset_to_media(asset, ext_domain) media.extra["year"] = year_map.get(asset.id) all_assets.append(media) return ServiceEvent( event_type=EventType.SCHEDULED_MESSAGE, provider_type=ServiceProviderType.IMMICH, provider_name=provider_name, collection_id=collection_ids[0] if collection_ids else "", collection_name=tracker_name, timestamp=datetime.now(timezone.utc), added_assets=all_assets, added_count=len(all_assets), extra={ "collections": [], "albums": [], }, )