From d0bc767e989abb08f011e9d8d006358ee180bd3f Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 16:48:57 +0300 Subject: [PATCH] feat: rich command templates with public links + media text-first flow - Command templates now match notification template style: type icons, linked filenames via album shared links, location, favorite status - Media mode sends text message first, then media as reply (was media-only) - Search/find/person/place resolve asset public URLs from tracked albums' shared links (share/{key}/photos/{id}) - Albums/summary commands include album public_url in context - Enriched command template preview sample context with public_url, city, country, is_favorite - Extract sanitizePreview to shared lib/sanitize.ts - Command template preview now renders HTML links (was raw text) - Global provider filter moved above search in sidebar - CLAUDE.md: template consistency + context variable sync rules --- CLAUDE.md | 9 ++- frontend/src/lib/sanitize.ts | 40 +++++++++++++ .../command-template-configs/+page.svelte | 3 +- .../src/routes/template-configs/+page.svelte | 39 +----------- .../command_defaults/en/albums.jinja2 | 2 +- .../command_defaults/en/favorites.jinja2 | 3 +- .../command_defaults/en/latest.jinja2 | 4 +- .../command_defaults/en/memory.jinja2 | 3 +- .../command_defaults/en/random.jinja2 | 4 +- .../command_defaults/en/search.jinja2 | 4 +- .../command_defaults/en/summary.jinja2 | 2 +- .../command_defaults/ru/albums.jinja2 | 2 +- .../command_defaults/ru/favorites.jinja2 | 3 +- .../command_defaults/ru/latest.jinja2 | 4 +- .../command_defaults/ru/memory.jinja2 | 3 +- .../command_defaults/ru/random.jinja2 | 4 +- .../command_defaults/ru/search.jinja2 | 6 +- .../command_defaults/ru/summary.jinja2 | 2 +- .../api/command_template_configs.py | 8 +-- .../commands/immich/albums.py | 40 +++++++++---- .../commands/immich/common.py | 55 +++++++++++++---- .../commands/immich/events.py | 60 ++++++++++--------- .../commands/immich/handler.py | 38 +++++++++--- .../commands/immich/search.py | 19 ++++++ .../notify_bridge_server/commands/webhook.py | 5 +- .../services/telegram_poller.py | 7 ++- 26 files changed, 253 insertions(+), 116 deletions(-) create mode 100644 frontend/src/lib/sanitize.ts diff --git a/CLAUDE.md b/CLAUDE.md index f2c415a..a89dc33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,4 +35,11 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the - Feature gating: check `capabilities.notification_slots` or `capabilities.commands`, not `provider.type === 'immich'` - Template variable helpers: ALL provider types must have entries in `get_template_variables()` 9. **Nav tree & entity types** — when adding a new entity type (target type, bot type, etc.), update the sidebar nav in `frontend/src/routes/+layout.svelte`. Target types need: `{ href: '/targets?type={type}', key: 'nav.target{PascalName}', icon: '...', countKey: 'targets_{type}' }` in the `children` array under `nav.targets`, plus i18n keys `nav.target{PascalName}` in both locale files. Also add the counter entry `targets_{type}: targets.filter(t => t.type === '{type}').length` to the `counts` derived block in `+layout.svelte`. -10. **New provider descriptor checklist** — when adding a new service provider, create a descriptor file in `frontend/src/lib/providers/{name}.ts` and register it in `index.ts`. The descriptor must define: `type`, `defaultName`, `icon`, `hasUrl`, `configFields`, `buildConfig()`, `hasConfigChanged()`, `eventFields`, `collectionMeta` (or `null`). Optional: `extraTrackingFields`, `featureSections`, `webhookUrlPattern`, `webhookBased`, `onBeforeSave`. Also add i18n keys: `providers.type{PascalName}` and `gridDesc.provider{PascalName}` in both `en.json` and `ru.json`. +10. **Template consistency** (IMPORTANT) — notification templates and command templates for the same provider MUST use the same formatting patterns. If notification templates wrap URLs in ``, show type icons, location, and favorite status for assets, command templates must do the same. The reference pattern is the `assets_added` notification template. Command handlers must pass rich asset dicts (`public_url`, `city`, `country`, `is_favorite`, `type`) to templates — not just `id` and `originalFileName`. +11. **New provider descriptor checklist** — when adding a new service provider, create a descriptor file in `frontend/src/lib/providers/{name}.ts` and register it in `index.ts`. The descriptor must define: `type`, `defaultName`, `icon`, `hasUrl`, `configFields`, `buildConfig()`, `hasConfigChanged()`, `eventFields`, `collectionMeta` (or `null`). Optional: `extraTrackingFields`, `featureSections`, `webhookUrlPattern`, `webhookBased`, `onBeforeSave`. Also add i18n keys: `providers.type{PascalName}` and `gridDesc.provider{PascalName}` in both `en.json` and `ru.json`. +12. **Template context variables** (IMPORTANT) — when adding new variables to templates, update ALL of these in sync: + - Runtime context builder: `packages/core/src/notify_bridge_core/templates/context.py` + - Variable docs: `packages/server/src/notify_bridge_server/api/template_configs.py` (`get_template_variables()`) + - Notification preview sample: `packages/server/src/notify_bridge_server/services/sample_context.py` (`_SAMPLE_CONTEXT`) + - Command preview sample: `packages/server/src/notify_bridge_server/api/command_template_configs.py` (`sample_ctx` in `preview_raw`) + - Runtime validator whitelist: `packages/core/src/notify_bridge_core/templates/validator.py` diff --git a/frontend/src/lib/sanitize.ts b/frontend/src/lib/sanitize.ts new file mode 100644 index 0000000..df95984 --- /dev/null +++ b/frontend/src/lib/sanitize.ts @@ -0,0 +1,40 @@ +/** + * Sanitize HTML preview output — allows only safe Telegram-compatible tags. + * Used by notification and command template preview renderers. + */ + +const ALLOWED_TAGS = new Set(['B', 'I', 'CODE', 'PRE', 'A', 'BR']); + +function walkNodes(parent: Node, target: Node): void { + for (const node of Array.from(parent.childNodes)) { + if (node.nodeType === Node.TEXT_NODE) { + target.appendChild(document.createTextNode(node.textContent || '')); + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element; + if (ALLOWED_TAGS.has(el.tagName)) { + const safe = document.createElement(el.tagName); + if (el.tagName === 'A') { + const href = el.getAttribute('href') || ''; + if (/^https?:\/\//i.test(href)) { + safe.setAttribute('href', href); + safe.setAttribute('target', '_blank'); + safe.setAttribute('rel', 'noopener noreferrer'); + } + } + walkNodes(el, safe); + target.appendChild(safe); + } else { + walkNodes(el, target); + } + } + } +} + +export function sanitizePreview(html: string): string { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const fragment = document.createDocumentFragment(); + walkNodes(doc.body, fragment); + const wrapper = document.createElement('div'); + wrapper.appendChild(fragment); + return wrapper.innerHTML; +} diff --git a/frontend/src/routes/command-template-configs/+page.svelte b/frontend/src/routes/command-template-configs/+page.svelte index e9b60cb..e7b4bff 100644 --- a/frontend/src/routes/command-template-configs/+page.svelte +++ b/frontend/src/routes/command-template-configs/+page.svelte @@ -3,6 +3,7 @@ import { slide } from 'svelte/transition'; import { api } from '$lib/api'; import { t } from '$lib/i18n'; + import { sanitizePreview } from '$lib/sanitize'; import { commandTemplateConfigsCache } from '$lib/stores/caches.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; @@ -318,7 +319,7 @@ {/if} {#if slotPreview[slot.name] && !slotErrors[slot.name]}
-
{slotPreview[slot.name]}
+
{@html sanitizePreview(slotPreview[slot.name])}
{/if} diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index f62f875..37f357c 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -3,6 +3,7 @@ import { slide } from 'svelte/transition'; import { api } from '$lib/api'; import { t } from '$lib/i18n'; + import { sanitizePreview } from '$lib/sanitize'; import { templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; @@ -213,44 +214,6 @@ setTimeout(() => refreshAllPreviews(), 100); } - function sanitizePreview(html: string): string { - // DOM-based sanitizer: parse HTML, walk tree, keep only safe elements - const ALLOWED_TAGS = new Set(['B', 'I', 'CODE', 'PRE', 'A', 'BR']); - const doc = new DOMParser().parseFromString(html, 'text/html'); - const fragment = document.createDocumentFragment(); - - function walkNodes(parent: Node, target: Node) { - for (const node of Array.from(parent.childNodes)) { - if (node.nodeType === Node.TEXT_NODE) { - target.appendChild(document.createTextNode(node.textContent || '')); - } else if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as Element; - if (ALLOWED_TAGS.has(el.tagName)) { - const safe = document.createElement(el.tagName); - if (el.tagName === 'A') { - const href = el.getAttribute('href') || ''; - if (/^https?:\/\//i.test(href)) { - safe.setAttribute('href', href); - safe.setAttribute('target', '_blank'); - safe.setAttribute('rel', 'noopener noreferrer'); - } - } - walkNodes(el, safe); - target.appendChild(safe); - } else { - // Unwrap: keep text content of disallowed tags - walkNodes(el, target); - } - } - } - } - - walkNodes(doc.body, fragment); - const wrapper = document.createElement('div'); - wrapper.appendChild(fragment); - return wrapper.innerHTML; - } - function remove(id: number) { confirmDelete = { id, diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/albums.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/albums.jinja2 index 596a0d5..07284f7 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/en/albums.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/albums.jinja2 @@ -1,7 +1,7 @@ 📚 Tracked albums: {%- if albums %} {%- for album in albums %} - • {{ album.name }} ({{ album.asset_count }} assets) + • {% if album.public_url %}
{{ album.name }}{% else %}{{ album.name }}{% endif %} ({{ album.asset_count }} assets) {%- endfor %} {%- else %} (none) diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/favorites.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/favorites.jinja2 index 1941877..48f1002 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/en/favorites.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/favorites.jinja2 @@ -1,4 +1,5 @@ ⭐ Favorites: {%- for asset in assets %} - • {{ asset.originalFileName }} + • {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}{{ asset.originalFileName }}{% else %}{{ asset.originalFileName }}{% endif %} ❤️ + {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/latest.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/latest.jinja2 index da93f6f..a1685f2 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/en/latest.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/latest.jinja2 @@ -1,4 +1,6 @@ 📸 Latest: {%- for asset in assets %} - • {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %} + • {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}{{ asset.originalFileName }}{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} + {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} + {%- if asset.is_favorite %} ❤️{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/memory.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/memory.jinja2 index 12366a1..1dd76d8 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/en/memory.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/memory.jinja2 @@ -1,4 +1,5 @@ 📅 On this day: {%- for asset in assets %} - • {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %} + • {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}{{ asset.originalFileName }}{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} + {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/random.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/random.jinja2 index ff857ed..1bb6cb0 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/en/random.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/random.jinja2 @@ -1,4 +1,6 @@ 🎲 Random: {%- for asset in assets %} - • {{ asset.originalFileName }} + • {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}{{ asset.originalFileName }}{% else %}{{ asset.originalFileName }}{% endif %} + {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} + {%- if asset.is_favorite %} ❤️{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/search.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/search.jinja2 index 260a136..664aa0a 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/en/search.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/search.jinja2 @@ -4,5 +4,7 @@ {%- else %}🔍 Results for "{{ query }}": {%- endif %} {%- for asset in assets %} - • {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %} + • {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}{{ asset.originalFileName }}{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} + {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} + {%- if asset.is_favorite %} ❤️{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/summary.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/summary.jinja2 index 506c712..5c498e5 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/en/summary.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/summary.jinja2 @@ -1,4 +1,4 @@ 📋 Album summary ({{ albums | length }}): {%- for album in albums %} - • {{ album.name }}: {{ album.asset_count }} assets + • {% if album.public_url %}{{ album.name }}{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/albums.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/albums.jinja2 index 5995523..85bfbaa 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/albums.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/albums.jinja2 @@ -1,7 +1,7 @@ 📚 Отслеживаемые альбомы: {%- if albums %} {%- for album in albums %} - • {{ album.name }} ({{ album.asset_count }} файлов) + • {% if album.public_url %}{{ album.name }}{% else %}{{ album.name }}{% endif %} ({{ album.asset_count }} файлов) {%- endfor %} {%- else %} (нет) diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/favorites.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/favorites.jinja2 index ef1fd77..6e9a8c7 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/favorites.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/favorites.jinja2 @@ -1,4 +1,5 @@ ⭐ Избранное: {%- for asset in assets %} - • {{ asset.originalFileName }} + • {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}{{ asset.originalFileName }}{% else %}{{ asset.originalFileName }}{% endif %} ❤️ + {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/latest.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/latest.jinja2 index b73a947..650f5e2 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/latest.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/latest.jinja2 @@ -1,4 +1,6 @@ 📸 Последние: {%- for asset in assets %} - • {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %} + • {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}{{ asset.originalFileName }}{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} + {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} + {%- if asset.is_favorite %} ❤️{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/memory.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/memory.jinja2 index 2346538..f0b0a9e 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/memory.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/memory.jinja2 @@ -1,4 +1,5 @@ 📅 В этот день: {%- for asset in assets %} - • {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %} + • {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}{{ asset.originalFileName }}{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} + {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/random.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/random.jinja2 index 12273ff..b55312d 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/random.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/random.jinja2 @@ -1,4 +1,6 @@ 🎲 Случайные: {%- for asset in assets %} - • {{ asset.originalFileName }} + • {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}{{ asset.originalFileName }}{% else %}{{ asset.originalFileName }}{% endif %} + {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} + {%- if asset.is_favorite %} ❤️{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/search.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/search.jinja2 index 9f4956c..9750a00 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/search.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/search.jinja2 @@ -1,8 +1,10 @@ {%- if command == "find" %}📄 Файлы по запросу "{{ query }}": {%- elif command == "person" %}👤 Фото {{ query }}: {%- elif command == "place" %}📍 Фото из {{ query }}: -{%- else %}🔍 Результаты для "{{ query }}": +{%- else %}🔍 Результаты по "{{ query }}": {%- endif %} {%- for asset in assets %} - • {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %} + • {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}{{ asset.originalFileName }}{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %} + {%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %} + {%- if asset.is_favorite %} ❤️{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/summary.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/summary.jinja2 index 13d5e6c..ac11f89 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/summary.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/summary.jinja2 @@ -1,4 +1,4 @@ 📋 Сводка альбомов ({{ albums | length }}): {%- for album in albums %} - • {{ album.name }}: {{ album.asset_count }} файлов + • {% if album.public_url %}{{ album.name }}{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов {%- endfor %} \ No newline at end of file diff --git a/packages/server/src/notify_bridge_server/api/command_template_configs.py b/packages/server/src/notify_bridge_server/api/command_template_configs.py index caf5120..ffd4e47 100644 --- a/packages/server/src/notify_bridge_server/api/command_template_configs.py +++ b/packages/server/src/notify_bridge_server/api/command_template_configs.py @@ -343,8 +343,8 @@ async def preview_raw( ], # /albums, /summary "albums": [ - {"name": "Family Photos", "asset_count": 142, "id": "abc-123"}, - {"name": "Vacation 2025", "asset_count": 87, "id": "def-456"}, + {"name": "Family Photos", "asset_count": 142, "id": "abc-123", "public_url": "https://immich.example.com/share/abc123"}, + {"name": "Vacation 2025", "asset_count": 87, "id": "def-456", "public_url": ""}, ], # /events "events": [ @@ -355,8 +355,8 @@ async def preview_raw( "people": ["Alice", "Bob", "Charlie"], # /search, /find, /person, /place, /latest, /favorites, /random, /memory "assets": [ - {"id": "a1", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "createdAt": "2026-03-19T14:30:00", "year": 2024}, - {"id": "a2", "originalFileName": "VID_002.mp4", "type": "VIDEO", "createdAt": "2026-03-19T15:00:00", "year": 2023}, + {"id": "a1", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "createdAt": "2026-03-19T14:30:00", "year": 2024, "public_url": "https://immich.example.com/share/abc123/photos/a1", "city": "Paris", "country": "France", "is_favorite": True}, + {"id": "a2", "originalFileName": "VID_002.mp4", "type": "VIDEO", "createdAt": "2026-03-19T15:00:00", "year": 2023, "public_url": "", "city": "", "country": "", "is_favorite": False}, ], "query": "sunset", "command": "search", diff --git a/packages/server/src/notify_bridge_server/commands/immich/albums.py b/packages/server/src/notify_bridge_server/commands/immich/albums.py index 6d2796c..f792913 100644 --- a/packages/server/src/notify_bridge_server/commands/immich/albums.py +++ b/packages/server/src/notify_bridge_server/commands/immich/albums.py @@ -8,10 +8,12 @@ from typing import Any import aiohttp +from notify_bridge_core.providers.immich.asset_utils import get_public_url + from ...database.models import ServiceProvider, TelegramBot from ...services import make_immich_provider from ..handler import _get_notification_trackers_for_providers, _render_cmd_template -from .common import _format_assets +from .common import _format_assets, build_asset_dict _LOGGER = logging.getLogger(__name__) @@ -35,19 +37,28 @@ async def _cmd_albums( if not album_ids: continue - results = await asyncio.gather( + ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/") + album_results = await asyncio.gather( *[immich.client.get_album(aid) for aid in album_ids], return_exceptions=True, ) - for album_id, result in zip(album_ids, results): + link_results = await asyncio.gather( + *[immich.client.get_shared_links(aid) for aid in album_ids], + return_exceptions=True, + ) + for album_id, result, links in zip(album_ids, album_results, link_results): if isinstance(result, Exception): _LOGGER.warning("Failed to fetch album %s: %s", album_id, result) albums_data.append({ "name": f"{album_id[:8]}...", "asset_count": "?", "id": album_id, }) elif result: + pub_url = "" + if not isinstance(links, Exception) and ext_domain: + pub_url = get_public_url(ext_domain, links) or "" albums_data.append({ - "name": result.name, "asset_count": result.asset_count, "id": album_id, + "name": result.name, "asset_count": result.asset_count, + "id": album_id, "public_url": pub_url, }) return {"albums": albums_data} @@ -77,10 +88,7 @@ async def cmd_favorites( if result: for aid, asset in list(result.assets.items())[:50]: if asset.is_favorite and len(fav_assets) < count: - fav_assets.append({ - "id": asset.id, "originalFileName": asset.filename, - "type": asset.type, - }) + fav_assets.append(build_asset_dict(asset)) if len(fav_assets) >= count: break @@ -90,24 +98,34 @@ async def cmd_favorites( async def cmd_summary( client: Any, all_album_ids: list[str], locale: str, cmd_templates: dict[str, dict[str, str]], + external_domain: str = "", ) -> str: """Handle /summary command with concurrent album fetching.""" if not all_album_ids: return _render_cmd_template(cmd_templates, "summary", locale, {"albums": []}) - results = await asyncio.gather( + album_results = await asyncio.gather( *[client.get_album(aid) for aid in all_album_ids], return_exceptions=True, ) + link_results = await asyncio.gather( + *[client.get_shared_links(aid) for aid in all_album_ids], + return_exceptions=True, + ) + ext = external_domain.rstrip("/") albums_data: list[dict] = [] - for album_id, result in zip(all_album_ids, results): + for album_id, result, links in zip(all_album_ids, album_results, link_results): if isinstance(result, Exception): _LOGGER.warning("Failed to fetch album %s: %s", album_id, result) continue if result: + pub_url = "" + if not isinstance(links, Exception) and ext: + pub_url = get_public_url(ext, links) or "" albums_data.append({ - "name": result.name, "asset_count": result.asset_count, "id": album_id, + "name": result.name, "asset_count": result.asset_count, + "id": album_id, "public_url": pub_url, }) return _render_cmd_template(cmd_templates, "summary", locale, {"albums": albums_data}) diff --git a/packages/server/src/notify_bridge_server/commands/immich/common.py b/packages/server/src/notify_bridge_server/commands/immich/common.py index fe9996c..0c2906f 100644 --- a/packages/server/src/notify_bridge_server/commands/immich/common.py +++ b/packages/server/src/notify_bridge_server/commands/immich/common.py @@ -17,6 +17,41 @@ _IMMICH_COMMANDS = { } +def build_asset_dict( + asset: Any, + *, + public_url: str = "", + year: int | None = None, +) -> dict[str, Any]: + """Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict.""" + if isinstance(asset, dict): + d = { + "id": asset.get("id", ""), + "originalFileName": asset.get("originalFileName", asset.get("filename", "")), + "type": asset.get("type", "IMAGE"), + "createdAt": asset.get("createdAt", asset.get("created_at", asset.get("fileCreatedAt", ""))), + "city": asset.get("city", ""), + "country": asset.get("country", ""), + "is_favorite": asset.get("is_favorite", asset.get("isFavorite", False)), + "public_url": asset.get("public_url", public_url), + } + if year or asset.get("year"): + d["year"] = year or asset.get("year") + return d + # ImmichAssetInfo dataclass + return { + "id": asset.id, + "originalFileName": asset.filename, + "type": asset.type, + "createdAt": asset.created_at, + "city": getattr(asset, "city", "") or "", + "country": getattr(asset, "country", "") or "", + "is_favorite": getattr(asset, "is_favorite", False), + "public_url": public_url, + **({"year": year} if year else {}), + } + + def _format_assets( assets: list[dict[str, Any]], cmd: str, query: str, locale: str, response_mode: str, client: Any, @@ -26,24 +61,24 @@ def _format_assets( if not assets: return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": query}) + slot_map = {"find": "search", "person": "search", "place": "search"} + slot_name = slot_map.get(cmd, cmd) + text = _render_cmd_template(cmd_templates, slot_name, locale, { + "assets": assets, "query": query, "command": cmd, "count": len(assets), + }) + if response_mode == "media": media_items = [] for asset in assets: asset_id = asset.get("id", "") - filename = asset.get("originalFileName", "") - year = asset.get("year", "") - caption = f"{filename} ({year})" if year else filename media_items.append({ "type": "photo", "asset_id": asset_id, - "caption": caption, + "caption": "", "thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview", "api_key": client.api_key, }) - return media_items + # Return text message + media items — text is sent first, media as reply + return {"text": text, "media": media_items} - slot_map = {"find": "search", "person": "search", "place": "search"} - slot_name = slot_map.get(cmd, cmd) - return _render_cmd_template(cmd_templates, slot_name, locale, { - "assets": assets, "query": query, "command": cmd, "count": len(assets), - }) + return text diff --git a/packages/server/src/notify_bridge_server/commands/immich/events.py b/packages/server/src/notify_bridge_server/commands/immich/events.py index c4eca37..9d983b0 100644 --- a/packages/server/src/notify_bridge_server/commands/immich/events.py +++ b/packages/server/src/notify_bridge_server/commands/immich/events.py @@ -16,8 +16,10 @@ from ...database.models import ( EventLog, NotificationTarget, NotificationTrackerTarget, ServiceProvider, TelegramBot, TrackingConfig, ) +from notify_bridge_core.providers.immich.asset_utils import get_public_url + from ..handler import _get_notification_trackers_for_providers, _render_cmd_template -from .common import _format_assets +from .common import _format_assets, build_asset_dict _LOGGER = logging.getLogger(__name__) @@ -54,28 +56,35 @@ async def cmd_latest( client: Any, all_album_ids: list[str], count: int, locale: str, response_mode: str, cmd_templates: dict[str, dict[str, str]], + external_domain: str = "", ) -> str | list[dict[str, Any]]: """Handle /latest command with concurrent album fetching.""" album_ids = all_album_ids[:10] if not album_ids: return _format_assets([], "latest", "", locale, response_mode, client, cmd_templates) - results = await asyncio.gather( + album_results = await asyncio.gather( *[client.get_album(aid) for aid in album_ids], return_exceptions=True, ) + link_results = await asyncio.gather( + *[client.get_shared_links(aid) for aid in album_ids], + return_exceptions=True, + ) + ext = external_domain.rstrip("/") latest_assets: list[dict[str, Any]] = [] - for album_id, result in zip(album_ids, results): + for album_id, result, links in zip(album_ids, album_results, link_results): if isinstance(result, Exception): _LOGGER.warning("Failed to fetch album %s: %s", album_id, result) continue if result: + pub_url = "" + if not isinstance(links, Exception) and ext: + pub_url = get_public_url(ext, links) or "" for aid, asset in list(result.assets.items())[:count]: - latest_assets.append({ - "id": asset.id, "originalFileName": asset.filename, - "type": asset.type, "createdAt": asset.created_at, - }) + asset_pub = f"{pub_url}/photos/{asset.id}" if pub_url else "" + latest_assets.append(build_asset_dict(asset, public_url=asset_pub)) latest_assets.sort(key=lambda a: a.get("createdAt", ""), reverse=True) return _format_assets(latest_assets[:count], "latest", "", locale, response_mode, client, cmd_templates) @@ -85,30 +94,37 @@ async def cmd_random( client: Any, all_album_ids: list[str], count: int, locale: str, response_mode: str, cmd_templates: dict[str, dict[str, str]], + external_domain: str = "", ) -> str | list[dict[str, Any]]: """Handle /random command with concurrent album fetching.""" album_ids = all_album_ids[:10] if not album_ids: return _format_assets([], "random", "", locale, response_mode, client, cmd_templates) - results = await asyncio.gather( + album_results = await asyncio.gather( *[client.get_album(aid) for aid in album_ids], return_exceptions=True, ) + link_results = await asyncio.gather( + *[client.get_shared_links(aid) for aid in album_ids], + return_exceptions=True, + ) + ext = external_domain.rstrip("/") random_assets: list[dict[str, Any]] = [] - for album_id, result in zip(album_ids, results): + for album_id, result, links in zip(album_ids, album_results, link_results): if isinstance(result, Exception): _LOGGER.warning("Failed to fetch album %s: %s", album_id, result) continue if result: + pub_url = "" + if not isinstance(links, Exception) and ext: + pub_url = get_public_url(ext, links) or "" asset_list = list(result.assets.values()) sampled = rng.sample(asset_list, min(count, len(asset_list))) for asset in sampled: - random_assets.append({ - "id": asset.id, "originalFileName": asset.filename, - "type": asset.type, - }) + asset_pub = f"{pub_url}/photos/{asset.id}" if pub_url else "" + random_assets.append(build_asset_dict(asset, public_url=asset_pub)) rng.shuffle(random_assets) return _format_assets(random_assets[:count], "random", "", locale, response_mode, client, cmd_templates) @@ -161,22 +177,16 @@ async def cmd_memory( asset_albums = raw_asset.get("albums", []) if not any(a.get("id") in tracked_ids for a in asset_albums): continue - memory_assets.append({ - "id": raw_asset.get("id", ""), - "originalFileName": raw_asset.get("originalFileName", ""), - "type": raw_asset.get("type", "IMAGE"), - "createdAt": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")), - "year": year, - }) + memory_assets.append(build_asset_dict(raw_asset, year=year)) else: album_ids = all_album_ids[:10] if album_ids: - results = await asyncio.gather( + album_results = await asyncio.gather( *[client.get_album(aid) for aid in album_ids], return_exceptions=True, ) month_day = (today.month, today.day) - for album_id, result in zip(album_ids, results): + for album_id, result in zip(album_ids, album_results): if isinstance(result, Exception): _LOGGER.warning("Failed to fetch album %s: %s", album_id, result) continue @@ -185,11 +195,7 @@ async def cmd_memory( try: dt = datetime.fromisoformat(asset.created_at.replace("Z", "+00:00")) if (dt.month, dt.day) == month_day and dt.year != today.year: - memory_assets.append({ - "id": asset.id, "originalFileName": asset.filename, - "type": asset.type, "createdAt": asset.created_at, - "year": dt.year, - }) + memory_assets.append(build_asset_dict(asset, year=dt.year)) except (ValueError, AttributeError): pass diff --git a/packages/server/src/notify_bridge_server/commands/immich/handler.py b/packages/server/src/notify_bridge_server/commands/immich/handler.py index 84efde9..a916ee4 100644 --- a/packages/server/src/notify_bridge_server/commands/immich/handler.py +++ b/packages/server/src/notify_bridge_server/commands/immich/handler.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from typing import Any @@ -17,6 +18,8 @@ from ...database.models import ( from ...services import make_immich_provider from ..base import ProviderCommandHandler from ..handler import _get_notification_trackers_for_providers, _render_cmd_template +from notify_bridge_core.providers.immich.asset_utils import get_public_url + from .albums import _cmd_albums, cmd_favorites, cmd_summary from .common import _IMMICH_COMMANDS from .events import _cmd_events, cmd_latest, cmd_memory, cmd_random @@ -134,33 +137,54 @@ async def _cmd_immich( if not provider: return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args}) + ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/") + async with aiohttp.ClientSession() as http: immich = make_immich_provider(http, provider) client = immich.client + # Build asset_id → public_url map from tracked albums' shared links + asset_public_urls: dict[str, str] = {} + if ext_domain and all_album_ids and cmd in ("search", "find", "person", "place", "favorites"): + link_results = await asyncio.gather( + *[client.get_shared_links(aid) for aid in all_album_ids], + return_exceptions=True, + ) + album_results = await asyncio.gather( + *[client.get_album(aid) for aid in all_album_ids], + return_exceptions=True, + ) + for album_id, links, album in zip(all_album_ids, link_results, album_results): + if isinstance(links, Exception) or isinstance(album, Exception): + continue + pub_url = get_public_url(ext_domain, links) + if pub_url and album: + for asset_id in album.assets: + asset_public_urls[asset_id] = f"{pub_url}/photos/{asset_id}" + if cmd == "search": - return await cmd_search(client, args, all_album_ids, count, locale, response_mode, cmd_templates) + return await cmd_search(client, args, all_album_ids, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls) if cmd == "find": - return await cmd_find(client, args, all_album_ids, count, locale, response_mode, cmd_templates) + return await cmd_find(client, args, all_album_ids, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls) if cmd == "person": - return await cmd_person(client, args, count, locale, response_mode, cmd_templates) + return await cmd_person(client, args, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls) if cmd == "place": - return await cmd_place(client, args, all_album_ids, count, locale, response_mode, cmd_templates) + return await cmd_place(client, args, all_album_ids, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls) if cmd == "favorites": return await cmd_favorites(bot, providers_map, all_album_ids, count, locale, response_mode, client, cmd_templates) if cmd == "latest": - return await cmd_latest(client, all_album_ids, count, locale, response_mode, cmd_templates) + return await cmd_latest(client, all_album_ids, count, locale, response_mode, cmd_templates, external_domain=ext_domain) if cmd == "random": - return await cmd_random(client, all_album_ids, count, locale, response_mode, cmd_templates) + return await cmd_random(client, all_album_ids, count, locale, response_mode, cmd_templates, external_domain=ext_domain) if cmd == "summary": - return await cmd_summary(client, all_album_ids, locale, cmd_templates) + return await cmd_summary(client, all_album_ids, locale, cmd_templates, external_domain=ext_domain) if cmd == "memory": return await cmd_memory(bot, client, all_album_ids, count, locale, response_mode, cmd_templates) diff --git a/packages/server/src/notify_bridge_server/commands/immich/search.py b/packages/server/src/notify_bridge_server/commands/immich/search.py index 163d166..7881ff2 100644 --- a/packages/server/src/notify_bridge_server/commands/immich/search.py +++ b/packages/server/src/notify_bridge_server/commands/immich/search.py @@ -8,15 +8,28 @@ from ..handler import _render_cmd_template from .common import _format_assets +def _enrich_assets(assets: list[dict[str, Any]], asset_public_urls: dict[str, str]) -> list[dict[str, Any]]: + """Add public_url to assets from the pre-built map.""" + if not asset_public_urls: + return assets + for asset in assets: + aid = asset.get("id", "") + if aid and aid in asset_public_urls and not asset.get("public_url"): + asset["public_url"] = asset_public_urls[aid] + return assets + + async def cmd_search( client: Any, args: str, all_album_ids: list[str], count: int, locale: str, response_mode: str, cmd_templates: dict[str, dict[str, str]], + asset_public_urls: dict[str, str] | None = None, ) -> str | list[dict[str, Any]]: """Handle /search command.""" if not args: return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""}) assets = await client.search_smart(args, album_ids=all_album_ids, limit=count) + _enrich_assets(assets, asset_public_urls or {}) return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates) @@ -24,11 +37,13 @@ async def cmd_find( client: Any, args: str, all_album_ids: list[str], count: int, locale: str, response_mode: str, cmd_templates: dict[str, dict[str, str]], + asset_public_urls: dict[str, str] | None = None, ) -> str | list[dict[str, Any]]: """Handle /find command.""" if not args: return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""}) assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count) + _enrich_assets(assets, asset_public_urls or {}) return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates) @@ -36,6 +51,7 @@ async def cmd_person( client: Any, args: str, count: int, locale: str, response_mode: str, cmd_templates: dict[str, dict[str, str]], + asset_public_urls: dict[str, str] | None = None, ) -> str | list[dict[str, Any]]: """Handle /person command.""" if not args: @@ -49,6 +65,7 @@ async def cmd_person( if not person_id: return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args}) assets = await client.search_by_person(person_id, limit=count) + _enrich_assets(assets, asset_public_urls or {}) return _format_assets(assets, "person", args, locale, response_mode, client, cmd_templates) @@ -56,6 +73,7 @@ async def cmd_place( client: Any, args: str, all_album_ids: list[str], count: int, locale: str, response_mode: str, cmd_templates: dict[str, dict[str, str]], + asset_public_urls: dict[str, str] | None = None, ) -> str | list[dict[str, Any]]: """Handle /place command.""" if not args: @@ -63,4 +81,5 @@ async def cmd_place( assets = await client.search_smart( f"photos taken in {args}", album_ids=all_album_ids, limit=count ) + _enrich_assets(assets, asset_public_urls or {}) return _format_assets(assets, "place", args, locale, response_mode, client, cmd_templates) diff --git a/packages/server/src/notify_bridge_server/commands/webhook.py b/packages/server/src/notify_bridge_server/commands/webhook.py index 79a8c69..972f456 100644 --- a/packages/server/src/notify_bridge_server/commands/webhook.py +++ b/packages/server/src/notify_bridge_server/commands/webhook.py @@ -90,7 +90,10 @@ async def telegram_webhook( message_id = message.get("message_id") cmd_response = await handle_command(bot, chat_id, text, language_code=effective_lang) if cmd_response is not None: - if isinstance(cmd_response, list): + if isinstance(cmd_response, dict) and "media" in cmd_response: + await send_reply(bot.token, chat_id, cmd_response["text"], reply_to_message_id=message_id) + await send_media_group(bot.token, chat_id, cmd_response["media"], reply_to_message_id=message_id) + elif isinstance(cmd_response, list): await send_media_group(bot.token, chat_id, cmd_response, reply_to_message_id=message_id) else: await send_reply(bot.token, chat_id, cmd_response, reply_to_message_id=message_id) diff --git a/packages/server/src/notify_bridge_server/services/telegram_poller.py b/packages/server/src/notify_bridge_server/services/telegram_poller.py index 5114308..83e01af 100644 --- a/packages/server/src/notify_bridge_server/services/telegram_poller.py +++ b/packages/server/src/notify_bridge_server/services/telegram_poller.py @@ -211,7 +211,12 @@ async def _poll_bot(bot_id: int) -> None: message_id = message.get("message_id") cmd_response = await handle_command(bot_obj, chat_id, text, language_code=effective_lang) if cmd_response is not None: - if isinstance(cmd_response, list): + if isinstance(cmd_response, dict) and "media" in cmd_response: + # Text + media: send text first, media as reply + from ..commands.handler import send_reply as _reply + await _reply(bot_token, chat_id, cmd_response["text"], reply_to_message_id=message_id) + await send_media_group(bot_token, chat_id, cmd_response["media"], reply_to_message_id=message_id) + elif isinstance(cmd_response, list): await send_media_group(bot_token, chat_id, cmd_response, reply_to_message_id=message_id) else: await send_reply(bot_token, chat_id, cmd_response, reply_to_message_id=message_id)