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:
@@ -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
@@ -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
@@ -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 %}.
|
||||
Reference in New Issue
Block a user