feat: UX & notification improvements — icons, events, chat names, link validation, templates

- Show entity icons on all cards with fallback defaults (providers, trackers, targets, bots)
- Enrich EventLog with provider_name, tracker_name, assets_count; add DB migration
- Dashboard events: filtering (type, provider, search), sorting, pagination, dynamic page size
- Friendly chat names on telegram target cards (resolve from TelegramChat table)
- Test message button on bot chat items with locale-aware messages
- Album public link validation on tracker save with auto-create dialog
- Support albums without public links: conditional <a href> in templates
- Fetch shared links during poll, enrich events with public_url/protected_url
- Per-asset public_url in template context ({share_url}/photos/{asset_id})
- Common date/location detection: common_date + common_location context vars
- Dual date formats: date_format (datetime) + date_only_format (date only)
- Template clone button, HTML link rendering in template preview
- Fix Telegram asset download 401: pass x-api-key headers through client
- Fix provider external_url matching for API key scoping
- Fix event timestamp timezone (append Z suffix for UTC)
- Localize event filter controls, test messages (EN/RU)
- Template variable UI helpers updated with all new fields
- CLAUDE.md: template system sync rules documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 16:18:03 +03:00
parent 91e5cd58e9
commit 03c5c66eed
41 changed files with 1424 additions and 132 deletions
@@ -18,7 +18,9 @@ from .webhook.client import WebhookClient
_LOGGER = logging.getLogger(__name__)
DEFAULT_TEMPLATE = (
'{{ added_count }} new item(s) added to "{{ collection_name }}".'
'{{ 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 %}'
)
@@ -30,8 +32,11 @@ class TargetConfig:
type: str # "telegram" or "webhook"
config: dict[str, Any] # type-specific config
template_slots: dict[str, str] | None = None # event_type -> template string
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
provider_api_key: str | None = None # API key for downloading assets from provider
provider_internal_url: str | None = None # Internal provider URL for API key scoping
provider_external_url: str | None = None # External domain for API key scoping
class NotificationDispatcher:
@@ -68,7 +73,11 @@ class NotificationDispatcher:
template_str = slot
# Build context and render
ctx = build_template_context(event, target_type=target.type)
ctx = build_template_context(
event, target_type=target.type,
date_format=target.date_format,
date_only_format=target.date_only_format,
)
message = render_template(template_str, ctx)
if target.type == "telegram":
@@ -90,16 +99,19 @@ class NotificationDispatcher:
client = TelegramClient(session, bot_token)
# Build asset list for media sending
# Only attach API key header for URLs pointing to the internal provider
internal_url = target.provider_internal_url or ""
# Attach API key header for URLs pointing to the provider (internal or external)
provider_urls = []
if target.provider_internal_url:
provider_urls.append(target.provider_internal_url)
if target.provider_external_url:
provider_urls.append(target.provider_external_url)
assets = []
for asset in event.added_assets:
url = asset.full_url or asset.thumbnail_url
if url:
asset_type = "video" if asset.type.value == "video" else "photo"
# Include API key only for internal provider URLs
asset_headers = {}
if target.provider_api_key and internal_url and url.startswith(internal_url):
if target.provider_api_key and any(url.startswith(u) for u in provider_urls):
asset_headers["x-api-key"] = target.provider_api_key
assets.append({"url": url, "type": asset_type, "headers": asset_headers})
@@ -103,12 +103,14 @@ class TelegramClient:
chat_id, assets[0].get("url"), caption, reply_to_message_id,
parse_mode, max_asset_data_size, send_large_photos_as_documents,
assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"),
)
if len(assets) == 1 and assets[0].get("type") == "video":
return await self._send_video(
chat_id, assets[0].get("url"), caption, reply_to_message_id,
parse_mode, max_asset_data_size,
assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"),
)
if len(assets) == 1 and assets[0].get("type", "document") == "document":
url = assets[0].get("url")
@@ -116,7 +118,8 @@ class TelegramClient:
return {"success": False, "error": "Missing 'url' for document"}
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url) as resp:
dl_headers = assets[0].get("headers") or {}
async with self._session.get(download_url, headers=dl_headers) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
data = await resp.read()
@@ -196,6 +199,7 @@ class TelegramClient:
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False,
content_type: str | None = None, cache_key: str | None = None,
download_headers: dict[str, str] | None = None,
) -> NotificationResult:
if not content_type:
content_type = "image/jpeg"
@@ -223,7 +227,7 @@ class TelegramClient:
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url) as resp:
async with self._session.get(download_url, headers=download_headers or {}) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
data = await resp.read()
@@ -264,7 +268,7 @@ class TelegramClient:
self, chat_id: str, url: str | None, caption: str | None = None,
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
max_asset_data_size: int | None = None, content_type: str | None = None,
cache_key: str | None = None,
cache_key: str | None = None, download_headers: dict[str, str] | None = None,
) -> NotificationResult:
if not content_type:
content_type = "video/mp4"
@@ -291,7 +295,7 @@ class TelegramClient:
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url) as resp:
async with self._session.get(download_url, headers=download_headers or {}) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
data = await resp.read()
@@ -396,9 +400,9 @@ class TelegramClient:
chunk_caption = caption if chunk_idx == 0 else None
chunk_reply = reply_to_message_id if chunk_idx == 0 else None
if item.get("type") == "photo":
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"))
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"))
elif item.get("type") == "video":
result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"))
result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"))
else:
continue
if not result.get("success"):
@@ -435,7 +439,8 @@ class TelegramClient:
else:
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url) as resp:
dl_headers = item.get("headers") or {}
async with self._session.get(download_url, headers=dl_headers) as resp:
if resp.status != 200:
continue
data = await resp.read()
@@ -184,6 +184,25 @@ class ImmichServiceProvider(ServiceProvider):
)
if event:
# Fetch shared links to enrich event with public_url
shared_links = await self._client.get_shared_links(album_id)
public_link = None
protected_link = None
for link in shared_links:
if link.is_accessible and not link.is_expired:
if link.has_password:
protected_link = link
else:
public_link = link
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
events.append(event)
# Update state
@@ -226,6 +245,7 @@ class ImmichServiceProvider(ServiceProvider):
"id": a.get("id", ""),
"name": a.get("albumName", "Unnamed"),
"asset_count": a.get("assetCount", 0),
"updated_at": a.get("updatedAt", ""),
}
for a in albums
]
@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from notify_bridge_core.models.events import ServiceEvent
@@ -10,6 +11,8 @@ from notify_bridge_core.models.events import ServiceEvent
def build_template_context(
event: ServiceEvent,
target_type: str = "webhook",
date_format: str = "%d.%m.%Y, %H:%M UTC",
date_only_format: str = "%d.%m.%Y",
) -> dict[str, Any]:
"""Build a flat template context dict from a ServiceEvent.
@@ -56,6 +59,15 @@ def build_template_context(
asset_dict.update(asset.extra)
assets.append(asset_dict)
# Enrich assets with per-asset public URLs if album has a public share link
album_public_url = event.extra.get("public_url", "")
if album_public_url:
for asset_dict in assets:
asset_dict["public_url"] = f"{album_public_url}/photos/{asset_dict['id']}"
else:
for asset_dict in assets:
asset_dict.setdefault("public_url", "")
ctx["assets"] = assets
ctx["added_assets"] = assets # alias for backward compat
@@ -63,9 +75,48 @@ def build_template_context(
ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in assets)
ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in assets)
# Date format strings (available to templates for custom formatting)
ctx["date_format"] = date_format
ctx["date_only_format"] = date_only_format
# Common date/location — set when ALL assets share the same value
ctx["common_date"] = ""
ctx["common_location"] = ""
if len(assets) > 1:
# Date: compare date portion only (YYYY-MM-DD)
dates = set()
for a in assets:
ca = a.get("created_at", "")
if ca:
dates.add(ca[:10]) # "2026-03-19T..." -> "2026-03-19"
if len(dates) == 1:
raw_date = dates.pop()
try:
ctx["common_date"] = datetime.fromisoformat(raw_date).strftime(date_only_format)
except (ValueError, TypeError):
ctx["common_date"] = raw_date
# Location: "City, Country" or just "City"
locations = set()
for a in assets:
city = a.get("city", "")
country = a.get("country", "")
if city:
loc = f"{city}, {country}" if country else city
locations.add(loc)
else:
locations.add("") # asset with no location breaks commonality
if len(locations) == 1 and "" not in locations:
ctx["common_location"] = locations.pop()
# Provider-specific extras merged at top level
ctx.update(event.extra)
# Ensure URL variables always exist (avoid Jinja2 undefined errors)
ctx.setdefault("public_url", "")
ctx.setdefault("protected_url", "")
ctx.setdefault("album_url", "")
# Provider-specific aliases for Immich
if event.provider_type.value == "immich":
ctx.setdefault("album_name", event.collection_name)
@@ -1,15 +1,17 @@
📷 {{ added_count }} new photo(s) added to album "{{ album_name }}".
📷 {{ added_count }} new photo(s) added to album {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
{%- if common_date %} 📅 {{ common_date }}{% endif %}
{%- if common_location %} 📍 {{ common_location }}{% endif %}
{%- if people %}
👤 {{ people | join(", ") }}
{%- endif %}
{%- if added_assets %}
{%- for asset in added_assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
{%- endif %}
{%- if target_type == "telegram" and has_videos %}
⚠️ Videos may not be sent due to Telegram's 50 MB file size limit.
{%- endif %}
{%- endif %}
@@ -1 +1 @@
🗑️ {{ removed_count }} photo(s) removed from album "{{ album_name }}".
🗑️ {{ removed_count }} photo(s) removed from album {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
@@ -1 +1 @@
✏️ Album "{{ old_name }}" renamed to "{{ new_name }}".
✏️ Album "{{ old_name }}" renamed to {% if public_url %}<a href="{{ public_url }}">{{ new_name }}</a>{% else %}"{{ new_name }}"{% endif %}.
@@ -1 +1 @@
🔗 Sharing changed for album "{{ album_name }}".
🔗 Sharing changed for album {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
@@ -1,15 +1,17 @@
📷 {{ added_count }} новых фото добавлено в альбом "{{ album_name }}".
📷 {{ added_count }} новых фото добавлено в альбом {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
{%- if common_date %} 📅 {{ common_date }}{% endif %}
{%- if common_location %} 📍 {{ common_location }}{% endif %}
{%- if people %}
👤 {{ people | join(", ") }}
{%- endif %}
{%- if added_assets %}
{%- for asset in added_assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
{%- endif %}
{%- if target_type == "telegram" and has_videos %}
⚠️ Видео может не отправиться из-за ограничения Telegram в 50 МБ.
{%- endif %}
{%- endif %}
@@ -1 +1 @@
🗑️ {{ removed_count }} фото удалено из альбома "{{ album_name }}".
🗑️ {{ removed_count }} фото удалено из альбома {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
@@ -1 +1 @@
✏️ Альбом "{{ old_name }}" переименован в "{{ new_name }}".
✏️ Альбом "{{ old_name }}" переименован в {% if public_url %}<a href="{{ public_url }}">{{ new_name }}</a>{% else %}"{{ new_name }}"{% endif %}.
@@ -1 +1 @@
🔗 Изменён доступ к альбому "{{ album_name }}".
🔗 Изменён доступ к альбому {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.