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
@@ -12,17 +12,13 @@ from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.templates.context import build_template_context
from notify_bridge_core.templates.renderer import render_template
from .telegram.cache import TelegramFileCache
from .telegram.client import TelegramClient
from .webhook.client import WebhookClient
_LOGGER = logging.getLogger(__name__)
DEFAULT_TEMPLATE = (
'{{ added_count }} new item(s) added to '
'{% if public_url %}<a href="{{ public_url }}">{{ collection_name }}</a>'
'{% else %}"{{ collection_name }}"{% endif %}.'
'{% if people %}\nPeople: {{ people | join(", ") }}{% endif %}'
)
DEFAULT_TEMPLATE = '{{ event_type }}: "{{ collection_name }}"'
@dataclass
@@ -42,6 +38,15 @@ class TargetConfig:
class NotificationDispatcher:
"""Dispatches ServiceEvent notifications to configured targets."""
def __init__(
self,
*,
url_cache: TelegramFileCache | None = None,
asset_cache: TelegramFileCache | None = None,
) -> None:
self._url_cache = url_cache
self._asset_cache = asset_cache
async def dispatch(
self,
event: ServiceEvent,
@@ -104,13 +109,17 @@ class NotificationDispatcher:
return {"success": False, "error": "Missing bot_token or chat_id"}
async with aiohttp.ClientSession() as session:
client = TelegramClient(session, bot_token)
client = TelegramClient(
session, bot_token,
url_cache=self._url_cache,
asset_cache=self._asset_cache,
)
# Step 1: Send the text message first
text_result = await client.send_message(
chat_id=str(chat_id),
text=message,
disable_web_page_preview=disable_preview or None,
disable_web_page_preview=bool(disable_preview),
)
if not text_result.get("success"):
return text_result
@@ -16,8 +16,10 @@ from .media import (
TELEGRAM_API_BASE_URL,
TELEGRAM_MAX_PHOTO_SIZE,
TELEGRAM_MAX_VIDEO_SIZE,
asset_id_from_cache_key,
check_photo_limits,
extract_asset_id_from_url,
is_asset_cache_key,
is_asset_id,
split_media_by_upload_size,
)
@@ -61,16 +63,17 @@ class TelegramClient:
if is_asset_id(url):
thumbhash = self._thumbhash_resolver(url) if self._thumbhash_resolver else None
return self._asset_cache, url, thumbhash
asset_id = extract_asset_id_from_url(url)
if asset_id:
thumbhash = self._thumbhash_resolver(asset_id) if self._thumbhash_resolver else None
return self._asset_cache, asset_id, thumbhash
asset_cache_key = extract_asset_id_from_url(url)
if asset_cache_key:
bare_id = asset_id_from_cache_key(asset_cache_key)
thumbhash = self._thumbhash_resolver(bare_id) if self._thumbhash_resolver else None
return self._asset_cache, asset_cache_key, thumbhash
return self._url_cache, url, None
return None, None, None
def _get_cache_for_key(self, key: str, is_asset: bool | None = None) -> TelegramFileCache | None:
if is_asset is None:
is_asset = is_asset_id(key)
is_asset = is_asset_cache_key(key)
return self._asset_cache if is_asset else self._url_cache
async def send_notification(
@@ -163,8 +166,8 @@ class TelegramClient:
}
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
if disable_web_page_preview is not None:
payload["disable_web_page_preview"] = disable_web_page_preview
if disable_web_page_preview:
payload["link_preview_options"] = {"is_disabled": True}
try:
async with self._session.post(telegram_url, json=payload) as response:
@@ -429,9 +432,10 @@ class TelegramClient:
# Check cache
ck = custom_cache_key or extract_asset_id_from_url(url) or url
ck_is_asset = is_asset_id(ck)
ck_is_asset = is_asset_cache_key(ck)
item_cache = self._get_cache_for_key(ck, ck_is_asset)
item_thumbhash = self._thumbhash_resolver(ck) if ck_is_asset and self._thumbhash_resolver else None
bare_ck = asset_id_from_cache_key(ck) if ck_is_asset else ck
item_thumbhash = self._thumbhash_resolver(bare_ck) if ck_is_asset and self._thumbhash_resolver else None
cached = item_cache.get(ck, thumbhash=item_thumbhash) if item_cache else None
if cached and cached.get("file_id"):
@@ -4,6 +4,7 @@ from __future__ import annotations
import re
from typing import Final
from urllib.parse import urlparse
# Telegram constants
TELEGRAM_API_BASE_URL: Final = "https://api.telegram.org/bot"
@@ -13,6 +14,8 @@ TELEGRAM_MAX_DIMENSION_SUM: Final = 10000
# Generic UUID pattern for asset IDs
_ASSET_ID_PATTERN = re.compile(r"^[a-f0-9-]{36}$")
# Cache key: "host:uuid" or bare "uuid"
_ASSET_CACHE_KEY_PATTERN = re.compile(r"^(?:[^:]+:)?[a-f0-9-]{36}$")
# URL patterns to extract asset IDs (generic enough for Immich-style URLs)
_ASSET_ID_URL_PATTERNS = [
@@ -26,14 +29,26 @@ def is_asset_id(value: str) -> bool:
return bool(_ASSET_ID_PATTERN.match(value))
def is_asset_cache_key(value: str) -> bool:
"""Check if a string is an asset cache key (bare UUID or host:UUID)."""
return bool(_ASSET_CACHE_KEY_PATTERN.match(value))
def asset_id_from_cache_key(key: str) -> str:
"""Extract bare asset ID from a cache key (strips host: prefix if present)."""
idx = key.find(":")
return key[idx + 1:] if idx != -1 else key
def extract_asset_id_from_url(url: str) -> str | None:
"""Extract asset ID from a URL if possible."""
"""Extract host-qualified asset cache key (host:uuid) from a URL."""
if not url:
return None
for pattern in _ASSET_ID_URL_PATTERNS:
match = pattern.search(url)
if match:
return match.group(1)
host = urlparse(url).hostname or ""
return f"{host}:{match.group(1)}" if host else match.group(1)
return None
@@ -47,14 +47,27 @@ def _asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
)
def _make_base_extra(new_album: ImmichAlbumData, external_url: str) -> dict:
"""Build the common extra dict for album events."""
return {
"album_url": f"{external_url}/albums/{new_album.id}",
"people": list(new_album.people),
"shared": new_album.shared,
"photo_count": new_album.photo_count,
"video_count": new_album.video_count,
"asset_count": new_album.asset_count,
"owner": new_album.owner,
}
def detect_album_changes(
old_album: ImmichAlbumData,
new_album: ImmichAlbumData,
pending_asset_ids: set[str],
provider_name: str,
external_url: str,
) -> tuple[ServiceEvent | None, set[str]]:
"""Detect changes between two album states, producing a generic ServiceEvent.
) -> tuple[list[ServiceEvent], set[str]]:
"""Detect changes between two album states, producing generic ServiceEvents.
Args:
old_album: Previous album data
@@ -64,7 +77,7 @@ def detect_album_changes(
external_url: External URL for building asset URLs
Returns:
Tuple of (ServiceEvent or None, updated pending_asset_ids)
Tuple of (list of ServiceEvents, updated pending_asset_ids)
"""
added_ids = new_album.asset_ids - old_album.asset_ids
removed_ids = old_album.asset_ids - new_album.asset_ids
@@ -97,47 +110,76 @@ def detect_album_changes(
sharing_changed = old_album.shared != new_album.shared
if not added_assets and not removed_ids and not name_changed and not sharing_changed:
return None, pending
return [], pending
# Determine event type
if name_changed and not added_assets and not removed_ids and not sharing_changed:
event_type = EventType.COLLECTION_RENAMED
elif sharing_changed and not added_assets and not removed_ids and not name_changed:
event_type = EventType.SHARING_CHANGED
elif added_assets and not removed_ids and not name_changed and not sharing_changed:
event_type = EventType.ASSETS_ADDED
elif removed_ids and not added_assets and not name_changed and not sharing_changed:
event_type = EventType.ASSETS_REMOVED
else:
event_type = EventType.ASSETS_ADDED # default for mixed changes
now = datetime.now(timezone.utc)
extra = _make_base_extra(new_album, external_url)
events: list[ServiceEvent] = []
# Convert to generic MediaAssets
media_assets = [_asset_to_media(a, external_url) for a in added_assets]
# Emit one event per change type detected
if added_assets:
media_assets = [_asset_to_media(a, external_url) for a in added_assets]
events.append(ServiceEvent(
event_type=EventType.ASSETS_ADDED,
provider_type=ServiceProviderType.IMMICH,
provider_name=provider_name,
collection_id=new_album.id,
collection_name=new_album.name,
timestamp=now,
added_assets=media_assets,
removed_asset_ids=[],
added_count=len(added_assets),
removed_count=0,
extra=dict(extra),
))
event = ServiceEvent(
event_type=event_type,
provider_type=ServiceProviderType.IMMICH,
provider_name=provider_name,
collection_id=new_album.id,
collection_name=new_album.name,
timestamp=datetime.now(timezone.utc),
added_assets=media_assets,
removed_asset_ids=list(removed_ids),
added_count=len(added_assets),
removed_count=len(removed_ids),
old_name=old_album.name if name_changed else None,
new_name=new_album.name if name_changed else None,
old_shared=old_album.shared if sharing_changed else None,
new_shared=new_album.shared if sharing_changed else None,
extra={
"album_url": f"{external_url}/albums/{new_album.id}",
"people": list(new_album.people),
"shared": new_album.shared,
"photo_count": new_album.photo_count,
"video_count": new_album.video_count,
"asset_count": new_album.asset_count,
"owner": new_album.owner,
},
)
if removed_ids:
events.append(ServiceEvent(
event_type=EventType.ASSETS_REMOVED,
provider_type=ServiceProviderType.IMMICH,
provider_name=provider_name,
collection_id=new_album.id,
collection_name=new_album.name,
timestamp=now,
added_assets=[],
removed_asset_ids=list(removed_ids),
added_count=0,
removed_count=len(removed_ids),
extra=dict(extra),
))
return event, pending
if name_changed:
events.append(ServiceEvent(
event_type=EventType.COLLECTION_RENAMED,
provider_type=ServiceProviderType.IMMICH,
provider_name=provider_name,
collection_id=new_album.id,
collection_name=new_album.name,
timestamp=now,
added_assets=[],
removed_asset_ids=[],
added_count=0,
removed_count=0,
old_name=old_album.name,
new_name=new_album.name,
extra=dict(extra),
))
if sharing_changed:
events.append(ServiceEvent(
event_type=EventType.SHARING_CHANGED,
provider_type=ServiceProviderType.IMMICH,
provider_name=provider_name,
collection_id=new_album.id,
collection_name=new_album.name,
timestamp=now,
added_assets=[],
removed_asset_ids=[],
added_count=0,
removed_count=0,
old_shared=old_album.shared,
new_shared=new_album.shared,
extra=dict(extra),
))
return events, pending
@@ -30,6 +30,14 @@ class ImmichClient:
def url(self) -> str:
return self._url
@property
def external_domain(self) -> str | None:
return self._external_domain
@external_domain.setter
def external_domain(self, value: str | None) -> None:
self._external_domain = value
@property
def external_url(self) -> str:
if self._external_domain:
@@ -252,6 +260,79 @@ class ImmichClient:
pass
return []
async def search_metadata(
self,
query: str,
album_ids: list[str] | None = None,
limit: int = 10,
) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"originalFileName": query, "page": 1, "size": limit}
try:
async with self._session.post(
f"{self._url}/api/search/metadata",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
if album_ids:
tracked = set(album_ids)
items = [
a for a in items
if any(alb.get("id") in tracked for alb in a.get("albums", []))
]
return items[:limit]
except aiohttp.ClientError:
pass
return []
async def search_by_person(
self, person_id: str, limit: int = 10
) -> list[dict[str, Any]]:
try:
async with self._session.get(
f"{self._url}/api/people/{person_id}/assets",
headers=self._headers,
) as response:
if response.status == 200:
data = await response.json()
return data[:limit] if isinstance(data, list) else []
except aiohttp.ClientError:
pass
return []
async def get_memories(
self,
date: str | None = None,
) -> list[dict[str, Any]]:
"""Fetch native Immich memories (On This Day).
Args:
date: ISO date string (e.g. "2026-03-20") to fetch memories for.
If None, Immich returns memories for today.
Returns a list of memory objects, each containing an ``assets`` list
with full ``AssetResponseDto`` items.
"""
params: dict[str, str] = {}
if date:
params["for"] = date
try:
async with self._session.get(
f"{self._url}/api/memories",
headers=self._headers,
params=params,
) as response:
if response.status == 200:
return await response.json()
_LOGGER.warning(
"Failed to fetch memories: HTTP %s", response.status
)
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch memories: %s", err)
return []
async def get_asset_thumbnail(self, asset_id: str, size: str = "preview") -> bytes | None:
try:
async with self._session.get(
@@ -134,7 +134,7 @@ class ImmichServiceProvider(ServiceProvider):
if ok:
await self._client.get_server_config()
if self._external_domain:
self._client._external_domain = self._external_domain
self._client.external_domain = self._external_domain
self._users_cache = await self._client.get_users()
return ok
@@ -179,12 +179,12 @@ class ImmichServiceProvider(ServiceProvider):
old_album = _deserialize_album_state(album_id, prev)
pending = set(prev.get("pending_asset_ids", []))
event, updated_pending = detect_album_changes(
detected_events, updated_pending = detect_album_changes(
old_album, album, pending, self._name, external_url
)
if event:
# Fetch shared links to enrich event with public_url
if detected_events:
# Fetch shared links to enrich events with public_url
shared_links = await self._client.get_shared_links(album_id)
public_link = None
protected_link = None
@@ -197,13 +197,13 @@ class ImmichServiceProvider(ServiceProvider):
break # prefer non-password link
ext_domain = self._external_domain or self._client.external_url
if public_link:
event.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
elif protected_link:
event.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
# If no links, public_url stays absent — templates handle gracefully
for evt in detected_events:
if public_link:
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
elif protected_link:
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
events.append(event)
events.extend(detected_events)
# Update state
state = _serialize_album_state(album)
@@ -4,6 +4,9 @@
{%- if people %}
👤 {{ people | join(", ") }}
{%- endif %}
{%- if public_url %}
🔗 <a href="{{ public_url }}">Album URL</a>
{%- endif %}
{%- if added_assets %}
{%- for asset in added_assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
@@ -1,4 +1,4 @@
📅 On this day:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})
{%- endfor %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
{%- endfor %}
@@ -1,5 +1,4 @@
📋 Tracked Albums Summary ({{ albums | length }} albums):
{%- for album in albums %}
• {{ album.name }}: {{ album.asset_count }} assets
{%- if album.url %} — {{ album.url }}{% endif %}
{%- endfor %}
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
{%- endfor %}
@@ -1,4 +1,4 @@
📸 Photos from "{{ album_name }}":
📸 Photos from {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
{%- endfor %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- endfor %}
@@ -4,6 +4,9 @@
{%- if people %}
👤 {{ people | join(", ") }}
{%- endif %}
{%- if public_url %}
🔗 <a href="{{ public_url }}">Ссылка на альбом</a>
{%- endif %}
{%- if added_assets %}
{%- for asset in added_assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
@@ -1,4 +1,4 @@
📅 В этот день:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})
{%- endfor %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
{%- endfor %}
@@ -1,5 +1,4 @@
📋 Сводка альбомов ({{ albums | length }}):
{%- for album in albums %}
• {{ album.name }}: {{ album.asset_count }} файлов
{%- if album.url %} — {{ album.url }}{% endif %}
{%- endfor %}
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
{%- endfor %}
@@ -1,4 +1,4 @@
📸 Фото из "{{ album_name }}":
📸 Фото из {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
{%- endfor %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- endfor %}