feat: telegram commands, app settings, bot polling, webhook handling, UI improvements

Adds telegram bot command system with 13 commands (search, latest, random, etc.),
webhook/polling handlers, rate limiting, app settings page, and various UI/UX
improvements across all entity pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 23:11:42 +03:00
parent 5015e378fe
commit 03ec9b3c86
64 changed files with 2585 additions and 648 deletions
@@ -1 +1,17 @@
"""Business logic services — scheduler, watcher, notifier."""
"""Shared service utilities."""
from notify_bridge_core.providers.immich import ImmichServiceProvider
from ..database.models import ServiceProvider
def make_immich_provider(http_session, provider: ServiceProvider) -> ImmichServiceProvider:
"""Create an ImmichServiceProvider from a DB provider model."""
config = provider.config or {}
return ImmichServiceProvider(
http_session,
config.get("url", ""),
config.get("api_key", ""),
config.get("external_domain"),
provider.name,
)
@@ -1,6 +1,7 @@
"""Test notification sender."""
"""Notification sender — unified send logic for all paths (dispatch + test)."""
import logging
from typing import Any
import aiohttp
@@ -25,41 +26,69 @@ def _get_test_message(locale: str, target_type: str) -> str:
return msgs.get(target_type, msgs.get("webhook", "Test"))
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
"""Send a simple test message to a notification target."""
async def send_to_target(target: NotificationTarget, message: str) -> dict:
"""Send a message to a target, respecting all target config settings.
This is the SINGLE send path used by dispatch, test, and real-data notifications.
"""
try:
if target.type == "telegram":
return await _test_telegram(target, locale)
return await _send_telegram(target, message)
elif target.type == "webhook":
return await _test_webhook(target, locale)
return await _send_webhook(target, message)
return {"success": False, "error": f"Unknown target type: {target.type}"}
except Exception as e:
_LOGGER.error("Test notification failed: %s", e)
_LOGGER.error("Send failed: %s", e)
return {"success": False, "error": str(e)}
async def _test_telegram(target: NotificationTarget, locale: str = "en") -> dict:
async def _send_telegram(target: NotificationTarget, message: str) -> dict:
from notify_bridge_core.notifications.telegram.client import TelegramClient
bot_token = target.config.get("bot_token")
chat_id = target.config.get("chat_id")
disable_preview = target.config.get("disable_url_preview", False)
if not bot_token or not chat_id:
return {"success": False, "error": "Missing bot_token or chat_id"}
async with aiohttp.ClientSession() as session:
client = TelegramClient(session, bot_token)
return await client.send_notification(
return await client.send_message(
chat_id=str(chat_id),
caption=_get_test_message(locale, "telegram"),
text=message,
disable_web_page_preview=bool(disable_preview),
)
async def _send_webhook(target: NotificationTarget, message: str, event_type: str = "notification") -> dict:
from notify_bridge_core.notifications.webhook.client import WebhookClient
url = target.config.get("url")
headers = target.config.get("headers", {})
if not url:
return {"success": False, "error": "Missing url in target config"}
async with aiohttp.ClientSession() as session:
client = WebhookClient(session, url, headers)
return await client.send({"message": message, "event_type": event_type})
# --- Public API used by routes ---
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
"""Send a simple test message."""
message = _get_test_message(locale, target.type)
return await send_to_target(target, message)
async def send_test_template_notification(
target: NotificationTarget, slot: str, template_str: str
) -> dict:
"""Render a template slot with sample data and send it to a target."""
"""Render a template slot with sample data and send."""
from jinja2.sandbox import SandboxedEnvironment
from ..api.template_configs import _SAMPLE_CONTEXT
from .sample_context import _SAMPLE_CONTEXT
if not template_str:
return await send_test_notification(target)
@@ -71,53 +100,7 @@ async def send_test_template_notification(
except Exception as e:
return {"success": False, "error": f"Template render error: {e}"}
try:
if target.type == "telegram":
return await _test_telegram_with_message(target, message)
elif target.type == "webhook":
return await _test_webhook_with_message(target, message)
return {"success": False, "error": f"Unknown target type: {target.type}"}
except Exception as e:
_LOGGER.error("Test template notification failed: %s", e)
return {"success": False, "error": str(e)}
async def _test_telegram_with_message(target: NotificationTarget, message: str) -> dict:
from notify_bridge_core.notifications.telegram.client import TelegramClient
bot_token = target.config.get("bot_token")
chat_id = target.config.get("chat_id")
if not bot_token or not chat_id:
return {"success": False, "error": "Missing bot_token or chat_id"}
async with aiohttp.ClientSession() as session:
client = TelegramClient(session, bot_token)
return await client.send_notification(chat_id=str(chat_id), caption=message)
async def _test_webhook_with_message(target: NotificationTarget, message: str) -> dict:
from notify_bridge_core.notifications.webhook.client import WebhookClient
url = target.config.get("url")
headers = target.config.get("headers", {})
if not url:
return {"success": False, "error": "Missing url in target config"}
async with aiohttp.ClientSession() as session:
client = WebhookClient(session, url, headers)
return await client.send({"message": message, "event_type": "test_template"})
async def _test_webhook(target: NotificationTarget, locale: str = "en") -> dict:
from notify_bridge_core.notifications.webhook.client import WebhookClient
url = target.config.get("url")
headers = target.config.get("headers", {})
if not url:
return {"success": False, "error": "Missing url in target config"}
async with aiohttp.ClientSession() as session:
client = WebhookClient(session, url, headers)
return await client.send({
"message": _get_test_message(locale, "webhook"),
"event_type": "test",
})
return await send_to_target(target, message)
async def send_real_data_notification(
@@ -129,20 +112,19 @@ async def send_real_data_notification(
collection_ids: list[str],
date_format: str = "%d.%m.%Y, %H:%M UTC",
date_only_format: str = "%d.%m.%Y",
memory_source: str = "albums",
) -> dict:
"""Fetch real data from provider, render template, and send notification."""
from datetime import datetime, timezone
"""Fetch real data from provider, render template, and send."""
from jinja2.sandbox import SandboxedEnvironment
if not template_str:
return {"success": False, "error": f"No template configured for {test_type}"}
# Fetch real data from provider
ctx: dict = {}
try:
ctx = await _build_real_context(
provider_type, provider_config, collection_ids,
test_type, date_format, date_only_format,
memory_source=memory_source,
)
except Exception as e:
_LOGGER.error("Failed to fetch real data for test: %s", e)
@@ -152,7 +134,6 @@ async def send_real_data_notification(
ctx["date_format"] = date_format
ctx["date_only_format"] = date_only_format
# Render template
try:
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_str)
@@ -160,16 +141,7 @@ async def send_real_data_notification(
except Exception as e:
return {"success": False, "error": f"Template render error: {e}"}
# Send
try:
if target.type == "telegram":
return await _test_telegram_with_message(target, message)
elif target.type == "webhook":
return await _test_webhook_with_message(target, message)
return {"success": False, "error": f"Unknown target type: {target.type}"}
except Exception as e:
_LOGGER.error("Test notification failed: %s", e)
return {"success": False, "error": str(e)}
return await send_to_target(target, message)
async def _build_real_context(
@@ -179,6 +151,7 @@ async def _build_real_context(
test_type: str,
date_format: str,
date_only_format: str,
memory_source: str = "albums",
) -> dict:
"""Build template context from real provider data."""
from datetime import datetime, timezone
@@ -200,16 +173,77 @@ async def _build_real_context(
if not connected:
raise RuntimeError("Failed to connect to Immich")
# Fetch album data for all tracked collections
collections = []
all_assets = []
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
# --- Native Immich memories ---
if test_type == "memory" and memory_source == "native":
memories = await immich.client.get_memories()
all_assets: list[dict[str, Any]] = []
tracked_ids = set(collection_ids) if collection_ids else None
for mem in memories:
for raw_asset in mem.get("assets", []):
asset_id = raw_asset.get("id", "")
# Optional album filtering: keep only assets in tracked albums
if tracked_ids:
asset_albums = raw_asset.get("albums", [])
if not any(a.get("id") in tracked_ids for a in asset_albums):
continue
exif = raw_asset.get("exifInfo") or {}
people_raw = raw_asset.get("people", [])
all_assets.append({
"id": asset_id,
"filename": raw_asset.get("originalFileName", ""),
"type": (raw_asset.get("type") or "IMAGE").upper(),
"created_at": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")),
"owner": "",
"description": exif.get("description", "") or raw_asset.get("description", "") or "",
"people": [p.get("name", "") for p in people_raw if p.get("name")],
"is_favorite": raw_asset.get("isFavorite", False),
"rating": exif.get("rating"),
"city": exif.get("city", "") or "",
"state": exif.get("state", "") or "",
"country": exif.get("country", "") or "",
"public_url": "",
"url": f"{ext_domain.rstrip('/')}/api/assets/{asset_id}/original",
"photo_url": f"{ext_domain.rstrip('/')}/api/assets/{asset_id}/thumbnail",
"year": mem.get("data", {}).get("year"),
})
now = datetime.now(timezone.utc)
ctx: dict[str, Any] = {
"date": now.strftime(date_only_format),
"timestamp": now.isoformat(),
"service_name": "Immich",
"service_type": "immich",
"collections": [],
"albums": [],
"assets": all_assets,
"common_date": "",
"common_location": "",
"collection_name": "", "album_name": "",
"public_url": "", "album_url": "",
"shared": False, "photo_count": 0, "video_count": 0, "owner": "",
}
people: set[str] = set()
for a in all_assets:
people.update(a.get("people", []))
ctx["people"] = list(people)
ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in all_assets)
ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in all_assets)
ctx["added_count"] = len(all_assets)
ctx["added_assets"] = all_assets
ctx["protected_url"] = ""
return ctx
# --- Album-based asset collection (default path) ---
collections: list[dict[str, Any]] = []
all_assets: list[dict[str, Any]] = []
for album_id in collection_ids:
album = await immich.client.get_album(album_id)
if not album:
continue
# Get shared link for public URL
shared_links = await immich.client.get_shared_links(album_id)
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
album_public_url = ""
@@ -229,7 +263,6 @@ async def _build_real_context(
"owner": album.owner,
})
# Collect assets (limited sample)
for asset_id, asset in list(album.assets.items())[:10]:
asset_public_url = f"{album_public_url}/photos/{asset_id}" if album_public_url else ""
all_assets.append({
@@ -250,60 +283,42 @@ async def _build_real_context(
"photo_url": f"{ext_domain.rstrip('/')}/api/assets/{asset.id}/thumbnail",
})
# Build context based on test type
now = datetime.now(timezone.utc)
ctx: dict = {
ctx: dict[str, Any] = {
"date": now.strftime(date_only_format),
"timestamp": now.isoformat(),
"service_name": "Immich",
"service_type": "immich",
"collections": collections,
"albums": collections, # alias
"albums": collections,
"assets": all_assets,
"common_date": "",
"common_location": "",
}
# Common date/location for assets
if len(all_assets) > 1:
dates = set()
for a in all_assets:
ca = a.get("created_at", "")
if ca:
dates.add(ca[:10])
dates = {a.get("created_at", "")[:10] for a in all_assets if a.get("created_at")}
if len(dates) == 1:
try:
ctx["common_date"] = datetime.fromisoformat(dates.pop()).strftime(date_only_format)
except (ValueError, TypeError):
ctx["common_date"] = ""
else:
ctx["common_date"] = ""
pass
locations = set()
for a in all_assets:
city = a.get("city", "")
country = a.get("country", "")
if city:
locations.add(f"{city}, {country}" if country else city)
else:
locations.add("")
locations.add(f"{city}, {country}" if city and country else city or "")
if len(locations) == 1 and "" not in locations:
ctx["common_location"] = locations.pop()
else:
ctx["common_location"] = ""
else:
ctx["common_date"] = ""
ctx["common_location"] = ""
# Add first collection details as top-level for periodic-style templates
if collections:
first = collections[0]
ctx.update({
"collection_name": first["name"],
"album_name": first["name"],
"public_url": first.get("public_url", ""),
"album_url": first.get("url", ""),
"collection_name": first["name"], "album_name": first["name"],
"public_url": first.get("public_url", ""), "album_url": first.get("url", ""),
"shared": first.get("shared", False),
"photo_count": first.get("photo_count", 0),
"video_count": first.get("video_count", 0),
"photo_count": first.get("photo_count", 0), "video_count": first.get("video_count", 0),
"owner": first.get("owner", ""),
})
else:
@@ -313,8 +328,7 @@ async def _build_real_context(
"shared": False, "photo_count": 0, "video_count": 0, "owner": "",
})
# People across all assets
people = set()
people: set[str] = set()
for a in all_assets:
people.update(a.get("people", []))
ctx["people"] = list(people)
@@ -0,0 +1,94 @@
"""Sample template context for previews and test notifications."""
# Sample asset matching what build_asset_detail() actually returns
_SAMPLE_ASSET = {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"filename": "IMG_001.jpg",
"type": "IMAGE",
"created_at": "2026-03-19T10:30:00",
"owner": "Alice",
"owner_id": "user-uuid-1",
"description": "Family picnic",
"people": ["Alice", "Bob"],
"is_favorite": True,
"rating": 5,
"latitude": 48.8566,
"longitude": 2.3522,
"city": "Paris",
"state": "Ile-de-France",
"country": "France",
"url": "https://immich.example.com/photos/abc123",
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"download_url": "https://immich.example.com/api/assets/abc123/original",
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
}
_SAMPLE_VIDEO_ASSET = {
**_SAMPLE_ASSET,
"id": "d4e5f6a7-b8c9-0123-defg-456789abcdef",
"filename": "VID_002.mp4",
"type": "VIDEO",
"is_favorite": False,
"rating": None,
"photo_url": None,
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
"playback_url": "https://immich.example.com/api/assets/def456/video",
}
_SAMPLE_COLLECTION = {
"name": "Family Photos",
"url": "https://immich.example.com/share/abc123",
"public_url": "https://immich.example.com/share/abc123",
"asset_count": 42,
"shared": True,
}
# Full context covering ALL possible template variables
_SAMPLE_CONTEXT = {
# Core event fields (always present)
"collection_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
"collection_name": "Family Photos",
"collection_url": "https://immich.example.com/share/abc123",
"event_type": "assets_added",
"timestamp": "2026-03-19T10:30:00+00:00",
"service_name": "Immich",
"service_type": "immich",
# Immich aliases (always present alongside collection_*)
"album_name": "Family Photos",
"album_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
"old_album_name": "Old Album",
"new_album_name": "New Album",
"change_type": "assets_added",
"added_count": 3,
"removed_count": 1,
"added_assets": [_SAMPLE_ASSET, _SAMPLE_VIDEO_ASSET],
"removed_assets": ["asset-id-1", "asset-id-2"],
"people": ["Alice", "Bob"],
"shared": True,
"target_type": "telegram",
"has_videos": True,
"has_photos": True,
# Rename fields (always present, empty for non-rename events)
"old_name": "Old Album",
"new_name": "New Album",
"old_shared": False,
"new_shared": True,
# Public share URLs (may be empty if no shared link exists)
"public_url": "https://immich.example.com/share/abc123",
"protected_url": "",
"album_url": "https://immich.example.com/albums/b2eeeaa4",
# Common date/location (set when all assets share the same value)
"common_date": "19.03.2026",
"common_location": "Paris, France",
# Date format strings (from template config)
"date_format": "%d.%m.%Y, %H:%M UTC",
"date_only_format": "%d.%m.%Y",
# Scheduled/periodic variables (for those templates)
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
"albums": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/abc123/photos/x1y2z3"}],
"date": "2026-03-19",
"photo_count": 30,
"video_count": 5,
"owner": "Alice",
}
@@ -26,6 +26,10 @@ async def start_scheduler() -> None:
await _load_tracker_jobs()
# Start Telegram bot polling for bots in polling mode
from .telegram_poller import start_bot_polling
await start_bot_polling()
async def _load_tracker_jobs() -> None:
"""Load enabled trackers and schedule polling jobs."""
@@ -0,0 +1,43 @@
"""Telegram service utilities — chat persistence helpers."""
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.models import TelegramChat
async def save_chat_from_webhook(
session: AsyncSession, bot_id: int, chat_data: dict
) -> None:
"""Save or update a chat entry from an incoming webhook message.
Called by the webhook handler to auto-persist chats.
"""
chat_id = str(chat_data.get("id", ""))
if not chat_id:
return
result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)
existing = result.first()
title = chat_data.get("title") or (
chat_data.get("first_name", "") + (" " + chat_data.get("last_name", "")).strip()
)
if existing:
existing.title = title
existing.username = chat_data.get("username", existing.username)
session.add(existing)
else:
session.add(TelegramChat(
bot_id=bot_id,
chat_id=chat_id,
title=title,
chat_type=chat_data.get("type", "private"),
username=chat_data.get("username", ""),
))
@@ -0,0 +1,158 @@
"""Telegram long-polling service for bots in polling mode.
Uses APScheduler to run getUpdates periodically for each bot
with update_mode == "polling". Processes updates identically
to the webhook handler (auto-save chat, dispatch commands).
"""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
from ..database.engine import get_engine
from ..database.models import TelegramBot
from ..services.telegram import save_chat_from_webhook
from .scheduler import get_scheduler
_LOGGER = logging.getLogger(__name__)
# Track last update_id per bot to use as offset
_last_update_id: dict[int, int] = {}
async def start_bot_polling() -> None:
"""Schedule polling jobs for all bots with update_mode == 'polling'."""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(TelegramBot).where(TelegramBot.update_mode == "polling")
)
bots = result.all()
for bot in bots:
schedule_bot_polling(bot.id)
def schedule_bot_polling(bot_id: int) -> None:
"""Add a polling job for a bot (idempotent)."""
scheduler = get_scheduler()
job_id = f"telegram_poll_{bot_id}"
if scheduler.get_job(job_id):
return
scheduler.add_job(
_poll_bot,
"interval",
seconds=3,
id=job_id,
args=[bot_id],
replace_existing=True,
max_instances=1,
)
_LOGGER.info("Started polling for bot %d", bot_id)
def unschedule_bot_polling(bot_id: int) -> None:
"""Remove polling job for a bot."""
scheduler = get_scheduler()
job_id = f"telegram_poll_{bot_id}"
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
_LOGGER.info("Stopped polling for bot %d", bot_id)
async def _poll_bot(bot_id: int) -> None:
"""Fetch updates from Telegram and process them."""
engine = get_engine()
async with AsyncSession(engine) as session:
bot = await session.get(TelegramBot, bot_id)
if not bot or bot.update_mode != "polling":
unschedule_bot_polling(bot_id)
return
offset = _last_update_id.get(bot_id, 0)
params: dict[str, Any] = {
"timeout": 0,
"limit": 50,
"allowed_updates": '["message"]',
}
if offset:
params["offset"] = offset + 1
try:
async with aiohttp.ClientSession() as http:
async with http.get(
f"{TELEGRAM_API_BASE_URL}{bot.token}/getUpdates",
params=params,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
data = await resp.json()
if not data.get("ok"):
return
updates = data.get("result", [])
except Exception as e:
_LOGGER.debug("Polling error for bot %d: %s", bot_id, e)
return
if not updates:
return
# Update offset to latest
_last_update_id[bot_id] = updates[-1]["update_id"]
# Process each update
from ..commands.handler import handle_command, send_media_group
for update in updates:
message = update.get("message")
if not message:
continue
chat_info = message.get("chat", {})
chat_id = str(chat_info.get("id", ""))
text = message.get("text", "")
if not chat_id:
continue
# Auto-persist chat
try:
async with AsyncSession(engine) as save_session:
await save_chat_from_webhook(save_session, bot.id, chat_info)
await save_session.commit()
except Exception:
_LOGGER.debug("Failed to auto-save chat %s", chat_id, exc_info=True)
# Dispatch commands
if text and text.startswith("/"):
try:
cmd_response = await handle_command(bot, chat_id, text)
if cmd_response is not None:
if isinstance(cmd_response, list):
await send_media_group(bot.token, chat_id, cmd_response)
else:
await _send_reply(bot.token, chat_id, cmd_response)
except Exception:
_LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True)
async def _send_reply(bot_token: str, chat_id: str, text: str) -> None:
"""Send a text reply via Telegram Bot API."""
async with aiohttp.ClientSession() as http:
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMessage"
payload: dict[str, Any] = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
try:
async with http.post(url, json=payload) as resp:
if resp.status != 200:
result = await resp.json()
if "parse" in str(result.get("description", "")).lower():
payload.pop("parse_mode", None)
await http.post(url, json=payload)
except aiohttp.ClientError as err:
_LOGGER.error("Failed to send Telegram reply: %s", err)
@@ -12,7 +12,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig
from notify_bridge_core.providers.immich import ImmichServiceProvider
from notify_bridge_core.notifications.telegram.cache import TelegramFileCache
from notify_bridge_core.storage import JsonFileBackend
from ..database.engine import get_engine
from ..database.models import (
@@ -28,6 +29,29 @@ from ..database.models import (
_LOGGER = logging.getLogger(__name__)
# Module-level Telegram file caches — shared across dispatches for reuse
_url_cache: TelegramFileCache | None = None
_asset_cache: TelegramFileCache | None = None
async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFileCache | None]:
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR."""
global _url_cache, _asset_cache
if _url_cache is not None:
return _url_cache, _asset_cache
import os
from pathlib import Path
data_dir = os.environ.get("NOTIFY_BRIDGE_DATA_DIR")
if not data_dir:
return None, None
cache_dir = Path(data_dir) / "cache"
_url_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_url_cache.json"))
_asset_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_asset_cache.json"))
await _url_cache.async_load()
await _asset_cache.async_load()
_LOGGER.info("Initialized Telegram file caches in %s", cache_dir)
return _url_cache, _asset_cache
def _in_quiet_hours(start: str | None, end: str | None) -> bool:
"""Check if the current UTC time is within the quiet hours window."""
@@ -131,6 +155,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
new_state: dict[str, Any] = {}
if provider_type == "immich":
from notify_bridge_core.providers.immich import ImmichServiceProvider
async with aiohttp.ClientSession() as http_session:
immich = ImmichServiceProvider(
http_session,
@@ -208,7 +233,8 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
)
if events and link_data:
dispatcher = NotificationDispatcher()
url_cache, asset_cache = await _get_telegram_caches()
dispatcher = NotificationDispatcher(url_cache=url_cache, asset_cache=asset_cache)
for event in events:
_LOGGER.info(
"Dispatching event %s for %s (added=%d removed=%d)",
@@ -239,7 +265,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
config=ld["target_config"],
template_slots=slots,
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and hasattr(tmpl, "date_only_format") else "%d.%m.%Y",
date_only_format=tmpl.date_only_format if tmpl and tmpl.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", ""),