diff --git a/frontend/src/lib/components/MultiEntitySelect.svelte b/frontend/src/lib/components/MultiEntitySelect.svelte index 88e407e..cfb199a 100644 --- a/frontend/src/lib/components/MultiEntitySelect.svelte +++ b/frontend/src/lib/components/MultiEntitySelect.svelte @@ -105,7 +105,7 @@ {/if} @@ -120,7 +120,7 @@ - + {#if !loaded} @@ -192,9 +193,7 @@ {#if showForm}
- {#if error} -
{error}
- {/if} + {#if error}{/if}
@@ -242,7 +241,7 @@
{#if form.schedule_type === 'interval'} @@ -263,10 +262,9 @@
- + {#if editing} diff --git a/frontend/src/routes/bots/EmailBotTab.svelte b/frontend/src/routes/bots/EmailBotTab.svelte index 9d1ac22..f905c36 100644 --- a/frontend/src/routes/bots/EmailBotTab.svelte +++ b/frontend/src/routes/bots/EmailBotTab.svelte @@ -131,10 +131,9 @@ {t('emailBot.useTls')} - + {/if} @@ -157,7 +156,7 @@ {bot.email} {bot.smtp_host}:{bot.smtp_port} {#if bot.smtp_use_tls} - TLS + TLS {/if} diff --git a/frontend/src/routes/bots/TelegramBotTab.svelte b/frontend/src/routes/bots/TelegramBotTab.svelte index 5e65221..4e87b39 100644 --- a/frontend/src/routes/bots/TelegramBotTab.svelte +++ b/frontend/src/routes/bots/TelegramBotTab.svelte @@ -303,10 +303,9 @@ class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" /> {/if} - + {/if} @@ -329,8 +328,8 @@ {/if} + ? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]' + : 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'}"> {bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')} @@ -378,7 +377,7 @@ onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)} title={t('telegramBot.clickToCopy')} role="button" tabindex="0"> - {chat.title || chat.username || 'Unknown'} + {chat.title || chat.username || t('common.unknown')} {chatTypeLabel(chat.type)} {(chat.language_code || '—').toUpperCase()}
e.stopPropagation()}> @@ -399,7 +398,7 @@
{chat.chat_id}
- testChat(e, bot.id, chat.chat_id)} disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} /> {#if bot.update_mode === 'polling'} - + {t('telegramBot.pollingActive')} @@ -489,7 +488,7 @@ {/if} {#if ws.last_error_message} - {t('telegramBot.webhookError')}: {ws.last_error_message} + {t('telegramBot.webhookError')}: {ws.last_error_message} {/if} {:else} + {/if} @@ -268,7 +268,7 @@

{cfg.name}

{cfg.provider_type} - + {(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
diff --git a/frontend/src/routes/command-trackers/+page.svelte b/frontend/src/routes/command-trackers/+page.svelte index a6d653e..bd17d93 100644 --- a/frontend/src/routes/command-trackers/+page.svelte +++ b/frontend/src/routes/command-trackers/+page.svelte @@ -18,6 +18,7 @@ import { highlightFromUrl } from '$lib/highlight'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { providerDefaultIcon } from '$lib/grid-items'; + import Button from '$lib/components/Button.svelte'; import type { ServiceProvider, TelegramBot } from '$lib/types'; let allCmdTrackers = $state([]); @@ -186,10 +187,9 @@ - + {#if !loaded}{:else} @@ -217,10 +217,9 @@ - + {/if} @@ -257,8 +256,8 @@ + ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' + : 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]'}"> {trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')} @@ -293,7 +292,7 @@
- {listener.listener_type} + {listener.listener_type}
removeListener(trk.id, listener.id)} variant="danger" /> @@ -307,10 +306,9 @@
- + {/if} diff --git a/frontend/src/routes/notification-trackers/+page.svelte b/frontend/src/routes/notification-trackers/+page.svelte index a368328..e7500c4 100644 --- a/frontend/src/routes/notification-trackers/+page.svelte +++ b/frontend/src/routes/notification-trackers/+page.svelte @@ -131,7 +131,7 @@ try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; } } - let _prevProviderId = 0; + let _prevProviderId = $state(0); $effect(() => { if (showForm && form.provider_id && form.provider_id !== _prevProviderId) { _prevProviderId = form.provider_id; @@ -146,8 +146,8 @@ name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id, collection_ids: [...(trk.collection_ids || [])], scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0, - default_tracking_config_id: (trk as any).default_tracking_config_id || 0, - default_template_config_id: (trk as any).default_template_config_id || 0, + default_tracking_config_id: trk.default_tracking_config_id ?? 0, + default_template_config_id: trk.default_template_config_id ?? 0, filters: trk.filters || {}, }; previousCollectionIds = [...(trk.collection_ids || [])]; diff --git a/frontend/src/routes/providers/+page.svelte b/frontend/src/routes/providers/+page.svelte index 89fd3e6..1635f3f 100644 --- a/frontend/src/routes/providers/+page.svelte +++ b/frontend/src/routes/providers/+page.svelte @@ -285,19 +285,19 @@ transition: all 0.3s ease; } .health-dot.online { - background: #059669; - box-shadow: 0 0 8px rgba(5, 150, 105, 0.4); + background: var(--color-success-fg); + box-shadow: 0 0 8px color-mix(in srgb, var(--color-success-fg) 40%, transparent); } .health-dot.offline { - background: #ef4444; - box-shadow: 0 0 8px rgba(239, 68, 68, 0.3); + background: var(--color-error-fg); + box-shadow: 0 0 8px color-mix(in srgb, var(--color-error-fg) 30%, transparent); } .health-dot.checking { - background: #f59e0b; + background: var(--color-warning-fg); animation: pulseCheck 1.5s ease-in-out infinite; } @keyframes pulseCheck { - 0%, 100% { box-shadow: 0 0 4px rgba(245, 158, 11, 0.3); } - 50% { box-shadow: 0 0 12px rgba(245, 158, 11, 0.6); } + 0%, 100% { box-shadow: 0 0 4px color-mix(in srgb, var(--color-warning-fg) 30%, transparent); } + 50% { box-shadow: 0 0 12px color-mix(in srgb, var(--color-warning-fg) 60%, transparent); } } diff --git a/frontend/src/routes/providers/WebhookPayloadHistory.svelte b/frontend/src/routes/providers/WebhookPayloadHistory.svelte index d787948..e426781 100644 --- a/frontend/src/routes/providers/WebhookPayloadHistory.svelte +++ b/frontend/src/routes/providers/WebhookPayloadHistory.svelte @@ -20,7 +20,8 @@ loading = true; try { logs = await api(`/providers/${providerId}/webhook-logs`); - } catch { + } catch (e) { + console.error('Failed to load webhook logs', e); logs = []; } loading = false; @@ -30,7 +31,7 @@ try { await api(`/providers/${providerId}/webhook-logs`, { method: 'DELETE' }); logs = []; - } catch { /* ignore */ } + } catch (e) { console.error('Failed to clear webhook logs', e); } showClearConfirm = false; } @@ -39,9 +40,9 @@ } function statusColor(status: string): string { - if (status === 'matched') return '#059669'; - if (status === 'unmatched') return '#f59e0b'; - return '#ef4444'; + if (status === 'matched') return 'var(--color-success-fg)'; + if (status === 'unmatched') return 'var(--color-warning-fg)'; + return 'var(--color-error-fg)'; } function statusIcon(status: string): string { @@ -71,7 +72,7 @@

{t('webhookLogs.title')}

{#if logs.length > 0} - {logs.length} + {logs.length} {/if}
{#if logs.length > 0} @@ -99,7 +100,7 @@
{formatTime(log.created_at)} - {log.method} + {log.method} {statusLabel(log.status)} diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index f552715..3f4db6e 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -8,6 +8,7 @@ import MdiIcon from '$lib/components/MdiIcon.svelte'; import Hint from '$lib/components/Hint.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte'; + import Button from '$lib/components/Button.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; let loaded = $state(false); @@ -67,7 +68,7 @@
-
@@ -78,9 +79,8 @@
- +
{/if} diff --git a/frontend/src/routes/targets/TargetForm.svelte b/frontend/src/routes/targets/TargetForm.svelte index 57074a2..05af825 100644 --- a/frontend/src/routes/targets/TargetForm.svelte +++ b/frontend/src/routes/targets/TargetForm.svelte @@ -9,6 +9,7 @@ import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte'; import type { EntityItem } from '$lib/components/EntitySelect.svelte'; import type { GridItem } from '$lib/components/IconGridSelect.svelte'; + import Button from '$lib/components/Button.svelte'; interface Props { form: { @@ -184,7 +185,7 @@ {/if} - +
diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index f9f399a..c9fa16d 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -22,6 +22,8 @@ import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { highlightFromUrl } from '$lib/highlight'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; + import ErrorBanner from '$lib/components/ErrorBanner.svelte'; + import Button from '$lib/components/Button.svelte'; import type { TemplateConfig } from '$lib/types'; let allTemplateConfigs = $derived(templateConfigsCache.items); @@ -263,10 +265,9 @@ - + {#if !loaded}{:else} @@ -274,7 +275,7 @@ {#if showForm}
- {#if error}
{error}
{/if} + {#if error}{/if}
@@ -391,9 +392,9 @@ {/if} {/each} - +
diff --git a/frontend/src/routes/tracking-configs/+page.svelte b/frontend/src/routes/tracking-configs/+page.svelte index 470644d..bd777c7 100644 --- a/frontend/src/routes/tracking-configs/+page.svelte +++ b/frontend/src/routes/tracking-configs/+page.svelte @@ -19,6 +19,8 @@ import { highlightFromUrl } from '$lib/highlight'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers'; + import ErrorBanner from '$lib/components/ErrorBanner.svelte'; + import Button from '$lib/components/Button.svelte'; import type { TrackingConfig } from '$lib/types'; /** Grid-select item source lookup — maps descriptor string name to actual function. */ @@ -83,10 +85,9 @@ - + {#if !loaded}{:else} @@ -94,7 +95,7 @@ {#if showForm}
- {#if error}
{error}
{/if} + {#if error}{/if}
@@ -194,9 +195,9 @@ {/if} - +
diff --git a/frontend/src/routes/users/+page.svelte b/frontend/src/routes/users/+page.svelte index 23aea70..982d6da 100644 --- a/frontend/src/routes/users/+page.svelte +++ b/frontend/src/routes/users/+page.svelte @@ -12,6 +12,8 @@ import EmptyState from '$lib/components/EmptyState.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import ErrorBanner from '$lib/components/ErrorBanner.svelte'; + import Button from '$lib/components/Button.svelte'; import type { User } from '$lib/types'; const auth = getAuth(); @@ -67,17 +69,16 @@ - + {#if !loaded}{:else} {#if showForm} - {#if error}
{error}
{/if} + {#if error}{/if}
@@ -94,7 +95,7 @@
- +
{/if} @@ -110,7 +111,7 @@

{user.username}

-

{user.role} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}

+

{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}

{#if user.id !== auth.user?.id} @@ -137,9 +138,9 @@ {#if resetMsg}

{resetMsg}

{/if} - + diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/latest.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/latest.jinja2 index da93f6f..f9bff60 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/latest.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/latest.jinja2 @@ -1,4 +1,4 @@ 📸 Latest: {%- for asset in assets %} - • {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %} + • {{ asset.filename }}{% if asset.type == 'VIDEO' %} 🎬{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/random.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/random.jinja2 index ff857ed..7911ddd 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/random.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/random.jinja2 @@ -1,4 +1,4 @@ 🎲 Random: {%- for asset in assets %} - • {{ asset.originalFileName }} + • {{ asset.filename }} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/search.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/search.jinja2 index 0cd9e05..00268d4 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/search.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/search.jinja2 @@ -1,4 +1,4 @@ 🔍 Results for "{{ query }}": {%- for asset in assets %} - • {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %} + • {{ asset.filename }}{% if asset.type == 'VIDEO' %} 🎬{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/latest.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/latest.jinja2 index b73a947..c77a519 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/latest.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/latest.jinja2 @@ -1,4 +1,4 @@ 📸 Последние: {%- for asset in assets %} - • {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %} + • {{ asset.filename }}{% if asset.type == 'VIDEO' %} 🎬{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/random.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/random.jinja2 index 12273ff..61f59ac 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/random.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/random.jinja2 @@ -1,4 +1,4 @@ 🎲 Случайные: {%- for asset in assets %} - • {{ asset.originalFileName }} + • {{ asset.filename }} {%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/search.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/search.jinja2 index 2f42fb9..4cf6d65 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/search.jinja2 +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/search.jinja2 @@ -1,4 +1,4 @@ 🔍 Результаты по запросу "{{ query }}": {%- for asset in assets %} - • {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %} + • {{ asset.filename }}{% if asset.type == 'VIDEO' %} 🎬{% endif %} {%- endfor %} \ No newline at end of file diff --git a/packages/server/src/notify_bridge_server/api/command_template_configs.py b/packages/server/src/notify_bridge_server/api/command_template_configs.py index 6c1f7b6..0a3a78f 100644 --- a/packages/server/src/notify_bridge_server/api/command_template_configs.py +++ b/packages/server/src/notify_bridge_server/api/command_template_configs.py @@ -112,7 +112,8 @@ async def get_command_variables( "asset_fields": asset_fields, } - return { + # --- Shared slots (all providers) --- + shared = { "start": { "description": "/start greeting message", "variables": {**common_vars, "bot_name": "Bot display name"}, @@ -122,6 +123,20 @@ async def get_command_variables( "variables": {**common_vars, "commands": "List of command dicts (use {% for cmd in commands %})"}, "command_fields": command_fields, }, + "rate_limited": { + "description": "Rate limit warning message", + "variables": {**common_vars, "wait": "Seconds to wait before retry"}, + }, + "no_results": { + "description": "Empty results fallback", + "variables": {**common_vars, "command": "Command name", "query": "Search query (empty for non-search commands)"}, + }, + "desc_help": {"description": "Description for /help command", "variables": common_vars}, + "desc_status": {"description": "Description for /status command", "variables": common_vars}, + } + + # --- Immich-specific --- + immich = { "status": { "description": "/status tracker summary", "variables": { @@ -163,17 +178,6 @@ async def get_command_variables( "variables": {**common_vars, "assets": "List of asset dicts with year field (use {% for asset in assets %})", "count": "Number of results"}, "asset_fields": asset_fields, }, - "rate_limited": { - "description": "Rate limit warning message", - "variables": {**common_vars, "wait": "Seconds to wait before retry"}, - }, - "no_results": { - "description": "Empty results fallback", - "variables": {**common_vars, "command": "Command name", "query": "Search query (empty for non-search commands)"}, - }, - # --- Description slots (shown in /help listing) --- - "desc_help": {"description": "Description for /help command", "variables": common_vars}, - "desc_status": {"description": "Description for /status command", "variables": common_vars}, "desc_albums": {"description": "Description for /albums command", "variables": common_vars}, "desc_events": {"description": "Description for /events command", "variables": common_vars}, "desc_summary": {"description": "Description for /summary command", "variables": common_vars}, @@ -186,7 +190,6 @@ async def get_command_variables( "desc_place": {"description": "Description for /place command", "variables": common_vars}, "desc_favorites": {"description": "Description for /favorites command", "variables": common_vars}, "desc_people": {"description": "Description for /people command", "variables": common_vars}, - # --- Usage example slots (shown in /help listing) --- "usage_search": {"description": "Usage example for /search (e.g. '/search sunset')", "variables": common_vars}, "usage_find": {"description": "Usage example for /find", "variables": common_vars}, "usage_person": {"description": "Usage example for /person", "variables": common_vars}, @@ -198,6 +201,183 @@ async def get_command_variables( "usage_memory": {"description": "Usage example for /memory", "variables": common_vars}, } + # --- Gitea-specific --- + repo_fields = { + "full_name": "Repository full name (owner/repo)", + "description": "Repository description", + } + issue_fields = { + "repo": "Repository name", + "number": "Issue number", + "title": "Issue title", + "url": "Issue URL", + "user": "Issue author", + } + pr_fields = { + "repo": "Repository name", + "number": "PR number", + "title": "PR title", + "url": "PR URL", + "user": "PR author", + } + commit_fields = { + "repo": "Repository name", + "short_id": "Short commit hash", + "message": "Commit message", + "author": "Commit author", + } + gitea = { + "status": { + "description": "/status Gitea server summary", + "variables": {**common_vars, "repos_count": "Number of tracked repositories", "server_version": "Gitea server version", "last_event": "Last event timestamp"}, + }, + "repos": { + "description": "/repos tracked repositories", + "variables": {**common_vars, "repos": "List of repo dicts (use {% for repo in repos %})"}, + "repo_fields": repo_fields, + }, + "issues": { + "description": "/issues open issues", + "variables": {**common_vars, "issues": "List of issue dicts (use {% for issue in issues %})"}, + "issue_fields": issue_fields, + }, + "prs": { + "description": "/prs open pull requests", + "variables": {**common_vars, "prs": "List of PR dicts (use {% for pr in prs %})"}, + "pr_fields": pr_fields, + }, + "commits": { + "description": "/commits recent commits", + "variables": {**common_vars, "commits": "List of commit dicts (use {% for c in commits %})"}, + "commit_fields": commit_fields, + }, + "desc_repos": {"description": "Description for /repos command", "variables": common_vars}, + "desc_issues": {"description": "Description for /issues command", "variables": common_vars}, + "desc_prs": {"description": "Description for /prs command", "variables": common_vars}, + "desc_commits": {"description": "Description for /commits command", "variables": common_vars}, + } + + # --- Planka-specific --- + board_fields = {"name": "Board name"} + card_fields_planka = { + "name": "Card name", + "list_name": "List the card belongs to", + "board_name": "Board the card belongs to", + } + list_fields = {"name": "List name", "board_name": "Board name"} + planka = { + "status": { + "description": "/status Planka board summary", + "variables": {**common_vars, "boards_count": "Number of tracked boards", "last_event": "Last event timestamp"}, + }, + "boards": { + "description": "/boards tracked boards", + "variables": {**common_vars, "boards": "List of board dicts (use {% for board in boards %})"}, + "board_fields": board_fields, + }, + "cards": { + "description": "/cards recent cards", + "variables": {**common_vars, "cards": "List of card dicts (use {% for card in cards %})"}, + "card_fields": card_fields_planka, + }, + "lists": { + "description": "/lists board lists", + "variables": {**common_vars, "lists": "List of list dicts (use {% for lst in lists %})"}, + "list_fields": list_fields, + }, + "desc_boards": {"description": "Description for /boards command", "variables": common_vars}, + "desc_cards": {"description": "Description for /cards command", "variables": common_vars}, + "desc_lists": {"description": "Description for /lists command", "variables": common_vars}, + } + + # --- NUT-specific --- + device_fields = { + "name": "UPS device name", + "description": "UPS description", + "model": "UPS model", + "battery_charge": "Battery charge percentage", + "battery_runtime": "Estimated runtime (formatted)", + "input_voltage": "Input voltage", + "output_voltage": "Output voltage", + } + nut = { + "status": { + "description": "/status UPS summary", + "variables": {**common_vars, "devices_count": "Number of monitored devices", "last_event": "Last event timestamp"}, + }, + "devices": { + "description": "/devices monitored UPS devices", + "variables": {**common_vars, "devices": "List of device dicts (use {% for d in devices %})"}, + "device_fields": device_fields, + }, + "battery": { + "description": "/battery battery report", + "variables": {**common_vars, "devices": "List of UPS dicts (use {% for ups in devices %})"}, + "device_fields": device_fields, + }, + "desc_devices": {"description": "Description for /devices command", "variables": common_vars}, + "desc_battery": {"description": "Description for /battery command", "variables": common_vars}, + } + + # --- Google Photos-specific --- + gp_asset_fields = { + "id": "Asset ID", + "filename": "Original filename", + "type": "IMAGE or VIDEO", + "created_at": "Creation date/time (ISO 8601)", + } + google_photos = { + "status": { + "description": "/status Google Photos summary", + "variables": {**common_vars, "trackers_active": "Number of active trackers", "trackers_total": "Total tracker count", "total_albums": "Total tracked albums", "last_event": "Last event timestamp"}, + }, + "albums": { + "description": "/albums tracked albums", + "variables": {**common_vars, "albums": "List of album dicts (use {% for album in albums %})"}, + "album_fields": album_fields, + }, + "latest": { + "description": "/latest recent photos", + "variables": {**common_vars, "assets": "List of asset dicts (use {% for asset in assets %})", "count": "Number of results"}, + "asset_fields": gp_asset_fields, + }, + "search": { + "description": "/search photo search results", + "variables": {**common_vars, "assets": "List of asset dicts (use {% for asset in assets %})", "query": "Search query", "count": "Number of results"}, + "asset_fields": gp_asset_fields, + }, + "random": { + "description": "/random random photos", + "variables": {**common_vars, "assets": "List of asset dicts (use {% for asset in assets %})", "count": "Number of results"}, + "asset_fields": gp_asset_fields, + }, + "desc_albums": {"description": "Description for /albums command", "variables": common_vars}, + "desc_latest": {"description": "Description for /latest command", "variables": common_vars}, + "desc_search": {"description": "Description for /search command", "variables": common_vars}, + "desc_random": {"description": "Description for /random command", "variables": common_vars}, + "usage_latest": {"description": "Usage example for /latest", "variables": common_vars}, + "usage_search": {"description": "Usage example for /search", "variables": common_vars}, + "usage_random": {"description": "Usage example for /random", "variables": common_vars}, + } + + # --- Webhook-specific --- + webhook = { + "status": { + "description": "/status webhook provider summary", + "variables": {**common_vars, "provider_name": "Webhook provider name", "last_event": "Last event timestamp"}, + }, + } + + return { + **shared, + "immich": immich, + "gitea": gitea, + "planka": planka, + "nut": nut, + "google_photos": google_photos, + "webhook": webhook, + } + @router.get("") async def list_configs( diff --git a/packages/server/src/notify_bridge_server/api/delete_protection.py b/packages/server/src/notify_bridge_server/api/delete_protection.py index 45d2f0e..b0311fe 100644 --- a/packages/server/src/notify_bridge_server/api/delete_protection.py +++ b/packages/server/src/notify_bridge_server/api/delete_protection.py @@ -15,8 +15,6 @@ from ..database.models import ( NotificationTarget, NotificationTracker, NotificationTrackerTarget, - TargetReceiver, - TelegramChat, ) @@ -164,16 +162,3 @@ async def check_notification_target(session: AsyncSession, target_id: int) -> li return consumers -async def check_notification_tracker(session: AsyncSession, tracker_id: int) -> list[str]: - """Check if a NotificationTracker has any linked targets.""" - consumers = [] - result = await session.exec( - select(NotificationTrackerTarget).where( - NotificationTrackerTarget.tracker_id == tracker_id - ) - ) - for tt in result.all(): - target = await session.get(NotificationTarget, tt.target_id) - name = target.name if target else f"#{tt.target_id}" - consumers.append(f"Linked Target: {name}") - return consumers diff --git a/packages/server/src/notify_bridge_server/api/targets.py b/packages/server/src/notify_bridge_server/api/targets.py index c6d7944..df36b04 100644 --- a/packages/server/src/notify_bridge_server/api/targets.py +++ b/packages/server/src/notify_bridge_server/api/targets.py @@ -180,7 +180,11 @@ async def create_target( await session.commit() await session.refresh(target) - return {"id": target.id, "type": target.type, "name": target.name} + # Load receivers for a full response consistent with GET + recv_result = await session.exec( + select(TargetReceiver).where(TargetReceiver.target_id == target.id) + ) + return _target_response(target, receivers=list(recv_result.all())) @router.get("/{target_id}") 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 c78db85..d842d5d 100644 --- a/packages/server/src/notify_bridge_server/api/template_configs.py +++ b/packages/server/src/notify_bridge_server/api/template_configs.py @@ -134,10 +134,10 @@ async def get_template_variables( "has_oversized_videos": "Whether any video exceeds the target's size limit (boolean)", "max_video_size": "Target video size limit in bytes (null if no limit)", "max_video_size_mb": "Target video size limit in MB (null if no limit)", - # Immich aliases + # Provider-specific aliases "album_name": "Alias for collection_name", "album_id": "Alias for collection_id", - "album_url": "Alias for collection_url", + "album_url": "Alias for collection_url (Immich) or album product URL (Google Photos)", } rename_vars = { **event_vars, @@ -236,7 +236,7 @@ async def get_template_variables( "message_scheduled_message": { "description": "Notification for scheduled message events", "variables": { - "tracker_name": "Name of the tracker that fired", + "schedule_name": "Name of the schedule that fired", "fire_count": "How many times this tracker has fired", "current_date": "Current date (formatted)", "current_time": "Current time (formatted)", @@ -420,6 +420,8 @@ async def update_config( session: AsyncSession = Depends(get_session), ): config = await _get(session, config_id, user.id) + if config.user_id == 0 and user.role != "admin": + raise HTTPException(status_code=403, detail="Cannot modify system default configs") for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items(): if value is not None: setattr(config, field, value) diff --git a/packages/server/src/notify_bridge_server/api/webhooks.py b/packages/server/src/notify_bridge_server/api/webhooks.py index 23c3266..c120009 100644 --- a/packages/server/src/notify_bridge_server/api/webhooks.py +++ b/packages/server/src/notify_bridge_server/api/webhooks.py @@ -4,6 +4,7 @@ from __future__ import annotations import hashlib import hmac +import json import logging from typing import Any @@ -71,6 +72,98 @@ def _passes_filters( return True +# --------------------------------------------------------------------------- +# Shared dispatch helper +# --------------------------------------------------------------------------- + +async def _dispatch_webhook_event( + engine: Any, + provider_id: int, + provider_name: str, + provider_config: dict[str, Any], + event: ServiceEvent, + detail_keys: tuple[str, ...], +) -> int: + """Load trackers, filter, create EventLogs, dispatch notifications, and commit. + + Parameters + ---------- + engine: + SQLAlchemy async engine. + provider_id: + ID of the ServiceProvider that received the webhook. + provider_name: + Human-readable name of the provider (for logging). + provider_config: + The provider's ``config`` dict (passed through to target config builder). + event: + Parsed :class:`ServiceEvent` to dispatch. + detail_keys: + Keys from ``event.extra`` to include in the EventLog ``details`` dict. + + Returns + ------- + int + Number of successfully dispatched notifications. + """ + dispatched = 0 + async with AsyncSession(engine) as session: + tracker_result = await session.exec( + select(NotificationTracker).where( + NotificationTracker.provider_id == provider_id, + NotificationTracker.enabled == True, # noqa: E712 + ) + ) + trackers = tracker_result.all() + + for tracker in trackers: + filters = tracker.filters or {} + if not _passes_filters(event, filters): + _LOGGER.debug( + "Event filtered out for tracker %d (%s)", tracker.id, tracker.name + ) + continue + + link_data = await load_link_data(session, tracker.id) + if not link_data: + continue + + # Log event + extra_details = {k: v for k, v in event.extra.items() if k in detail_keys} + session.add(EventLog( + tracker_id=tracker.id, + tracker_name=tracker.name, + provider_id=provider_id, + provider_name=provider_name, + event_type=event.event_type.value, + collection_id=event.collection_id, + collection_name=event.collection_name, + assets_count=0, + details={ + "provider_type": event.provider_type.value, + **extra_details, + }, + )) + + # Dispatch to targets + dispatcher = NotificationDispatcher() + target_configs = _build_target_configs(event, link_data, provider_config) + if target_configs: + results = await dispatcher.dispatch(event, target_configs) + for r in results: + if r.get("success"): + dispatched += 1 + else: + _LOGGER.error( + "Notification failed for tracker %d: %s", + tracker.id, r.get("error", "unknown"), + ) + + await session.commit() + + return dispatched + + # --------------------------------------------------------------------------- # Gitea webhook endpoint # --------------------------------------------------------------------------- @@ -108,74 +201,27 @@ async def gitea_webhook(provider_id: int, request: Request): try: payload = await request.json() - except Exception: + except (json.JSONDecodeError, ValueError): raise HTTPException(status_code=400, detail="Invalid JSON") event = parse_gitea_webhook(event_header, payload, provider.name) if event is None: return {"ok": True, "skipped": "unmapped event"} - # --- Find trackers for this provider and dispatch --- - dispatched = 0 - async with AsyncSession(engine) as session: - tracker_result = await session.exec( - select(NotificationTracker).where( - NotificationTracker.provider_id == provider_id, - NotificationTracker.enabled == True, - ) - ) - trackers = tracker_result.all() - - for tracker in trackers: - # Apply filters - filters = tracker.filters or {} - if not _passes_filters(event, filters): - _LOGGER.debug( - "Event filtered out for tracker %d (%s)", tracker.id, tracker.name - ) - continue - - # Load tracker-target links - link_data = await load_link_data(session, tracker.id) - if not link_data: - continue - - # Log event - session.add(EventLog( - tracker_id=tracker.id, - tracker_name=tracker.name, - provider_id=provider_id, - provider_name=provider.name, - event_type=event.event_type.value, - collection_id=event.collection_id, - collection_name=event.collection_name, - assets_count=0, - details={ - "provider_type": event.provider_type.value, - **{k: v for k, v in event.extra.items() if k in ( - "sender", "branch", "commit_count", - "issue_number", "issue_title", - "pr_number", "pr_title", - "release_tag", "release_name", - )}, - }, - )) - - # Dispatch to targets - dispatcher = NotificationDispatcher() - target_configs = _build_target_configs(event, link_data, provider.config or {}) - if target_configs: - results = await dispatcher.dispatch(event, target_configs) - for r in results: - if r.get("success"): - dispatched += 1 - else: - _LOGGER.error( - "Notification failed for tracker %d: %s", - tracker.id, r.get("error", "unknown"), - ) - - await session.commit() + # --- Dispatch --- + dispatched = await _dispatch_webhook_event( + engine=engine, + provider_id=provider_id, + provider_name=provider.name, + provider_config=provider.config or {}, + event=event, + detail_keys=( + "sender", "branch", "commit_count", + "issue_number", "issue_title", + "pr_number", "pr_title", + "release_tag", "release_name", + ), + ) return {"ok": True, "dispatched": dispatched} @@ -218,7 +264,7 @@ async def planka_webhook(provider_id: int, request: Request): # Parse payload try: payload = await request.json() - except Exception: + except (json.JSONDecodeError, ValueError): raise HTTPException(status_code=400, detail="Invalid JSON") event_type = payload.get("type", "") @@ -230,65 +276,20 @@ async def planka_webhook(provider_id: int, request: Request): if event is None: return {"ok": True, "skipped": "unmapped event"} - # --- Find trackers for this provider and dispatch --- - dispatched = 0 - async with AsyncSession(engine) as session: - tracker_result = await session.exec( - select(NotificationTracker).where( - NotificationTracker.provider_id == provider_id, - NotificationTracker.enabled == True, - ) - ) - trackers = tracker_result.all() - - for tracker in trackers: - filters = tracker.filters or {} - if not _passes_filters(event, filters): - _LOGGER.debug( - "Event filtered out for tracker %d (%s)", tracker.id, tracker.name - ) - continue - - link_data = await load_link_data(session, tracker.id) - if not link_data: - continue - - # Log event - session.add(EventLog( - tracker_id=tracker.id, - tracker_name=tracker.name, - provider_id=provider_id, - provider_name=provider.name, - event_type=event.event_type.value, - collection_id=event.collection_id, - collection_name=event.collection_name, - assets_count=0, - details={ - "provider_type": event.provider_type.value, - **{k: v for k, v in event.extra.items() if k in ( - "sender", "card_name", "board_name", - "list_name", "old_list_name", "new_list_name", - "comment_text", "task_name", "attachment_name", - "label_name", - )}, - }, - )) - - # Dispatch to targets - dispatcher = NotificationDispatcher() - target_configs = _build_target_configs(event, link_data, provider.config or {}) - if target_configs: - results = await dispatcher.dispatch(event, target_configs) - for r in results: - if r.get("success"): - dispatched += 1 - else: - _LOGGER.error( - "Notification failed for tracker %d: %s", - tracker.id, r.get("error", "unknown"), - ) - - await session.commit() + # --- Dispatch --- + dispatched = await _dispatch_webhook_event( + engine=engine, + provider_id=provider_id, + provider_name=provider.name, + provider_config=provider.config or {}, + event=event, + detail_keys=( + "sender", "card_name", "board_name", + "list_name", "old_list_name", "new_list_name", + "comment_text", "task_name", "attachment_name", + "label_name", + ), + ) return {"ok": True, "dispatched": dispatched} @@ -424,7 +425,7 @@ async def generic_webhook(provider_id: int, request: Request): # Parse JSON payload try: payload = await request.json() - except Exception: + except (json.JSONDecodeError, ValueError): if store_payloads: async with AsyncSession(get_engine()) as log_session: await _save_webhook_log( @@ -451,70 +452,28 @@ async def generic_webhook(provider_id: int, request: Request): source_ip = request.client.host if request.client else "" event.extra["source_ip"] = source_ip - # --- Find trackers for this provider and dispatch --- - dispatched = 0 - async with AsyncSession(engine) as session: - tracker_result = await session.exec( - select(NotificationTracker).where( - NotificationTracker.provider_id == provider_id, - NotificationTracker.enabled == True, - ) - ) - trackers = tracker_result.all() + # --- Dispatch --- + dispatched = await _dispatch_webhook_event( + engine=engine, + provider_id=provider_id, + provider_name=provider_name, + provider_config=provider_config, + event=event, + detail_keys=( + "event_type_raw", "source_ip", + ), + ) - for tracker in trackers: - filters = tracker.filters or {} - if not _passes_filters(event, filters): - _LOGGER.debug( - "Event filtered out for tracker %d (%s)", tracker.id, tracker.name - ) - continue - - link_data = await load_link_data(session, tracker.id) - if not link_data: - continue - - # Log event - session.add(EventLog( - tracker_id=tracker.id, - tracker_name=tracker.name, - provider_id=provider_id, - provider_name=provider_name, - event_type=event.event_type.value, - collection_id=event.collection_id, - collection_name=event.collection_name, - assets_count=0, - details={ - "provider_type": "webhook", - "event_type_raw": event.extra.get("event_type_raw", ""), - "source_ip": source_ip, - }, - )) - - # Dispatch to targets - dispatcher = NotificationDispatcher() - target_configs = _build_target_configs(event, link_data, provider_config) - if target_configs: - results = await dispatcher.dispatch(event, target_configs) - for r in results: - if r.get("success"): - dispatched += 1 - else: - _LOGGER.error( - "Notification failed for tracker %d: %s", - tracker.id, r.get("error", "unknown"), - ) - - # Log matched payload - if store_payloads: + # Log matched payload (separate session — dispatch already committed) + if store_payloads: + async with AsyncSession(engine) as log_session: await _save_webhook_log( - session, provider_id, request.method, safe_headers, + log_session, provider_id, request.method, safe_headers, payload, "matched" if dispatched > 0 else "unmatched", extracted_fields=dict(event.extra), max_count=max_stored, ) - - await session.commit() + await log_session.commit() return {"ok": True, "dispatched": dispatched} diff --git a/packages/server/src/notify_bridge_server/database/seeds.py b/packages/server/src/notify_bridge_server/database/seeds.py index e1510b1..4e77bd1 100644 --- a/packages/server/src/notify_bridge_server/database/seeds.py +++ b/packages/server/src/notify_bridge_server/database/seeds.py @@ -238,6 +238,24 @@ async def _seed_default_tracking_configs() -> None: "name": "Default Webhook", "track_webhook_received": True, }, + { + "provider_type": "immich", + "name": "Default Immich", + "track_assets_added": True, + "track_assets_removed": True, + "track_collection_renamed": True, + "track_collection_deleted": True, + "track_sharing_changed": False, + }, + { + "provider_type": "google_photos", + "name": "Default Google Photos", + "track_assets_added": True, + "track_assets_removed": True, + "track_collection_renamed": True, + "track_collection_deleted": True, + "track_sharing_changed": False, + }, { "provider_type": "nut", "name": "Default NUT",