From 846d480d384a3bb4e5cc31e6e9ba5a62ab5b7399 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 21 Mar 2026 16:33:24 +0300 Subject: [PATCH] feat: provider-strict configs, slot-based templates, broadcast targets, email bots, command templates Major architectural improvements: - Provider-type enforcement: configs validated against provider type at assignment - TemplateConfig migrated to slot-based pattern (TemplateSlot child table) - Broadcast targets: TargetReceiver child table for multi-receiver dispatch - EmailBot: first-class email sender entity with SMTP config, test connection - CommandTemplateConfig: generic slot-based command response templates - Provider capability registry: dynamic slot/event/command definitions per provider - CommandTracker play/pause button matches NotificationTracker style --- frontend/src/lib/i18n/en.json | 39 ++- frontend/src/lib/i18n/ru.json | 39 ++- frontend/src/lib/types.ts | 165 ++++++++++ frontend/src/routes/+layout.svelte | 1 + .../command-template-configs/+page.svelte | 298 ++++++++++++++++++ .../src/routes/command-trackers/+page.svelte | 10 +- .../routes/notification-trackers/+page.svelte | 18 +- .../src/routes/telegram-bots/+page.svelte | 167 +++++++++- .../src/routes/template-configs/+page.svelte | 60 ++-- packages/core/pyproject.toml | 1 + .../notifications/dispatcher.py | 187 ++++++++--- .../notifications/email/__init__.py | 5 + .../notifications/email/client.py | 76 +++++ .../providers/capabilities.py | 107 +++++++ .../api/command_template_configs.py | 230 ++++++++++++++ .../notify_bridge_server/api/email_bots.py | 148 +++++++++ .../api/notification_tracker_targets.py | 60 +++- .../src/notify_bridge_server/api/providers.py | 34 ++ .../api/target_receivers.py | 147 +++++++++ .../src/notify_bridge_server/api/targets.py | 27 +- .../api/template_configs.py | 143 +++++++-- .../notify_bridge_server/commands/handler.py | 46 ++- .../database/migrations.py | 176 +++++++++++ .../notify_bridge_server/database/models.py | 118 ++++++- .../server/src/notify_bridge_server/main.py | 46 ++- .../notify_bridge_server/services/notifier.py | 154 +++++++-- .../notify_bridge_server/services/watcher.py | 58 +++- 27 files changed, 2355 insertions(+), 205 deletions(-) create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/routes/command-template-configs/+page.svelte create mode 100644 packages/core/src/notify_bridge_core/notifications/email/__init__.py create mode 100644 packages/core/src/notify_bridge_core/notifications/email/client.py create mode 100644 packages/core/src/notify_bridge_core/providers/capabilities.py create mode 100644 packages/server/src/notify_bridge_server/api/command_template_configs.py create mode 100644 packages/server/src/notify_bridge_server/api/email_bots.py create mode 100644 packages/server/src/notify_bridge_server/api/target_receivers.py diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index f249520..bd94464 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -13,6 +13,7 @@ "targets": "Targets", "commandConfigs": "Cmd Configs", "commandTrackers": "Cmd Trackers", + "cmdTemplateConfigs": "Cmd Templates", "users": "Users", "settings": "Settings", "logout": "Logout" @@ -478,6 +479,36 @@ "botLocale": "Language for command descriptions in Telegram's menu and bot response messages.", "rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit." }, + "emailBot": { + "title": "Email Bots", + "description": "SMTP email senders for notifications", + "addBot": "Add Email Bot", + "name": "Name", + "namePlaceholder": "Family Notifications", + "email": "From Email", + "smtpHost": "SMTP Host", + "smtpPort": "Port", + "smtpUsername": "Username", + "smtpUsernamePlaceholder": "Same as email or app password", + "smtpPassword": "Password", + "passwordUnchanged": "(unchanged)", + "useTls": "Use TLS/SSL", + "testConnection": "Send test email", + "noBots": "No email bots yet.", + "confirmDelete": "Delete this email bot?" + }, + "cmdTemplateConfig": { + "title": "Command Templates", + "description": "Customize command response messages with Jinja2 templates", + "newConfig": "New Config", + "name": "Name", + "namePlaceholder": "Default Immich Commands", + "descriptionPlaceholder": "e.g. Custom response formats for family bot", + "noConfigs": "No command template configs yet.", + "confirmDelete": "Delete this command template config?", + "commandResponses": "Command Responses", + "commandResponsesHint": "Leave a slot empty to use the default hardcoded response." + }, "commandConfig": { "title": "Command Configs", "description": "Define command settings for Telegram bot interactions", @@ -558,7 +589,13 @@ "commandTrackerEnabled": "Command tracker enabled", "commandTrackerDisabled": "Command tracker disabled", "listenerAdded": "Listener added", - "listenerRemoved": "Listener removed" + "listenerRemoved": "Listener removed", + "cmdTemplateSaved": "Command template saved", + "cmdTemplateDeleted": "Command template deleted", + "emailBotCreated": "Email bot created", + "emailBotUpdated": "Email bot updated", + "emailBotDeleted": "Email bot deleted", + "emailBotTestSent": "Test email sent successfully" }, "common": { "loading": "Loading...", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 519df3e..923d0ca 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -13,6 +13,7 @@ "targets": "Получатели", "commandConfigs": "Конф. команд", "commandTrackers": "Трекеры команд", + "cmdTemplateConfigs": "Шаблоны команд", "users": "Пользователи", "settings": "Настройки", "logout": "Выход" @@ -478,6 +479,36 @@ "botLocale": "Язык описаний команд в меню Telegram и ответов бота.", "rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений." }, + "emailBot": { + "title": "Email боты", + "description": "SMTP отправители для уведомлений по email", + "addBot": "Добавить Email бот", + "name": "Название", + "namePlaceholder": "Семейные уведомления", + "email": "Email отправителя", + "smtpHost": "SMTP сервер", + "smtpPort": "Порт", + "smtpUsername": "Имя пользователя", + "smtpUsernamePlaceholder": "Как email или пароль приложения", + "smtpPassword": "Пароль", + "passwordUnchanged": "(без изменений)", + "useTls": "Использовать TLS/SSL", + "testConnection": "Отправить тестовое письмо", + "noBots": "Email ботов пока нет.", + "confirmDelete": "Удалить этот email бот?" + }, + "cmdTemplateConfig": { + "title": "Шаблоны команд", + "description": "Настройте ответы команд с помощью Jinja2 шаблонов", + "newConfig": "Новый шаблон", + "name": "Название", + "namePlaceholder": "Команды Immich по умолчанию", + "descriptionPlaceholder": "Например, пользовательские форматы ответов", + "noConfigs": "Шаблонов команд пока нет.", + "confirmDelete": "Удалить этот шаблон команд?", + "commandResponses": "Ответы команд", + "commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию." + }, "commandConfig": { "title": "Конфигурации команд", "description": "Настройки команд для взаимодействия с Telegram-ботами", @@ -558,7 +589,13 @@ "commandTrackerEnabled": "Трекер команд включён", "commandTrackerDisabled": "Трекер команд отключён", "listenerAdded": "Слушатель добавлен", - "listenerRemoved": "Слушатель удалён" + "listenerRemoved": "Слушатель удалён", + "cmdTemplateSaved": "Шаблон команд сохранён", + "cmdTemplateDeleted": "Шаблон команд удалён", + "emailBotCreated": "Email бот создан", + "emailBotUpdated": "Email бот обновлён", + "emailBotDeleted": "Email бот удалён", + "emailBotTestSent": "Тестовое письмо отправлено" }, "common": { "loading": "Загрузка...", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..03c5d2a --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,165 @@ +/** Shared TypeScript interfaces for API entities. */ + +export interface ServiceProvider { + id: number; + type: string; + name: string; + icon: string; + config: Record; + created_at: string; +} + +export interface EmailBot { + id: number; + name: string; + icon: string; + email: string; + smtp_host: string; + smtp_port: number; + smtp_username: string; + smtp_password: string; + smtp_use_tls: boolean; + created_at: string; +} + +export interface TelegramBot { + id: number; + name: string; + icon: string; + bot_username: string; + bot_id: number; + webhook_path_id: string; + commands_config: Record; + token_preview: string; + created_at: string; +} + +export interface TelegramChat { + id: number; + chat_id: string; + title: string; + type: string; + username: string; + discovered_at: string; +} + +export interface TrackerTarget { + id: number; + target_id: number; + target_name: string | null; + target_type: string | null; + target_icon: string | null; + tracking_config_id: number | null; + template_config_id: number | null; + enabled: boolean; + quiet_hours_start: string | null; + quiet_hours_end: string | null; + commands_config: Record | null; + created_at: string; +} + +export interface Tracker { + id: number; + name: string; + icon: string; + provider_id: number; + collection_ids: string[]; + scan_interval: number; + batch_duration: number; + enabled: boolean; + tracker_targets: TrackerTarget[]; + created_at: string; +} + +export interface NotificationTarget { + id: number; + type: string; + name: string; + icon: string; + config: Record; + chat_name?: string; + created_at: string; +} + +export interface TrackingConfig { + id: number; + provider_type: string; + name: string; + icon: string; + track_assets_added: boolean; + track_assets_removed: boolean; + track_collection_renamed: boolean; + track_collection_deleted: boolean; + track_sharing_changed: boolean; + track_images: boolean; + track_videos: boolean; + notify_favorites_only: boolean; + include_tags: boolean; + include_asset_details: boolean; + max_assets_to_show: number; + assets_order_by: string; + assets_order: string; + periodic_enabled: boolean; + periodic_interval_days: number; + periodic_start_date: string; + periodic_times: string; + scheduled_enabled: boolean; + scheduled_times: string; + scheduled_collection_mode: string; + scheduled_limit: number; + scheduled_favorite_only: boolean; + scheduled_asset_type: string; + scheduled_min_rating: number; + scheduled_order_by: string; + scheduled_order: string; + memory_enabled: boolean; + memory_source: string; + memory_times: string; + memory_collection_mode: string; + memory_limit: number; + memory_favorite_only: boolean; + memory_asset_type: string; + memory_min_rating: number; + created_at: string; +} + +export interface TemplateConfig { + id: number; + user_id: number; + provider_type: string; + name: string; + description: string; + icon: string; + slots: Record; + date_format: string; + date_only_format: string; + created_at: string; +} + +export interface EventLog { + id: number; + event_type: string; + collection_id: string; + collection_name: string; + tracker_name: string; + provider_name: string; + provider_id: number | null; + assets_count: number; + details: Record; + created_at: string; +} + +export interface User { + id: number; + username: string; + role: string; + created_at: string; +} + +export interface DashboardStatus { + providers: number; + trackers: { total: number; active: number }; + targets: number; + total_events: number; + recent_events: EventLog[]; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 94f7c47..2ff3f60 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -46,6 +46,7 @@ { href: '/targets', key: 'nav.targets', icon: 'mdiTarget' }, { href: '/command-trackers', key: 'nav.commandTrackers', icon: 'mdiConsoleLine' }, { href: '/command-configs', key: 'nav.commandConfigs', icon: 'mdiCog' }, + { href: '/command-template-configs', key: 'nav.cmdTemplateConfigs', icon: 'mdiCodeBracesBox' }, ]; const navItems = $derived(auth.isAdmin ? [...baseNavItems, { href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' }, { href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' }] diff --git a/frontend/src/routes/command-template-configs/+page.svelte b/frontend/src/routes/command-template-configs/+page.svelte new file mode 100644 index 0000000..51f5aa6 --- /dev/null +++ b/frontend/src/routes/command-template-configs/+page.svelte @@ -0,0 +1,298 @@ + + + + + + +{#if !loaded}{:else} + +{#if showForm} +
+ + {#if error}
{error}
{/if} +
+
+ +
+ form.icon = v} /> + +
+
+
+ + +
+ +
+ {t('cmdTemplateConfig.commandResponses')} +

{t('cmdTemplateConfig.commandResponsesHint')}

+
+ {#each commandSlots as slot} +
+
+ + {slot.description} +
+ { form.slots[slot.name] = v; validateSlot(slot.name, v); }} + rows={3} + errorLine={slotErrorLines[slot.name] || null} + /> + {#if slotErrors[slot.name]} + {#if slotErrorTypes[slot.name] === 'undefined'} +

⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}

+ {:else} +

✕ {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}

+ {/if} + {/if} + {#if slotPreview[slot.name] && !slotErrors[slot.name]} +
+
{slotPreview[slot.name]}
+
+ {/if} +
+ {/each} +
+
+ + +
+
+
+{/if} + +{#if configs.length === 0 && !showForm} + + + +{:else} +
+ {#each configs as config} + +
+
+
+ +

{config.name}

+ {#if config.user_id === 0} + System + {/if} + {Object.keys(config.slots).length} slots +
+ {#if config.description} +

{config.description}

+ {/if} +
+
+ clone(config)} /> + edit(config)} /> + remove(config.id)} variant="danger" /> +
+
+
+ {/each} +
+{/if} + +{/if} + + confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> diff --git a/frontend/src/routes/command-trackers/+page.svelte b/frontend/src/routes/command-trackers/+page.svelte index cfbeb8a..291fab4 100644 --- a/frontend/src/routes/command-trackers/+page.svelte +++ b/frontend/src/routes/command-trackers/+page.svelte @@ -210,11 +210,6 @@ - - + toggleEnabled(trk)} disabled={toggling[trk.id]} /> + + +{#if showEmailForm} + + {#if error}
{error}
{/if} +
+
+ +
+ emailForm.icon = v} /> + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+{/if} + +{#if emailBots.length === 0 && !showEmailForm} + + + +{:else} +
+ {#each emailBots as bot} + +
+
+
+ +

{bot.name}

+
+
+ {bot.email} + {bot.smtp_host}:{bot.smtp_port} + {#if bot.smtp_use_tls} + TLS + {/if} +
+
+
+ testEmailBot(bot.id)} disabled={emailTesting[bot.id]} /> + editEmailBot(bot)} /> + removeEmail(bot.id)} variant="danger" /> +
+
+
+ {/each} +
+{/if} + + {/if} confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> + + confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} /> diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index 07778f0..244a0dd 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -39,8 +39,8 @@ const res = await api('/template-configs/preview-date-format', { method: 'POST', body: JSON.stringify({ - date_format: (form as any).date_format, - date_only_format: (form as any).date_only_format, + date_format: form.date_format, + date_only_format: form.date_only_format, }), }); dateFormatPreview = res; @@ -63,7 +63,7 @@ const doValidate = async () => { try { - const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType, date_format: (form as any).date_format, date_only_format: (form as any).date_only_format }) }); + const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType, date_format: form.date_format, date_only_format: form.date_only_format }) }); slotErrors = { ...slotErrors, [slotKey]: res.error || '' }; slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null }; slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' }; @@ -86,8 +86,9 @@ function refreshAllPreviews() { for (const group of templateSlots) { for (const slot of group.slots) { - const template = (form as any)[slot.key]; - if (template && slot.key !== 'date_format' && slot.key !== 'date_only_format') { + if (slot.isDateFormat) continue; + const template = form.slots[slot.key] || ''; + if (template) { validateSlot(slot.key, template, true); } } @@ -97,14 +98,7 @@ const defaultForm = () => ({ provider_type: 'immich', name: '', description: '', icon: '', - message_assets_added: '', - message_assets_removed: '', - message_collection_renamed: '', - message_collection_deleted: '', - message_sharing_changed: '', - periodic_summary_message: '', - scheduled_assets_message: '', - memory_mode_message: '', + slots: {} as Record, date_format: '%d.%m.%Y, %H:%M UTC', date_only_format: '%d.%m.%Y', }); @@ -125,8 +119,8 @@ { key: 'memory_mode_message', label: 'memoryMode', rows: 6 }, ]}, { group: 'settings', slots: [ - { key: 'date_format', label: 'dateFormat', rows: 1 }, - { key: 'date_only_format', label: 'dateOnlyFormat', rows: 1 }, + { key: 'date_format', label: 'dateFormat', rows: 1, isDateFormat: true }, + { key: 'date_only_format', label: 'dateOnlyFormat', rows: 1, isDateFormat: true }, ]}, ]; @@ -142,8 +136,17 @@ } function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); } - function edit(c: any) { - form = { ...defaultForm(), ...c }; editing = c.id; showForm = true; + function edit(c: TemplateConfig) { + form = { + provider_type: c.provider_type, + name: c.name, + description: c.description || '', + icon: c.icon || '', + slots: { ...c.slots }, + date_format: c.date_format || '%d.%m.%Y, %H:%M UTC', + date_only_format: c.date_only_format || '%d.%m.%Y', + }; + editing = c.id; showForm = true; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; setTimeout(() => refreshAllPreviews(), 100); } @@ -158,11 +161,16 @@ } catch (err: any) { error = err.message; snackError(err.message); } } - function clone(c: any) { - form = { ...defaultForm(), ...c, name: `${c.name} (Copy)`, description: c.description || '' }; - delete (form as any).id; - delete (form as any).user_id; - delete (form as any).created_at; + function clone(c: TemplateConfig) { + form = { + provider_type: c.provider_type, + name: `${c.name} (Copy)`, + description: c.description || '', + icon: c.icon || '', + slots: { ...c.slots }, + date_format: c.date_format || '%d.%m.%Y, %H:%M UTC', + date_only_format: c.date_only_format || '%d.%m.%Y', + }; editing = null; showForm = true; slotPreview = {}; @@ -249,9 +257,9 @@ {/if} - {#if slot.key === 'date_format' || slot.key === 'date_only_format'} - { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }} + {#if slot.isDateFormat} + { (form as any)[slot.key] = (e.target as HTMLInputElement).value; clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" /> {#if dateFormatPreview[slot.key]}

{t('templateConfig.preview')}: {dateFormatPreview[slot.key]}

@@ -259,7 +267,7 @@

{t('templateConfig.invalidFormat')}

{/if} {:else} - { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} /> + { form.slots[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} /> {#if slotErrors[slot.key]} {#if slotErrorTypes[slot.key] === 'undefined'}

⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}

diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index 442e0a2..a56135b 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.12" dependencies = [ "aiohttp>=3.9", "jinja2>=3.1", + "aiosmtplib>=3.0", ] [project.optional-dependencies] diff --git a/packages/core/src/notify_bridge_core/notifications/dispatcher.py b/packages/core/src/notify_bridge_core/notifications/dispatcher.py index 68bf1a4..339ea77 100644 --- a/packages/core/src/notify_bridge_core/notifications/dispatcher.py +++ b/packages/core/src/notify_bridge_core/notifications/dispatcher.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any import aiohttp @@ -25,14 +25,16 @@ DEFAULT_TEMPLATE = '{{ event_type }}: "{{ collection_name }}"' class TargetConfig: """Configuration for a notification target.""" - type: str # "telegram" or "webhook" - config: dict[str, Any] # type-specific config + type: str # "telegram", "webhook", or "email" + config: dict[str, Any] # target-level config (bot_token, settings, etc.) 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 + # Broadcast receivers — if non-empty, sends to each receiver instead of config + receivers: list[dict[str, Any]] = field(default_factory=list) class NotificationDispatcher: @@ -69,7 +71,7 @@ class NotificationDispatcher: async def _send_to_target( self, event: ServiceEvent, target: TargetConfig ) -> dict[str, Any]: - """Send event to a single target.""" + """Send event to a single target (potentially multiple receivers).""" # Select template template_str = DEFAULT_TEMPLATE if target.template_slots: @@ -77,7 +79,7 @@ class NotificationDispatcher: if slot: template_str = slot - # Build context and render + # Build context and render ONCE ctx = build_template_context( event, target_type=target.type, date_format=target.date_format, @@ -89,13 +91,14 @@ class NotificationDispatcher: return await self._send_telegram(target, message, event) elif target.type == "webhook": return await self._send_webhook(target, message, event) + elif target.type == "email": + return await self._send_email(target, message, event) return {"success": False, "error": f"Unknown target type: {target.type}"} async def _send_telegram( self, target: TargetConfig, message: str, event: ServiceEvent ) -> dict[str, Any]: bot_token = target.config.get("bot_token") - chat_id = target.config.get("chat_id") disable_preview = target.config.get("disable_url_preview", False) max_media = target.config.get("max_media_to_send", 50) max_group = target.config.get("max_media_per_group", 10) @@ -105,9 +108,29 @@ class NotificationDispatcher: max_size = max_size * 1024 * 1024 # MB to bytes send_large_as_docs = target.config.get("send_large_photos_as_documents", False) - if not bot_token or not chat_id: - return {"success": False, "error": "Missing bot_token or chat_id"} + if not bot_token: + return {"success": False, "error": "Missing bot_token"} + # Resolve receivers — broadcast to each, or fall back to legacy chat_id in config + receivers = target.receivers or [{"chat_id": target.config.get("chat_id")}] + + # Prepare assets list once (shared across receivers) + 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[:max_media]: + url = asset.full_url or asset.thumbnail_url + if url: + asset_type = "video" if asset.type.value == "video" else "photo" + asset_headers = {} + 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}) + + results: list[dict[str, Any]] = [] async with aiohttp.ClientSession() as session: client = TelegramClient( session, bot_token, @@ -115,55 +138,55 @@ class NotificationDispatcher: 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=bool(disable_preview), - ) - if not text_result.get("success"): - return text_result + for receiver in receivers: + chat_id = receiver.get("chat_id") + if not chat_id: + results.append({"success": False, "error": "Missing chat_id in receiver"}) + continue - # Step 2: Send assets as reply to the text message - 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[:max_media]: - url = asset.full_url or asset.thumbnail_url - if url: - asset_type = "video" if asset.type.value == "video" else "photo" - asset_headers = {} - 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}) - - if assets: - reply_to = text_result.get("message_id") - media_result = await client.send_notification( + # Step 1: Send the text message + text_result = await client.send_message( chat_id=str(chat_id), - assets=assets, - reply_to_message_id=reply_to, - max_group_size=max_group, - chunk_delay=chunk_delay, - max_asset_data_size=max_size, - send_large_photos_as_documents=send_large_as_docs, + text=message, + disable_web_page_preview=bool(disable_preview), ) - if not media_result.get("success"): - _LOGGER.warning("Text sent OK but media failed: %s", media_result.get("error")) + if not text_result.get("success"): + _LOGGER.warning("Failed to send to chat %s: %s", chat_id, text_result.get("error")) + results.append(text_result) + continue - return text_result + # Step 2: Send assets as reply + if assets: + reply_to = text_result.get("message_id") + media_result = await client.send_notification( + chat_id=str(chat_id), + assets=assets, + reply_to_message_id=reply_to, + max_group_size=max_group, + chunk_delay=chunk_delay, + max_asset_data_size=max_size, + send_large_photos_as_documents=send_large_as_docs, + ) + if not media_result.get("success"): + _LOGGER.warning("Text sent OK but media failed for chat %s: %s", chat_id, media_result.get("error")) + + results.append(text_result) + + # Return aggregate result + successes = sum(1 for r in results if r.get("success")) + if successes == len(results): + return {"success": True, "receivers": len(results)} + elif successes > 0: + return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes} + elif results: + return results[0] # All failed — return first error + return {"success": False, "error": "No receivers configured"} async def _send_webhook( self, target: TargetConfig, message: str, event: ServiceEvent ) -> dict[str, Any]: - url = target.config.get("url") - headers = target.config.get("headers", {}) - - if not url: - return {"success": False, "error": "Missing url in target config"} + # Resolve receivers — broadcast to each, or fall back to legacy url in config + receivers = target.receivers or [{"url": target.config.get("url"), "headers": target.config.get("headers", {})}] payload = { "message": message, @@ -174,6 +197,68 @@ class NotificationDispatcher: "timestamp": event.timestamp.isoformat(), } + results: list[dict[str, Any]] = [] async with aiohttp.ClientSession() as session: - client = WebhookClient(session, url, headers) - return await client.send(payload) + for receiver in receivers: + url = receiver.get("url") + headers = receiver.get("headers", {}) + if not url: + results.append({"success": False, "error": "Missing url in receiver"}) + continue + client = WebhookClient(session, url, headers) + results.append(await client.send(payload)) + + successes = sum(1 for r in results if r.get("success")) + if successes == len(results): + return {"success": True, "receivers": len(results)} + elif successes > 0: + return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes} + elif results: + return results[0] + return {"success": False, "error": "No receivers configured"} + + async def _send_email( + self, target: TargetConfig, message: str, event: ServiceEvent + ) -> dict[str, Any]: + from .email.client import EmailClient, SmtpConfig + + smtp_cfg = target.config.get("smtp", {}) + if not smtp_cfg.get("host"): + return {"success": False, "error": "SMTP not configured"} + + client = EmailClient(SmtpConfig( + host=smtp_cfg["host"], + port=int(smtp_cfg.get("port", 587)), + username=smtp_cfg.get("username", ""), + password=smtp_cfg.get("password", ""), + from_address=smtp_cfg.get("from_address", ""), + from_name=smtp_cfg.get("from_name", "Notify Bridge"), + use_tls=smtp_cfg.get("use_tls", True), + )) + + # Resolve receivers + receivers = target.receivers or [{"email": target.config.get("email", "")}] + subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}" + + results: list[dict[str, Any]] = [] + for receiver in receivers: + email = receiver.get("email") + if not email: + results.append({"success": False, "error": "Missing email in receiver"}) + continue + result = await client.send( + to_email=email, + subject=subject, + body_text=message, + to_name=receiver.get("name", ""), + ) + results.append(result) + + successes = sum(1 for r in results if r.get("success")) + if successes == len(results) and results: + return {"success": True, "receivers": len(results)} + elif successes > 0: + return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes} + elif results: + return results[0] + return {"success": False, "error": "No receivers configured"} diff --git a/packages/core/src/notify_bridge_core/notifications/email/__init__.py b/packages/core/src/notify_bridge_core/notifications/email/__init__.py new file mode 100644 index 0000000..311f9b7 --- /dev/null +++ b/packages/core/src/notify_bridge_core/notifications/email/__init__.py @@ -0,0 +1,5 @@ +"""Email notification client.""" + +from .client import EmailClient + +__all__ = ["EmailClient"] diff --git a/packages/core/src/notify_bridge_core/notifications/email/client.py b/packages/core/src/notify_bridge_core/notifications/email/client.py new file mode 100644 index 0000000..30a40a3 --- /dev/null +++ b/packages/core/src/notify_bridge_core/notifications/email/client.py @@ -0,0 +1,76 @@ +"""Async email client using aiosmtplib.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Any + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class SmtpConfig: + """SMTP connection settings.""" + + host: str + port: int = 587 + username: str = "" + password: str = "" + from_address: str = "" + from_name: str = "Notify Bridge" + use_tls: bool = True + + +class EmailClient: + """Sends email notifications via SMTP.""" + + def __init__(self, smtp_config: SmtpConfig) -> None: + self._config = smtp_config + + async def send( + self, + to_email: str, + subject: str, + body_text: str, + body_html: str | None = None, + to_name: str = "", + ) -> dict[str, Any]: + """Send an email. Returns {"success": True} or {"success": False, "error": "..."}.""" + try: + import aiosmtplib + except ImportError: + return {"success": False, "error": "aiosmtplib not installed. Run: pip install aiosmtplib"} + + cfg = self._config + + if not cfg.host or not cfg.from_address: + return {"success": False, "error": "SMTP not configured (missing host or from_address)"} + + # Build email message + msg = MIMEMultipart("alternative") + msg["From"] = f"{cfg.from_name} <{cfg.from_address}>" if cfg.from_name else cfg.from_address + msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email + msg["Subject"] = subject + + msg.attach(MIMEText(body_text, "plain", "utf-8")) + if body_html: + msg.attach(MIMEText(body_html, "html", "utf-8")) + + try: + await aiosmtplib.send( + msg, + hostname=cfg.host, + port=cfg.port, + username=cfg.username or None, + password=cfg.password or None, + use_tls=cfg.use_tls, + start_tls=not cfg.use_tls and cfg.port != 25, + ) + _LOGGER.info("Email sent to %s", to_email) + return {"success": True} + except Exception as e: + _LOGGER.error("Failed to send email to %s: %s", to_email, e) + return {"success": False, "error": str(e)} diff --git a/packages/core/src/notify_bridge_core/providers/capabilities.py b/packages/core/src/notify_bridge_core/providers/capabilities.py new file mode 100644 index 0000000..13069e7 --- /dev/null +++ b/packages/core/src/notify_bridge_core/providers/capabilities.py @@ -0,0 +1,107 @@ +"""Provider capability registry. + +Defines what events, template slots, commands, and variables each provider type supports. +Used by the frontend to dynamically show relevant UI elements. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ProviderCapabilities: + """What a provider type supports.""" + + provider_type: str + display_name: str + + # Notification template slots (used in TemplateConfig) + notification_slots: list[dict[str, str]] = field(default_factory=list) + + # Command template slots (used in CommandTemplateConfig) + command_slots: list[dict[str, str]] = field(default_factory=list) + + # Events the provider can generate + events: list[dict[str, str]] = field(default_factory=list) + + # Commands the provider supports + commands: list[dict[str, str]] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Immich provider capabilities +# --------------------------------------------------------------------------- + +IMMICH_CAPABILITIES = ProviderCapabilities( + provider_type="immich", + display_name="Immich", + notification_slots=[ + {"name": "message_assets_added", "description": "New assets added to album"}, + {"name": "message_assets_removed", "description": "Assets removed from album"}, + {"name": "message_collection_renamed", "description": "Album renamed"}, + {"name": "message_collection_deleted", "description": "Album deleted"}, + {"name": "message_sharing_changed", "description": "Sharing status changed"}, + {"name": "periodic_summary_message", "description": "Periodic album summary"}, + {"name": "scheduled_assets_message", "description": "Scheduled asset delivery"}, + {"name": "memory_mode_message", "description": "On This Day memories"}, + ], + command_slots=[ + {"name": "start", "description": "/start greeting message"}, + {"name": "help", "description": "/help command listing"}, + {"name": "status", "description": "/status tracker summary"}, + {"name": "albums", "description": "/albums tracked albums list"}, + {"name": "events", "description": "/events recent events"}, + {"name": "people", "description": "/people detected people"}, + {"name": "search", "description": "/search results (also /find, /person, /place)"}, + {"name": "latest", "description": "/latest recent photos"}, + {"name": "favorites", "description": "/favorites starred items"}, + {"name": "random", "description": "/random random photos"}, + {"name": "summary", "description": "/summary album summary"}, + {"name": "memory", "description": "/memory On This Day photos"}, + {"name": "rate_limited", "description": "Rate limit warning message"}, + {"name": "no_results", "description": "Empty results fallback"}, + ], + events=[ + {"name": "assets_added", "description": "New assets detected in album"}, + {"name": "assets_removed", "description": "Assets removed from album"}, + {"name": "collection_renamed", "description": "Album was renamed"}, + {"name": "collection_deleted", "description": "Album was deleted"}, + {"name": "sharing_changed", "description": "Album sharing status changed"}, + ], + commands=[ + {"name": "status", "description": "Show tracker status"}, + {"name": "albums", "description": "List tracked albums"}, + {"name": "events", "description": "Show recent events"}, + {"name": "summary", "description": "Send album summary"}, + {"name": "latest", "description": "Show latest photos"}, + {"name": "memory", "description": "On This Day memories"}, + {"name": "random", "description": "Random photos"}, + {"name": "search", "description": "Search assets"}, + {"name": "find", "description": "Find assets by name"}, + {"name": "person", "description": "Find photos by person"}, + {"name": "place", "description": "Find photos by location"}, + {"name": "favorites", "description": "Show favorites"}, + {"name": "people", "description": "List detected people"}, + {"name": "help", "description": "Show commands"}, + ], +) + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +_REGISTRY: dict[str, ProviderCapabilities] = { + "immich": IMMICH_CAPABILITIES, +} + + +def get_capabilities(provider_type: str) -> ProviderCapabilities | None: + """Get capabilities for a provider type.""" + return _REGISTRY.get(provider_type) + + +def get_all_capabilities() -> dict[str, ProviderCapabilities]: + """Get all registered provider capabilities.""" + return dict(_REGISTRY) 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 new file mode 100644 index 0000000..85c5e65 --- /dev/null +++ b/packages/server/src/notify_bridge_server/api/command_template_configs.py @@ -0,0 +1,230 @@ +"""Command template configuration CRUD API routes. + +Template content is stored in CommandTemplateSlot child rows (one per slot_name). +Slot names correspond to command names (e.g. 'status', 'help', 'albums'). +""" + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from jinja2.sandbox import SandboxedEnvironment +from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import CommandTemplateConfig, CommandTemplateSlot, User + +_LOGGER = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/command-template-configs", tags=["command-template-configs"]) + + +class CommandTemplateConfigCreate(BaseModel): + provider_type: str + name: str + description: str | None = None + icon: str | None = None + slots: dict[str, str] = {} # slot_name -> template text + + +class CommandTemplateConfigUpdate(BaseModel): + name: str | None = None + description: str | None = None + icon: str | None = None + slots: dict[str, str] | None = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, str]: + result = await session.exec( + select(CommandTemplateSlot).where(CommandTemplateSlot.config_id == config_id) + ) + return {s.slot_name: s.template for s in result.all()} + + +async def _save_slots(session: AsyncSession, config_id: int, slots: dict[str, str]) -> None: + for slot_name, template_text in slots.items(): + result = await session.exec( + select(CommandTemplateSlot).where( + CommandTemplateSlot.config_id == config_id, + CommandTemplateSlot.slot_name == slot_name, + ) + ) + existing = result.first() + if existing: + existing.template = template_text + session.add(existing) + else: + session.add(CommandTemplateSlot( + config_id=config_id, + slot_name=slot_name, + template=template_text, + )) + + +async def _response(session: AsyncSession, c: CommandTemplateConfig) -> dict[str, Any]: + slots = await _load_slots(session, c.id) + return { + "id": c.id, + "user_id": c.user_id, + "provider_type": c.provider_type, + "name": c.name, + "description": c.description, + "icon": c.icon, + "slots": slots, + "created_at": c.created_at.isoformat(), + } + + +async def _get(session: AsyncSession, config_id: int, user_id: int) -> CommandTemplateConfig: + config = await session.get(CommandTemplateConfig, config_id) + if not config or (config.user_id != user_id and config.user_id != 0): + raise HTTPException(status_code=404, detail="Command template config not found") + return config + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@router.get("") +async def list_configs( + provider_type: str | None = None, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + from sqlalchemy import or_ + query = select(CommandTemplateConfig).where( + or_(CommandTemplateConfig.user_id == user.id, CommandTemplateConfig.user_id == 0) + ) + if provider_type: + query = query.where(CommandTemplateConfig.provider_type == provider_type) + result = await session.exec(query) + return [await _response(session, c) for c in result.all()] + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_config( + body: CommandTemplateConfigCreate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = CommandTemplateConfig( + user_id=user.id, + provider_type=body.provider_type, + name=body.name, + description=body.description or "", + icon=body.icon or "", + ) + session.add(config) + await session.flush() + if body.slots: + await _save_slots(session, config.id, body.slots) + await session.commit() + await session.refresh(config) + return await _response(session, config) + + +@router.get("/{config_id}") +async def get_config( + config_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await _get(session, config_id, user.id) + return await _response(session, config) + + +@router.put("/{config_id}") +async def update_config( + config_id: int, + body: CommandTemplateConfigUpdate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await _get(session, config_id, user.id) + for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items(): + if value is not None: + setattr(config, field, value) + session.add(config) + if body.slots is not None: + await _save_slots(session, config.id, body.slots) + await session.commit() + await session.refresh(config) + return await _response(session, config) + + +@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_config( + config_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + config = await _get(session, config_id, user.id) + slot_result = await session.exec( + select(CommandTemplateSlot).where(CommandTemplateSlot.config_id == config.id) + ) + for slot in slot_result.all(): + await session.delete(slot) + await session.delete(config) + await session.commit() + + +class PreviewRequest(BaseModel): + template: str + + +@router.post("/preview-raw") +async def preview_raw( + body: PreviewRequest, + user: User = Depends(get_current_user), +): + """Render arbitrary Jinja2 template text with sample command context.""" + sample_ctx = { + "trackers_active": 2, + "trackers_total": 3, + "total_albums": 5, + "last_event": "2026-03-19 14:30", + "albums": [ + {"name": "Family Photos", "asset_count": 142, "url": "https://example.com/albums/1"}, + {"name": "Vacation 2025", "asset_count": 87, "url": "https://example.com/albums/2"}, + ], + "events": [ + {"type": "assets_added", "album": "Family Photos", "count": 3, "date": "2026-03-19 14:30"}, + {"type": "assets_removed", "album": "Vacation 2025", "count": 1, "date": "2026-03-19 12:00"}, + ], + "people": ["Alice", "Bob", "Charlie"], + "assets": [ + {"filename": "IMG_001.jpg", "type": "IMAGE", "created_at": "2026-03-19T14:30:00"}, + {"filename": "VID_002.mp4", "type": "VIDEO", "created_at": "2026-03-19T15:00:00"}, + ], + "search_query": "sunset", + "search_results_count": 5, + "command": "status", + "bot_name": "NotifyBridgeBot", + "locale": "en", + } + + try: + env = SandboxedEnvironment(autoescape=False) + env.from_string(body.template) + except TemplateSyntaxError as e: + return {"rendered": None, "error": e.message, "error_line": e.lineno} + + try: + strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined) + tmpl = strict_env.from_string(body.template) + rendered = tmpl.render(**sample_ctx) + return {"rendered": rendered} + except UndefinedError as e: + return {"rendered": None, "error": str(e), "error_line": None, "error_type": "undefined"} + except Exception as e: + return {"rendered": None, "error": str(e), "error_line": None} diff --git a/packages/server/src/notify_bridge_server/api/email_bots.py b/packages/server/src/notify_bridge_server/api/email_bots.py new file mode 100644 index 0000000..04ad170 --- /dev/null +++ b/packages/server/src/notify_bridge_server/api/email_bots.py @@ -0,0 +1,148 @@ +"""Email bot management API routes.""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import EmailBot, User + +_LOGGER = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/email-bots", tags=["email-bots"]) + + +class EmailBotCreate(BaseModel): + name: str + icon: str = "" + email: str + smtp_host: str + smtp_port: int = 587 + smtp_username: str = "" + smtp_password: str = "" + smtp_use_tls: bool = True + + +class EmailBotUpdate(BaseModel): + name: str | None = None + icon: str | None = None + email: str | None = None + smtp_host: str | None = None + smtp_port: int | None = None + smtp_username: str | None = None + smtp_password: str | None = None + smtp_use_tls: bool | None = None + + +@router.get("") +async def list_email_bots( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + result = await session.exec( + select(EmailBot).where(EmailBot.user_id == user.id) + ) + return [_response(b) for b in result.all()] + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_email_bot( + body: EmailBotCreate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + bot = EmailBot(user_id=user.id, **body.model_dump()) + session.add(bot) + await session.commit() + await session.refresh(bot) + return _response(bot) + + +@router.get("/{bot_id}") +async def get_email_bot( + bot_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + bot = await _get_user_bot(session, bot_id, user.id) + return _response(bot) + + +@router.put("/{bot_id}") +async def update_email_bot( + bot_id: int, + body: EmailBotUpdate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + bot = await _get_user_bot(session, bot_id, user.id) + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(bot, field, value) + session.add(bot) + await session.commit() + await session.refresh(bot) + return _response(bot) + + +@router.delete("/{bot_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_email_bot( + bot_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + bot = await _get_user_bot(session, bot_id, user.id) + await session.delete(bot) + await session.commit() + + +@router.post("/{bot_id}/test") +async def test_email_bot( + bot_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """Send a test email to the bot's own address to verify SMTP connection.""" + bot = await _get_user_bot(session, bot_id, user.id) + + from notify_bridge_core.notifications.email.client import EmailClient, SmtpConfig + client = EmailClient(SmtpConfig( + host=bot.smtp_host, + port=bot.smtp_port, + username=bot.smtp_username, + password=bot.smtp_password, + from_address=bot.email, + from_name=bot.name, + use_tls=bot.smtp_use_tls, + )) + result = await client.send( + to_email=bot.email, + subject="Notify Bridge — Test Connection", + body_text="This is a test email from Notify Bridge. Your SMTP settings are working correctly.", + ) + return result + + +def _response(bot: EmailBot) -> dict: + return { + "id": bot.id, + "name": bot.name, + "icon": bot.icon, + "email": bot.email, + "smtp_host": bot.smtp_host, + "smtp_port": bot.smtp_port, + "smtp_username": bot.smtp_username, + "smtp_password": "***" if bot.smtp_password else "", + "smtp_use_tls": bot.smtp_use_tls, + "created_at": bot.created_at.isoformat(), + } + + +async def _get_user_bot(session: AsyncSession, bot_id: int, user_id: int) -> EmailBot: + bot = await session.get(EmailBot, bot_id) + if not bot or bot.user_id != user_id: + raise HTTPException(status_code=404, detail="Email bot not found") + return bot diff --git a/packages/server/src/notify_bridge_server/api/notification_tracker_targets.py b/packages/server/src/notify_bridge_server/api/notification_tracker_targets.py index 3e42851..1dd1c6f 100644 --- a/packages/server/src/notify_bridge_server/api/notification_tracker_targets.py +++ b/packages/server/src/notify_bridge_server/api/notification_tracker_targets.py @@ -16,6 +16,7 @@ from ..database.models import ( NotificationTrackerTarget, ServiceProvider, TemplateConfig, + TemplateSlot, TrackingConfig, User, ) @@ -65,7 +66,7 @@ async def create_notification_tracker_target( session: AsyncSession = Depends(get_session), ): """Link a target to a notification tracker with per-link configuration.""" - await _get_user_tracker(session, tracker_id, user.id) + tracker = await _get_user_tracker(session, tracker_id, user.id) # Validate target exists and belongs to user target = await session.get(NotificationTarget, body.target_id) @@ -85,15 +86,30 @@ async def create_notification_tracker_target( detail="Target is already linked to this tracker", ) - # Validate config ownership + # Resolve tracker's provider type for config validation + provider = await session.get(ServiceProvider, tracker.provider_id) + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + + # Validate config ownership + provider type match if body.tracking_config_id: tc = await session.get(TrackingConfig, body.tracking_config_id) if not tc or tc.user_id != user.id: raise HTTPException(status_code=404, detail="Tracking config not found") + if tc.provider_type != provider.type: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Tracking config provider type '{tc.provider_type}' does not match tracker provider '{provider.type}'", + ) if body.template_config_id: tpc = await session.get(TemplateConfig, body.template_config_id) if not tpc or (tpc.user_id != user.id and tpc.user_id != 0): raise HTTPException(status_code=404, detail="Template config not found") + if tpc.provider_type != provider.type: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Template config provider type '{tpc.provider_type}' does not match tracker provider '{provider.type}'", + ) tt = NotificationTrackerTarget(tracker_id=tracker_id, **body.model_dump()) session.add(tt) @@ -111,21 +127,34 @@ async def update_notification_tracker_target( session: AsyncSession = Depends(get_session), ): """Update a notification tracker-target link's configuration.""" - await _get_user_tracker(session, tracker_id, user.id) + tracker = await _get_user_tracker(session, tracker_id, user.id) tt = await session.get(NotificationTrackerTarget, tracker_target_id) if not tt or tt.tracker_id != tracker_id: raise HTTPException(status_code=404, detail="Tracker-target link not found") + provider = await session.get(ServiceProvider, tracker.provider_id) + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") updates = body.model_dump(exclude_unset=True) - # Validate config ownership if being changed + # Validate config ownership + provider type match if being changed if "tracking_config_id" in updates and updates["tracking_config_id"]: tc = await session.get(TrackingConfig, updates["tracking_config_id"]) if not tc or tc.user_id != user.id: raise HTTPException(status_code=404, detail="Tracking config not found") + if tc.provider_type != provider.type: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Tracking config provider type '{tc.provider_type}' does not match tracker provider '{provider.type}'", + ) if "template_config_id" in updates and updates["template_config_id"]: tpc = await session.get(TemplateConfig, updates["template_config_id"]) if not tpc or (tpc.user_id != user.id and tpc.user_id != 0): raise HTTPException(status_code=404, detail="Template config not found") + if tpc.provider_type != provider.type: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Template config provider type '{tpc.provider_type}' does not match tracker provider '{provider.type}'", + ) for field, value in updates.items(): setattr(tt, field, value) @@ -183,15 +212,24 @@ async def test_notification_tracker_target( # For periodic/scheduled/memory — fetch real data from provider template_config = None + template_str = "" if tt.template_config_id: template_config = await session.get(TemplateConfig, tt.template_config_id) - - slot_map = { - "periodic": "periodic_summary_message", - "scheduled": "scheduled_assets_message", - "memory": "memory_mode_message", - } - template_str = getattr(template_config, slot_map[test_type], "") if template_config else "" + if template_config: + slot_map = { + "periodic": "periodic_summary_message", + "scheduled": "scheduled_assets_message", + "memory": "memory_mode_message", + } + slot_name = slot_map[test_type] + slot_result = await session.exec( + select(TemplateSlot).where( + TemplateSlot.config_id == template_config.id, + TemplateSlot.slot_name == slot_name, + ) + ) + slot = slot_result.first() + template_str = slot.template if slot else "" # Load provider and tracker data eagerly before aiohttp context provider = await session.get(ServiceProvider, tracker.provider_id) diff --git a/packages/server/src/notify_bridge_server/api/providers.py b/packages/server/src/notify_bridge_server/api/providers.py index a3215b9..c9ccbe2 100644 --- a/packages/server/src/notify_bridge_server/api/providers.py +++ b/packages/server/src/notify_bridge_server/api/providers.py @@ -94,6 +94,40 @@ async def create_provider( return _provider_response(provider) +@router.get("/capabilities") +async def list_provider_capabilities(): + """List capabilities for all registered provider types.""" + from notify_bridge_core.providers.capabilities import get_all_capabilities + result = {} + for pt, caps in get_all_capabilities().items(): + result[pt] = { + "provider_type": caps.provider_type, + "display_name": caps.display_name, + "notification_slots": caps.notification_slots, + "command_slots": caps.command_slots, + "events": caps.events, + "commands": caps.commands, + } + return result + + +@router.get("/capabilities/{provider_type}") +async def get_provider_capabilities(provider_type: str): + """Get capabilities for a provider type (events, slots, commands).""" + from notify_bridge_core.providers.capabilities import get_capabilities + caps = get_capabilities(provider_type) + if not caps: + raise HTTPException(status_code=404, detail=f"Unknown provider type: {provider_type}") + return { + "provider_type": caps.provider_type, + "display_name": caps.display_name, + "notification_slots": caps.notification_slots, + "command_slots": caps.command_slots, + "events": caps.events, + "commands": caps.commands, + } + + @router.get("/{provider_id}") async def get_provider( provider_id: int, diff --git a/packages/server/src/notify_bridge_server/api/target_receivers.py b/packages/server/src/notify_bridge_server/api/target_receivers.py new file mode 100644 index 0000000..d145b13 --- /dev/null +++ b/packages/server/src/notify_bridge_server/api/target_receivers.py @@ -0,0 +1,147 @@ +"""Target receiver management API routes (nested under targets).""" + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import NotificationTarget, TargetReceiver, User + +_LOGGER = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/targets/{target_id}/receivers", tags=["target-receivers"]) + + +class ReceiverCreate(BaseModel): + name: str = "" + config: dict[str, Any] = {} + enabled: bool = True + + +class ReceiverUpdate(BaseModel): + name: str | None = None + config: dict[str, Any] | None = None + enabled: bool | None = None + + +def _receiver_key(target_type: str, config: dict[str, Any]) -> str: + """Derive a unique key for deduplication from receiver config.""" + if target_type == "telegram": + return str(config.get("chat_id", "")) + elif target_type == "webhook": + return config.get("url", "") + elif target_type == "email": + return config.get("email", "") + return "" + + +@router.get("") +async def list_receivers( + target_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + target = await _get_user_target(session, target_id, user.id) + result = await session.exec( + select(TargetReceiver).where(TargetReceiver.target_id == target.id) + ) + return [_response(r) for r in result.all()] + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_receiver( + target_id: int, + body: ReceiverCreate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + target = await _get_user_target(session, target_id, user.id) + key = _receiver_key(target.type, body.config) + if not key: + raise HTTPException(status_code=400, detail="Receiver config must include a delivery endpoint (chat_id, url, or email)") + + # Check for duplicate + existing = await session.exec( + select(TargetReceiver).where( + TargetReceiver.target_id == target.id, + TargetReceiver.receiver_key == key, + ) + ) + if existing.first(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Receiver already exists for this target") + + receiver = TargetReceiver( + target_id=target.id, + name=body.name, + config=body.config, + receiver_key=key, + enabled=body.enabled, + ) + session.add(receiver) + await session.commit() + await session.refresh(receiver) + return _response(receiver) + + +@router.put("/{receiver_id}") +async def update_receiver( + target_id: int, + receiver_id: int, + body: ReceiverUpdate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + await _get_user_target(session, target_id, user.id) + receiver = await session.get(TargetReceiver, receiver_id) + if not receiver or receiver.target_id != target_id: + raise HTTPException(status_code=404, detail="Receiver not found") + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(receiver, field, value) + # Update receiver_key if config changed + if body.config is not None: + target = await session.get(NotificationTarget, target_id) + receiver.receiver_key = _receiver_key(target.type, receiver.config) + session.add(receiver) + await session.commit() + await session.refresh(receiver) + return _response(receiver) + + +@router.delete("/{receiver_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_receiver( + target_id: int, + receiver_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + await _get_user_target(session, target_id, user.id) + receiver = await session.get(TargetReceiver, receiver_id) + if not receiver or receiver.target_id != target_id: + raise HTTPException(status_code=404, detail="Receiver not found") + await session.delete(receiver) + await session.commit() + + +def _response(r: TargetReceiver) -> dict: + return { + "id": r.id, + "target_id": r.target_id, + "name": r.name, + "config": dict(r.config), + "receiver_key": r.receiver_key, + "enabled": r.enabled, + "created_at": r.created_at.isoformat(), + } + + +async def _get_user_target(session: AsyncSession, target_id: int, user_id: int) -> NotificationTarget: + target = await session.get(NotificationTarget, target_id) + if not target or target.user_id != user_id: + raise HTTPException(status_code=404, detail="Target not found") + return target diff --git a/packages/server/src/notify_bridge_server/api/targets.py b/packages/server/src/notify_bridge_server/api/targets.py index 21166a6..de21113 100644 --- a/packages/server/src/notify_bridge_server/api/targets.py +++ b/packages/server/src/notify_bridge_server/api/targets.py @@ -10,7 +10,7 @@ from typing import Any from ..auth.dependencies import get_current_user from ..database.engine import get_session -from ..database.models import NotificationTarget, NotificationTrackerTarget, TelegramBot, TelegramChat, User +from ..database.models import NotificationTarget, NotificationTrackerTarget, TargetReceiver, TelegramBot, TelegramChat, User from ..services.notifier import send_test_notification _LOGGER = logging.getLogger(__name__) @@ -61,7 +61,15 @@ async def list_targets( if chat: chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or "" - return [_target_response(t, chat_names) for t in targets] + # Load receiver counts + receiver_counts: dict[int, int] = {} + for tgt in targets: + recv_result = await session.exec( + select(TargetReceiver).where(TargetReceiver.target_id == tgt.id) + ) + receiver_counts[tgt.id] = len(recv_result.all()) + + return [_target_response(t, chat_names, receiver_counts.get(t.id, 0)) for t in targets] @router.post("", status_code=status.HTTP_201_CREATED) @@ -71,10 +79,10 @@ async def create_target( session: AsyncSession = Depends(get_session), ): """Create a new notification target.""" - if body.type not in ("telegram", "webhook"): + if body.type not in ("telegram", "webhook", "email"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Type must be 'telegram' or 'webhook'", + detail="Type must be 'telegram', 'webhook', or 'email'", ) target = NotificationTarget( user_id=user.id, @@ -124,7 +132,7 @@ async def delete_target( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - """Delete a notification target and its tracker links.""" + """Delete a notification target, its tracker links, and receivers.""" target = await _get_user_target(session, target_id, user.id) # Delete associated tracker-target links result = await session.exec( @@ -132,6 +140,12 @@ async def delete_target( ) for tt in result.all(): await session.delete(tt) + # Delete receivers + recv_result = await session.exec( + select(TargetReceiver).where(TargetReceiver.target_id == target_id) + ) + for r in recv_result.all(): + await session.delete(r) await session.delete(target) await session.commit() @@ -149,7 +163,7 @@ async def test_target( return result -def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None) -> dict: +def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None, receiver_count: int = 0) -> dict: resp = { "id": target.id, "type": target.type, @@ -157,6 +171,7 @@ def _target_response(target: NotificationTarget, chat_names: dict[str, str] | No "icon": target.icon, "config": _safe_config(target), "chat_action": target.chat_action, + "receiver_count": receiver_count, "created_at": target.created_at.isoformat(), } # Attach resolved chat name for telegram targets diff --git a/packages/server/src/notify_bridge_server/api/template_configs.py b/packages/server/src/notify_bridge_server/api/template_configs.py index efbdb8c..7eb09be 100644 --- a/packages/server/src/notify_bridge_server/api/template_configs.py +++ b/packages/server/src/notify_bridge_server/api/template_configs.py @@ -1,6 +1,11 @@ -"""Template configuration CRUD API routes.""" +"""Template configuration CRUD API routes. + +Template content is stored in TemplateSlot child rows (one per slot_name). +The API exposes slots as a flat dict in create/update/response payloads. +""" import logging +from typing import Any from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel @@ -12,7 +17,7 @@ from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined from ..auth.dependencies import get_current_user from ..database.engine import get_session -from ..database.models import TemplateConfig, User +from ..database.models import TemplateConfig, TemplateSlot, User from ..services.sample_context import _SAMPLE_CONTEXT _LOGGER = logging.getLogger(__name__) @@ -25,21 +30,83 @@ class TemplateConfigCreate(BaseModel): name: str description: str | None = None icon: str | None = None - message_assets_added: str | None = None - message_assets_removed: str | None = None - message_collection_renamed: str | None = None - message_collection_deleted: str | None = None - message_sharing_changed: str | None = None - periodic_summary_message: str | None = None - scheduled_assets_message: str | None = None - memory_mode_message: str | None = None date_format: str | None = None date_only_format: str | None = None + slots: dict[str, str] = {} # slot_name -> template text -TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional +class TemplateConfigUpdate(BaseModel): + name: str | None = None + description: str | None = None + icon: str | None = None + date_format: str | None = None + date_only_format: str | None = None + slots: dict[str, str] | None = None # partial update: only provided slots change +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, str]: + """Load all template slots for a config as a dict.""" + result = await session.exec( + select(TemplateSlot).where(TemplateSlot.config_id == config_id) + ) + return {s.slot_name: s.template for s in result.all()} + + +async def _save_slots( + session: AsyncSession, config_id: int, slots: dict[str, str] +) -> None: + """Create or update template slots for a config.""" + for slot_name, template_text in slots.items(): + result = await session.exec( + select(TemplateSlot).where( + TemplateSlot.config_id == config_id, + TemplateSlot.slot_name == slot_name, + ) + ) + existing = result.first() + if existing: + existing.template = template_text + session.add(existing) + else: + session.add(TemplateSlot( + config_id=config_id, + slot_name=slot_name, + template=template_text, + )) + + +async def _response(session: AsyncSession, c: TemplateConfig) -> dict[str, Any]: + """Build API response dict for a TemplateConfig, including its slots.""" + slots = await _load_slots(session, c.id) + return { + "id": c.id, + "user_id": c.user_id, + "provider_type": c.provider_type, + "name": c.name, + "description": c.description, + "icon": c.icon, + "date_format": c.date_format, + "date_only_format": c.date_only_format, + "slots": slots, + "created_at": c.created_at.isoformat(), + } + + +async def _get(session: AsyncSession, config_id: int, user_id: int) -> TemplateConfig: + config = await session.get(TemplateConfig, config_id) + if not config or (config.user_id != user_id and config.user_id != 0): + raise HTTPException(status_code=404, detail="Template config not found") + return config + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + @router.get("") async def list_configs( provider_type: str | None = None, @@ -53,7 +120,7 @@ async def list_configs( if provider_type: query = query.where(TemplateConfig.provider_type == provider_type) result = await session.exec(query) - return [_response(c) for c in result.all()] + return [await _response(session, c) for c in result.all()] @router.get("/variables") @@ -180,12 +247,22 @@ async def create_config( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - data = {k: v for k, v in body.model_dump().items() if v is not None} - config = TemplateConfig(user_id=user.id, **data) + config = TemplateConfig( + user_id=user.id, + provider_type=body.provider_type, + name=body.name, + description=body.description or "", + icon=body.icon or "", + date_format=body.date_format or "%d.%m.%Y, %H:%M UTC", + date_only_format=body.date_only_format or "%d.%m.%Y", + ) session.add(config) + await session.flush() # get config.id + if body.slots: + await _save_slots(session, config.id, body.slots) await session.commit() await session.refresh(config) - return _response(config) + return await _response(session, config) @router.get("/{config_id}") @@ -194,7 +271,8 @@ async def get_config( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - return _response(await _get(session, config_id, user.id)) + config = await _get(session, config_id, user.id) + return await _response(session, config) @router.put("/{config_id}") @@ -205,13 +283,15 @@ async def update_config( session: AsyncSession = Depends(get_session), ): config = await _get(session, config_id, user.id) - for field, value in body.model_dump(exclude_unset=True).items(): + for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items(): if value is not None: setattr(config, field, value) session.add(config) + if body.slots is not None: + await _save_slots(session, config.id, body.slots) await session.commit() await session.refresh(config) - return _response(config) + return await _response(session, config) @router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -221,6 +301,12 @@ async def delete_config( session: AsyncSession = Depends(get_session), ): config = await _get(session, config_id, user.id) + # Delete child slots first + slot_result = await session.exec( + select(TemplateSlot).where(TemplateSlot.config_id == config.id) + ) + for slot in slot_result.all(): + await session.delete(slot) await session.delete(config) await session.commit() @@ -234,9 +320,10 @@ async def preview_config( ): """Render a specific template slot with sample data.""" config = await _get(session, config_id, user.id) - template_body = getattr(config, slot, None) - if template_body is None: - raise HTTPException(status_code=400, detail=f"Unknown slot: {slot}") + slots = await _load_slots(session, config.id) + template_body = slots.get(slot, "") + if not template_body: + raise HTTPException(status_code=400, detail=f"Slot '{slot}' has no template") try: env = SandboxedEnvironment(autoescape=False) tmpl = env.from_string(template_body) @@ -320,17 +407,3 @@ async def preview_raw( return {"rendered": None, "error": str(e), "error_line": None, "error_type": "undefined"} except Exception as e: return {"rendered": None, "error": str(e), "error_line": None} - - -def _response(c: TemplateConfig) -> dict: - return {k: getattr(c, k) for k in TemplateConfig.model_fields if k not in ("user_id", "created_at")} | { - "user_id": c.user_id, - "created_at": c.created_at.isoformat(), - } - - -async def _get(session: AsyncSession, config_id: int, user_id: int) -> TemplateConfig: - config = await session.get(TemplateConfig, config_id) - if not config or (config.user_id != user_id and config.user_id != 0): - raise HTTPException(status_code=404, detail="Template config not found") - return config diff --git a/packages/server/src/notify_bridge_server/commands/handler.py b/packages/server/src/notify_bridge_server/commands/handler.py index a2b9090..b0fc5e8 100644 --- a/packages/server/src/notify_bridge_server/commands/handler.py +++ b/packages/server/src/notify_bridge_server/commands/handler.py @@ -17,6 +17,8 @@ from ..database.engine import get_engine from ..services import make_immich_provider from ..database.models import ( CommandConfig, + CommandTemplateConfig, + CommandTemplateSlot, CommandTracker, CommandTrackerListener, EventLog, @@ -51,6 +53,23 @@ def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int return None +def _render_cmd_template( + templates: dict[str, str], slot_name: str, context: dict[str, Any] +) -> str | None: + """Try to render a command template. Returns None if no template or error.""" + template_str = templates.get(slot_name) + if not template_str: + return None + try: + from jinja2.sandbox import SandboxedEnvironment + env = SandboxedEnvironment(autoescape=False) + tmpl = env.from_string(template_str) + return tmpl.render(**context) + except Exception as e: + _LOGGER.warning("Failed to render command template '%s': %s", slot_name, e) + return None + + async def _resolve_command_context( bot: TelegramBot, ) -> list[tuple[CommandTracker, CommandConfig, ServiceProvider]]: @@ -87,7 +106,20 @@ async def _resolve_command_context( continue tuples.append((tracker, config, provider)) - return tuples + # Load command template slots from the first config that has one + cmd_template_slots: dict[str, str] = {} + for _, config, _ in tuples: + if config.command_template_config_id: + slot_result = await session.exec( + select(CommandTemplateSlot).where( + CommandTemplateSlot.config_id == config.command_template_config_id + ) + ) + cmd_template_slots = {s.slot_name: s.template for s in slot_result.all()} + if cmd_template_slots: + break + + return tuples, cmd_template_slots def _merge_command_context( @@ -125,10 +157,13 @@ async def handle_command( if not cmd: return None - ctx = await _resolve_command_context(bot) - enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx) + ctx_tuples, cmd_templates = await _resolve_command_context(bot) + enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples) if cmd == "start": + result = _render_cmd_template(cmd_templates, "start", {"locale": locale, "bot_name": bot.name}) + if result: + return result msgs = { "en": "Hi! I'm your Notify Bridge bot. Use /help to see available commands.", "ru": "Привет! Я бот Notify Bridge. Используйте /help для списка команд.", @@ -141,6 +176,9 @@ async def handle_command( # Rate limit check wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits) if wait is not None: + result = _render_cmd_template(cmd_templates, "rate_limited", {"wait": wait, "locale": locale}) + if result: + return result msgs = { "en": f"Please wait {wait}s before using this command again.", "ru": f"Подождите {wait} сек. перед повторным использованием.", @@ -151,7 +189,7 @@ async def handle_command( # Build providers map from command context providers_map: dict[int, ServiceProvider] = {} - for _, _, provider in ctx: + for _, _, provider in ctx_tuples: providers_map[provider.id] = provider # Dispatch diff --git a/packages/server/src/notify_bridge_server/database/migrations.py b/packages/server/src/notify_bridge_server/database/migrations.py index 5ad02a3..9df96a2 100644 --- a/packages/server/src/notify_bridge_server/database/migrations.py +++ b/packages/server/src/notify_bridge_server/database/migrations.py @@ -105,6 +105,14 @@ async def migrate_schema(engine: AsyncEngine) -> None: ) logger.info("Added update_mode column to telegram_bot table") + # Add command_template_config_id to command_config if missing + if await _has_table(conn, "command_config"): + if not await _has_column(conn, "command_config", "command_template_config_id"): + await conn.execute( + text("ALTER TABLE command_config ADD COLUMN command_template_config_id INTEGER") + ) + logger.info("Added command_template_config_id column to command_config table") + # Add date_only_format to template_config if missing if await _has_table(conn, "template_config"): if not await _has_column(conn, "template_config", "date_only_format"): @@ -537,3 +545,171 @@ async def migrate_entity_refactor(engine: AsyncEngine) -> None: # or notification_tracker_target. SQLite doesn't support DROP COLUMN in # all versions, and SQLModel will simply ignore columns not defined on # the model class. The columns will remain in the DB but are unused. + + +# --------------------------------------------------------------------------- +# Template slot migration +# --------------------------------------------------------------------------- + +# Old column names that existed on template_config before the slot refactor +_LEGACY_TEMPLATE_COLUMNS = [ + "message_assets_added", + "message_assets_removed", + "message_collection_renamed", + "message_collection_deleted", + "message_sharing_changed", + "periodic_summary_message", + "scheduled_assets_message", + "memory_mode_message", +] + + +async def migrate_template_slots(engine: AsyncEngine) -> None: + """Migrate legacy TemplateConfig column-based templates to TemplateSlot rows. + + Reads the old per-column template values via raw SQL (since they're no longer + on the SQLModel class) and inserts them as TemplateSlot rows. + Idempotent: skips if template_slot table already has data or legacy columns + don't exist. + """ + async with engine.begin() as conn: + if not await _has_table(conn, "template_config"): + return + + # Check if the legacy columns still exist in the DB + has_legacy = await _has_column(conn, "template_config", "message_assets_added") + if not has_legacy: + logger.debug("No legacy template columns found — skipping slot migration") + return + + # Check if template_slot table exists and already has data + if await _has_table(conn, "template_slot"): + slot_count = (await conn.execute(text("SELECT COUNT(*) FROM template_slot"))).scalar() + if slot_count and slot_count > 0: + logger.debug("template_slot table already has %d rows — skipping migration", slot_count) + return + + # Create template_slot table if it doesn't exist yet + # (SQLModel.metadata.create_all may have already created it, but be safe) + if not await _has_table(conn, "template_slot"): + await conn.execute(text( + "CREATE TABLE template_slot (" + " id INTEGER PRIMARY KEY," + " config_id INTEGER NOT NULL REFERENCES template_config(id)," + " slot_name TEXT NOT NULL," + " template TEXT DEFAULT ''," + " UNIQUE(config_id, slot_name)" + ")" + )) + logger.info("Created template_slot table") + + # Read all template configs with their legacy column values + col_list = ", ".join(_LEGACY_TEMPLATE_COLUMNS) + rows = (await conn.execute( + text(f"SELECT id, {col_list} FROM template_config") + )).fetchall() + + migrated = 0 + for row in rows: + config_id = row[0] + for i, col_name in enumerate(_LEGACY_TEMPLATE_COLUMNS): + template_text = row[i + 1] or "" + if template_text.strip(): + await conn.execute( + text( + "INSERT INTO template_slot (config_id, slot_name, template) " + "VALUES (:cid, :sn, :tmpl)" + ), + {"cid": config_id, "sn": col_name, "tmpl": template_text}, + ) + migrated += 1 + + if migrated: + logger.info("Migrated %d template slots from legacy columns", migrated) + + +# --------------------------------------------------------------------------- +# Target receiver migration +# --------------------------------------------------------------------------- + +async def migrate_target_receivers(engine: AsyncEngine) -> None: + """Migrate single chat_id/url from NotificationTarget.config to TargetReceiver rows. + + For each existing target that has a chat_id or url in its config JSON and + no receivers yet, creates a TargetReceiver row. + Idempotent: skips targets that already have receivers. + """ + async with engine.begin() as conn: + if not await _has_table(conn, "notification_target"): + return + + # Create target_receiver table if it doesn't exist yet + if not await _has_table(conn, "target_receiver"): + await conn.execute(text( + "CREATE TABLE target_receiver (" + " id INTEGER PRIMARY KEY," + " target_id INTEGER NOT NULL REFERENCES notification_target(id)," + " name TEXT DEFAULT ''," + " config TEXT DEFAULT '{}'," + " receiver_key TEXT DEFAULT ''," + " enabled INTEGER DEFAULT 1," + " created_at TIMESTAMP," + " UNIQUE(target_id, receiver_key)" + ")" + )) + logger.info("Created target_receiver table") + + # Check if any receivers already exist + if await _has_table(conn, "target_receiver"): + recv_count = (await conn.execute(text("SELECT COUNT(*) FROM target_receiver"))).scalar() + if recv_count and recv_count > 0: + logger.debug("target_receiver already has %d rows — skipping migration", recv_count) + return + + # Read all targets + targets = (await conn.execute( + text("SELECT id, type, config FROM notification_target") + )).fetchall() + + migrated = 0 + for row in targets: + target_id, target_type, raw_config = row[0], row[1], row[2] + try: + cfg = json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {}) + except (json.JSONDecodeError, TypeError): + cfg = {} + + receiver_key = "" + receiver_config = {} + receiver_name = "" + + if target_type == "telegram": + chat_id = cfg.get("chat_id", "") + if chat_id: + receiver_key = str(chat_id) + receiver_config = {"chat_id": str(chat_id)} + receiver_name = f"Chat {chat_id}" + elif target_type == "webhook": + url = cfg.get("url", "") + if url: + receiver_key = url + receiver_config = {"url": url, "headers": cfg.get("headers", {})} + receiver_name = url[:50] + + if receiver_key: + await conn.execute( + text( + "INSERT INTO target_receiver (target_id, name, config, receiver_key, enabled, created_at) " + "VALUES (:tid, :name, :cfg, :rk, 1, CURRENT_TIMESTAMP)" + ), + { + "tid": target_id, + "name": receiver_name, + "cfg": json.dumps(receiver_config), + "rk": receiver_key, + }, + ) + migrated += 1 + + if migrated: + logger.info("Migrated %d target receivers from legacy config", migrated) diff --git a/packages/server/src/notify_bridge_server/database/models.py b/packages/server/src/notify_bridge_server/database/models.py index 1b1a317..551e5e7 100644 --- a/packages/server/src/notify_bridge_server/database/models.py +++ b/packages/server/src/notify_bridge_server/database/models.py @@ -6,7 +6,7 @@ from datetime import datetime, timezone from typing import Any from uuid import uuid4 -from sqlalchemy import UniqueConstraint +from sqlalchemy import UniqueConstraint, Text from sqlmodel import JSON, Column, Field, SQLModel @@ -53,6 +53,24 @@ class TelegramBot(SQLModel, table=True): created_at: datetime = Field(default_factory=_utcnow) +class EmailBot(SQLModel, table=True): + """Email sender — SMTP connection for sending email notifications.""" + + __tablename__ = "email_bot" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + name: str + icon: str = Field(default="") + email: str # From address + smtp_host: str + smtp_port: int = Field(default=587) + smtp_username: str = Field(default="") + smtp_password: str = Field(default="") + smtp_use_tls: bool = Field(default=True) + created_at: datetime = Field(default_factory=_utcnow) + + class TelegramChat(SQLModel, table=True): __tablename__ = "telegram_chat" @@ -124,7 +142,10 @@ class TrackingConfig(SQLModel, table=True): class TemplateConfig(SQLModel, table=True): - """Jinja2 message templates. Tied to a provider type.""" + """Jinja2 message templates. Tied to a provider type. + + Template content is stored in TemplateSlot child rows (one per slot). + """ __tablename__ = "template_config" @@ -135,32 +156,41 @@ class TemplateConfig(SQLModel, table=True): description: str = Field(default="") icon: str = Field(default="") - # Event-driven notification templates - message_assets_added: str = Field(default="") - message_assets_removed: str = Field(default="") - message_collection_renamed: str = Field(default="") - message_collection_deleted: str = Field(default="") - message_sharing_changed: str = Field(default="") - - # Scheduled notification templates - periodic_summary_message: str = Field(default="") - scheduled_assets_message: str = Field(default="") - memory_mode_message: str = Field(default="") - date_format: str = Field(default="%d.%m.%Y, %H:%M UTC") date_only_format: str = Field(default="%d.%m.%Y") created_at: datetime = Field(default_factory=_utcnow) +class TemplateSlot(SQLModel, table=True): + """One Jinja2 template for a specific slot within a TemplateConfig. + + Slot names are provider-specific (e.g. 'message_assets_added' for Immich). + """ + + __tablename__ = "template_slot" + __table_args__ = ( + UniqueConstraint("config_id", "slot_name", name="uq_template_slot"), + ) + + id: int | None = Field(default=None, primary_key=True) + config_id: int = Field(foreign_key="template_config.id", index=True) + slot_name: str + template: str = Field(default="", sa_column=Column(Text, default="")) + + class NotificationTarget(SQLModel, table=True): - """Where to send notifications. Pure delivery endpoint.""" + """Where to send notifications. Pure delivery endpoint. + + Target-level config holds connection/display settings (e.g. bot_token, + disable_url_preview). Actual delivery endpoints live in TargetReceiver rows. + """ __tablename__ = "notification_target" id: int | None = Field(default=None, primary_key=True) user_id: int = Field(foreign_key="user.id") - type: str # "telegram" or "webhook" + type: str # "telegram", "webhook", or "email" name: str icon: str = Field(default="") config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) @@ -168,6 +198,28 @@ class NotificationTarget(SQLModel, table=True): created_at: datetime = Field(default_factory=_utcnow) +class TargetReceiver(SQLModel, table=True): + """One delivery endpoint within a NotificationTarget (broadcast support). + + For Telegram: config = {"chat_id": "12345"} + For Webhook: config = {"url": "https://...", "headers": {...}} + For Email: config = {"email": "user@example.com", "name": "..."} + """ + + __tablename__ = "target_receiver" + __table_args__ = ( + UniqueConstraint("target_id", "receiver_key", name="uq_target_receiver"), + ) + + id: int | None = Field(default=None, primary_key=True) + target_id: int = Field(foreign_key="notification_target.id", index=True) + name: str = Field(default="") + config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + receiver_key: str = Field(default="") # dedup key (e.g. chat_id, url, email) + enabled: bool = Field(default=True) + created_at: datetime = Field(default_factory=_utcnow) + + class NotificationTracker(SQLModel, table=True): """Watches a provider's collections for changes.""" @@ -246,9 +298,43 @@ class CommandConfig(SQLModel, table=True): response_mode: str = Field(default="media") # "media" or "text" default_count: int = Field(default=5) rate_limits: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + command_template_config_id: int | None = Field( + default=None, foreign_key="command_template_config.id" + ) created_at: datetime = Field(default_factory=_utcnow) +class CommandTemplateConfig(SQLModel, table=True): + """Jinja2 templates for command responses. Provider-specific via slots.""" + + __tablename__ = "command_template_config" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(default=0) # 0 = system-owned + provider_type: str + name: str + description: str = Field(default="") + icon: str = Field(default="") + created_at: datetime = Field(default_factory=_utcnow) + + +class CommandTemplateSlot(SQLModel, table=True): + """One Jinja2 template for a specific command response slot. + + Slot names match command names (e.g. 'status', 'help', 'albums'). + """ + + __tablename__ = "command_template_slot" + __table_args__ = ( + UniqueConstraint("config_id", "slot_name", name="uq_command_template_slot"), + ) + + id: int | None = Field(default=None, primary_key=True) + config_id: int = Field(foreign_key="command_template_config.id", index=True) + slot_name: str + template: str = Field(default="", sa_column=Column(Text, default="")) + + class CommandTracker(SQLModel, table=True): """Links a provider to a command config for interactive bot commands.""" diff --git a/packages/server/src/notify_bridge_server/main.py b/packages/server/src/notify_bridge_server/main.py index 4bd1d78..aaf5010 100644 --- a/packages/server/src/notify_bridge_server/main.py +++ b/packages/server/src/notify_bridge_server/main.py @@ -20,13 +20,16 @@ from .api.notification_tracker_targets import router as notification_tracker_tar from .api.tracking_configs import router as tracking_configs_router from .api.template_configs import router as template_configs_router from .api.targets import router as targets_router +from .api.target_receivers import router as target_receivers_router from .api.telegram_bots import router as telegram_bots_router +from .api.email_bots import router as email_bots_router from .api.users import router as users_router from .api.status import router as status_router from .api.template_vars import router as template_vars_router from .api.app_settings import router as app_settings_router from .api.command_configs import router as command_configs_router from .api.command_trackers import router as command_trackers_router +from .api.command_template_configs import router as command_template_configs_router from .commands.webhook import router as webhook_router, set_webhook_secret @@ -35,11 +38,13 @@ async def lifespan(app: FastAPI): await init_db() # Run data migrations (idempotent) from .database.engine import get_engine - from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor + from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers engine = get_engine() await migrate_schema(engine) await migrate_tracker_targets(engine) await migrate_entity_refactor(engine) + await migrate_template_slots(engine) + await migrate_target_receivers(engine) await _seed_default_templates() # Configure webhook secret from DB setting (falls back to env var) from sqlmodel.ext.asyncio.session import AsyncSession as _AS @@ -63,12 +68,15 @@ app.include_router(notification_tracker_targets_router) app.include_router(tracking_configs_router) app.include_router(template_configs_router) app.include_router(targets_router) +app.include_router(target_receivers_router) app.include_router(telegram_bots_router) +app.include_router(email_bots_router) app.include_router(users_router) app.include_router(status_router) app.include_router(app_settings_router) app.include_router(command_configs_router) app.include_router(command_trackers_router) +app.include_router(command_template_configs_router) app.include_router(webhook_router) @@ -78,11 +86,14 @@ async def health(): async def _seed_default_templates(): - """Seed or update default (system-owned) templates on startup.""" + """Seed or update default (system-owned) templates on startup. + + Uses TemplateSlot child rows for template content. + """ from sqlmodel import func, select from sqlmodel.ext.asyncio.session import AsyncSession from .database.engine import get_engine - from .database.models import TemplateConfig + from .database.models import TemplateConfig, TemplateSlot from notify_bridge_core.templates.defaults import load_default_templates engine = get_engine() @@ -102,9 +113,15 @@ async def _seed_default_templates(): provider_type="immich", name=name, description=f"Default Immich templates ({locale.upper()})", - **slots, ) session.add(config) + await session.flush() # get config.id + for slot_name, template_text in slots.items(): + session.add(TemplateSlot( + config_id=config.id, + slot_name=slot_name, + template=template_text, + )) else: # Update existing system-owned templates from files result = await session.exec( @@ -116,9 +133,24 @@ async def _seed_default_templates(): slots = load_default_templates(locale) if not slots: continue - for key, value in slots.items(): - setattr(config, key, value) - session.add(config) + for slot_name, template_text in slots.items(): + # Upsert: find existing slot or create new + slot_result = await session.exec( + select(TemplateSlot).where( + TemplateSlot.config_id == config.id, + TemplateSlot.slot_name == slot_name, + ) + ) + existing = slot_result.first() + if existing: + existing.template = template_text + session.add(existing) + else: + session.add(TemplateSlot( + config_id=config.id, + slot_name=slot_name, + template=template_text, + )) await session.commit() diff --git a/packages/server/src/notify_bridge_server/services/notifier.py b/packages/server/src/notify_bridge_server/services/notifier.py index 148b434..d8d508f 100644 --- a/packages/server/src/notify_bridge_server/services/notifier.py +++ b/packages/server/src/notify_bridge_server/services/notifier.py @@ -5,7 +5,11 @@ from typing import Any import aiohttp -from ..database.models import NotificationTarget +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..database.engine import get_engine +from ..database.models import NotificationTarget, TargetReceiver _LOGGER = logging.getLogger(__name__) @@ -26,52 +30,162 @@ def _get_test_message(locale: str, target_type: str) -> str: return msgs.get(target_type, msgs.get("webhook", "Test")) +async def _load_receivers(target_id: int) -> list[dict]: + """Load enabled receivers for a target from DB.""" + engine = get_engine() + async with AsyncSession(engine) as session: + result = await session.exec( + select(TargetReceiver).where( + TargetReceiver.target_id == target_id, + TargetReceiver.enabled == True, + ) + ) + return [dict(r.config) for r in result.all()] + + async def send_to_target(target: NotificationTarget, message: str) -> dict: - """Send a message to a target, respecting all target config settings. + """Send a message to a target, broadcasting to all receivers. This is the SINGLE send path used by dispatch, test, and real-data notifications. """ try: + receivers = await _load_receivers(target.id) if target.type == "telegram": - return await _send_telegram(target, message) + return await _send_telegram_broadcast(target, message, receivers) elif target.type == "webhook": - return await _send_webhook(target, message) + return await _send_webhook_broadcast(target, message, receivers) + elif target.type == "email": + return await _send_email_broadcast(target, message, receivers) return {"success": False, "error": f"Unknown target type: {target.type}"} except Exception as e: _LOGGER.error("Send failed: %s", e) return {"success": False, "error": str(e)} -async def _send_telegram(target: NotificationTarget, message: str) -> dict: +async def _send_telegram_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict: from notify_bridge_core.notifications.telegram.client import TelegramClient bot_token = target.config.get("bot_token") - chat_id = target.config.get("chat_id") disable_preview = target.config.get("disable_url_preview", False) - if not bot_token or not chat_id: - return {"success": False, "error": "Missing bot_token or chat_id"} + if not bot_token: + return {"success": False, "error": "Missing bot_token"} + # Fall back to legacy chat_id if no receivers + if not receivers: + chat_id = target.config.get("chat_id") + if chat_id: + receivers = [{"chat_id": str(chat_id)}] + else: + return {"success": False, "error": "No receivers configured"} + + results: list[dict] = [] async with aiohttp.ClientSession() as session: client = TelegramClient(session, bot_token) - return await client.send_message( - chat_id=str(chat_id), - text=message, - disable_web_page_preview=bool(disable_preview), - ) + for recv in receivers: + chat_id = recv.get("chat_id") + if not chat_id: + continue + result = await client.send_message( + chat_id=str(chat_id), + text=message, + disable_web_page_preview=bool(disable_preview), + ) + results.append(result) + + successes = sum(1 for r in results if r.get("success")) + if successes == len(results) and results: + return {"success": True, "receivers": len(results)} + elif successes > 0: + return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes} + elif results: + return results[0] + return {"success": False, "error": "No valid receivers"} -async def _send_webhook(target: NotificationTarget, message: str, event_type: str = "notification") -> dict: +async def _send_webhook_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict: from notify_bridge_core.notifications.webhook.client import WebhookClient - url = target.config.get("url") - headers = target.config.get("headers", {}) - if not url: - return {"success": False, "error": "Missing url in target config"} + # Fall back to legacy url if no receivers + if not receivers: + url = target.config.get("url") + headers = target.config.get("headers", {}) + if url: + receivers = [{"url": url, "headers": headers}] + else: + return {"success": False, "error": "No receivers configured"} + results: list[dict] = [] async with aiohttp.ClientSession() as session: - client = WebhookClient(session, url, headers) - return await client.send({"message": message, "event_type": event_type}) + for recv in receivers: + url = recv.get("url") + headers = recv.get("headers", {}) + if not url: + continue + client = WebhookClient(session, url, headers) + results.append(await client.send({"message": message, "event_type": "notification"})) + + successes = sum(1 for r in results if r.get("success")) + if successes == len(results) and results: + return {"success": True, "receivers": len(results)} + elif successes > 0: + return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes} + elif results: + return results[0] + return {"success": False, "error": "No valid receivers"} + + +async def _send_email_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict: + from notify_bridge_core.notifications.email.client import EmailClient, SmtpConfig + from ..database.models import EmailBot + + email_bot_id = target.config.get("email_bot_id") + if not email_bot_id: + return {"success": False, "error": "No email bot configured for this target"} + + engine = get_engine() + async with AsyncSession(engine) as session: + email_bot = await session.get(EmailBot, email_bot_id) + if not email_bot: + return {"success": False, "error": "Email bot not found"} + smtp_cfg = SmtpConfig( + host=email_bot.smtp_host, + port=email_bot.smtp_port, + username=email_bot.smtp_username, + password=email_bot.smtp_password, + from_address=email_bot.email, + from_name=email_bot.name, + use_tls=email_bot.smtp_use_tls, + ) + + if not smtp_cfg.host or not smtp_cfg.from_address: + return {"success": False, "error": "Email bot SMTP not configured"} + + if not receivers: + return {"success": False, "error": "No email receivers configured"} + + client = EmailClient(smtp_cfg) + results: list[dict] = [] + for recv in receivers: + email = recv.get("email") + if not email: + continue + result = await client.send( + to_email=email, + subject="Notification from Notify Bridge", + body_text=message, + to_name=recv.get("name", ""), + ) + results.append(result) + + successes = sum(1 for r in results if r.get("success")) + if successes == len(results) and results: + return {"success": True, "receivers": len(results)} + elif successes > 0: + return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes} + elif results: + return results[0] + return {"success": False, "error": "No valid email receivers"} # --- Public API used by routes --- diff --git a/packages/server/src/notify_bridge_server/services/watcher.py b/packages/server/src/notify_bridge_server/services/watcher.py index 661f069..5eeeb43 100644 --- a/packages/server/src/notify_bridge_server/services/watcher.py +++ b/packages/server/src/notify_bridge_server/services/watcher.py @@ -17,13 +17,16 @@ from notify_bridge_core.storage import JsonFileBackend from ..database.engine import get_engine from ..database.models import ( + EmailBot, EventLog, NotificationTarget, NotificationTracker, NotificationTrackerState, NotificationTrackerTarget, ServiceProvider, + TargetReceiver, TemplateConfig, + TemplateSlot, TrackingConfig, ) @@ -129,19 +132,59 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]: if not target: continue + # Load receivers for this target + recv_result = await session.exec( + select(TargetReceiver).where( + TargetReceiver.target_id == target.id, + TargetReceiver.enabled == True, + ) + ) + receivers = [dict(r.config) for r in recv_result.all()] + tracking_config = None if tt.tracking_config_id: tracking_config = await session.get(TrackingConfig, tt.tracking_config_id) template_config = None + template_slots: dict[str, str] | None = None if tt.template_config_id: template_config = await session.get(TemplateConfig, tt.template_config_id) + if template_config: + slot_result = await session.exec( + select(TemplateSlot).where(TemplateSlot.config_id == template_config.id) + ) + raw_slots = {s.slot_name: s.template for s in slot_result.all()} + # Map slot names to event_type values for dispatcher lookup + template_slots = {} + for slot_name, tmpl_text in raw_slots.items(): + # Strip "message_" prefix for event-type slots + event_key = slot_name.removeprefix("message_") if slot_name.startswith("message_") else slot_name + template_slots[event_key] = tmpl_text + + target_config = dict(target.config) + # Inject SMTP config for email targets from EmailBot + if target.type == "email": + email_bot_id = target.config.get("email_bot_id") + if email_bot_id: + email_bot = await session.get(EmailBot, email_bot_id) + if email_bot: + target_config["smtp"] = { + "host": email_bot.smtp_host, + "port": email_bot.smtp_port, + "username": email_bot.smtp_username, + "password": email_bot.smtp_password, + "from_address": email_bot.email, + "from_name": email_bot.name, + "use_tls": email_bot.smtp_use_tls, + } link_data.append({ "target_type": target.type, - "target_config": dict(target.config), + "target_config": target_config, + "receivers": receivers, "tracking_config": tracking_config, "template_config": template_config, + "template_slots": template_slots, }) # Snapshot the data we need @@ -249,26 +292,17 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]: _LOGGER.info(" Skipped by tracking config filter") continue - # Build template slots from template config tmpl = ld["template_config"] - slots = None - if tmpl: - slots = { - "assets_added": tmpl.message_assets_added, - "assets_removed": tmpl.message_assets_removed, - "collection_renamed": tmpl.message_collection_renamed, - "collection_deleted": tmpl.message_collection_deleted, - "sharing_changed": tmpl.message_sharing_changed, - } target_configs.append(TargetConfig( type=ld["target_type"], config=ld["target_config"], - template_slots=slots, + template_slots=ld["template_slots"], date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC", date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y", provider_api_key=provider_config.get("api_key"), provider_internal_url=provider_config.get("url", ""), provider_external_url=provider_config.get("external_domain", ""), + receivers=ld["receivers"], )) if target_configs: