feat: test menu dropdown, split text/media messages, target settings, provider URL links

- Replace 3 test buttons with unified dropdown menu (basic/periodic/scheduled/memory)
- Send text message first, then assets as reply (not combined caption+media)
- Pass all target config settings to Telegram client (disable_url_preview, max_media, chunk_delay, etc.)
- Real data test notifications for periodic/scheduled/memory (fetch from Immich)
- Provider card URL is now a clickable hyperlink
- Localized test type labels (EN/RU)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 16:34:25 +03:00
parent 03c5c66eed
commit 5015e378fe
9 changed files with 407 additions and 78 deletions
+10 -2
View File
@@ -59,7 +59,11 @@
"oldestFirst": "Oldest first", "oldestFirst": "Oldest first",
"loadingEvents": "Loading events...", "loadingEvents": "Loading events...",
"asset": "asset", "asset": "asset",
"assets": "assets" "assets": "assets",
"eventActivity": "Event Activity",
"last14days": "Last 14 days",
"events": "events",
"noChartData": "No event data yet"
}, },
"providers": { "providers": {
"title": "Providers", "title": "Providers",
@@ -126,7 +130,11 @@
"batchDuration": "Batch duration (seconds)", "batchDuration": "Batch duration (seconds)",
"linkedTargets": "targets", "linkedTargets": "targets",
"noLinkedTargets": "No targets linked. Add a target below.", "noLinkedTargets": "No targets linked. Add a target below.",
"addTarget": "Add target" "addTarget": "Add target",
"testBasic": "Send test message",
"testPeriodic": "Test periodic summary",
"testScheduled": "Test scheduled assets",
"testMemory": "Test memory / On This Day"
}, },
"templates": { "templates": {
"title": "Templates", "title": "Templates",
+10 -2
View File
@@ -59,7 +59,11 @@
"oldestFirst": "Сначала старые", "oldestFirst": "Сначала старые",
"loadingEvents": "Загрузка событий...", "loadingEvents": "Загрузка событий...",
"asset": "файл", "asset": "файл",
"assets": "файлов" "assets": "файлов",
"eventActivity": "Активность событий",
"last14days": "Последние 14 дней",
"events": "событий",
"noChartData": "Нет данных о событиях"
}, },
"providers": { "providers": {
"title": "Провайдеры", "title": "Провайдеры",
@@ -126,7 +130,11 @@
"batchDuration": "Длительность пакета (секунды)", "batchDuration": "Длительность пакета (секунды)",
"linkedTargets": "получатели", "linkedTargets": "получатели",
"noLinkedTargets": "Нет привязанных получателей. Добавьте получателя ниже.", "noLinkedTargets": "Нет привязанных получателей. Добавьте получателя ниже.",
"addTarget": "Добавить получателя" "addTarget": "Добавить получателя",
"testBasic": "Отправить тестовое сообщение",
"testPeriodic": "Тест периодической сводки",
"testScheduled": "Тест запланированных фото",
"testMemory": "Тест воспоминаний"
}, },
"templates": { "templates": {
"title": "Шаблоны", "title": "Шаблоны",
+10 -2
View File
@@ -6,9 +6,11 @@
import Card from '$lib/components/Card.svelte'; import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import EventChart from '$lib/components/EventChart.svelte';
let status = $state<any>(null); let status = $state<any>(null);
let providers = $state<any[]>([]); let providers = $state<any[]>([]);
let chartDays = $state<any[]>([]);
let loaded = $state(false); let loaded = $state(false);
let error = $state(''); let error = $state('');
@@ -33,7 +35,7 @@
function calcPageSize(): number { function calcPageSize(): number {
if (typeof window === 'undefined') return 8; if (typeof window === 'undefined') return 8;
const EVENT_ROW_HEIGHT = 50; // px per event row (content + gap) const EVENT_ROW_HEIGHT = 50; // px per event row (content + gap)
const FIXED_OVERHEAD = 390; // header + stats + events header + filters + paginator + padding const FIXED_OVERHEAD = 600; // header + stats + chart + events header + filters + paginator + padding
const available = window.innerHeight - FIXED_OVERHEAD; const available = window.innerHeight - FIXED_OVERHEAD;
return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT)); return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT));
} }
@@ -108,10 +110,14 @@
async function loadInitial() { async function loadInitial() {
try { try {
[status, providers] = await Promise.all([ const [statusRes, providersRes, chartRes] = await Promise.all([
api<any>(`/status?limit=${eventsLimit}`), api<any>(`/status?limit=${eventsLimit}`),
api<any[]>('/providers'), api<any[]>('/providers'),
api<any>('/status/chart'),
]); ]);
status = statusRes;
providers = providersRes;
chartDays = chartRes.days || [];
setTimeout(() => { setTimeout(() => {
animateCount(0, status.providers, (v) => displayProviders = v); animateCount(0, status.providers, (v) => displayProviders = v);
animateCount(0, status.trackers.active, (v) => displayActive = v); animateCount(0, status.trackers.active, (v) => displayActive = v);
@@ -200,6 +206,8 @@
{/each} {/each}
</div> </div>
<EventChart days={chartDays} />
<h3 class="text-base font-semibold mb-3 flex items-center gap-2"> <h3 class="text-base font-semibold mb-3 flex items-center gap-2">
<MdiIcon name="mdiPulse" size={18} /> <MdiIcon name="mdiPulse" size={18} />
{t('dashboard.recentEvents')} {t('dashboard.recentEvents')}
+3 -1
View File
@@ -161,7 +161,9 @@
<p class="font-medium">{provider.name}</p> <p class="font-medium">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span> <span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
</div> </div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config?.url || ''}</p> {#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
{/if}
</div> </div>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
+47 -11
View File
@@ -176,13 +176,23 @@
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
confirmDelete = null; confirmDelete = null;
} }
let testMenuOpen = $state<string | null>(null);
let testMenuStyle = $state('');
const testTypes = [
{ key: 'basic', icon: 'mdiSend', labelKey: 'trackers.testBasic' },
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'trackers.testPeriodic' },
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'trackers.testScheduled' },
{ key: 'memory', icon: 'mdiHistory', labelKey: 'trackers.testMemory' },
];
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) { async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
testMenuOpen = null;
const key = `${ttId}_${testType}`; const key = `${ttId}_${testType}`;
if (ttTesting[key]) return; if (ttTesting[key]) return;
ttTesting = { ...ttTesting, [key]: testType }; ttTesting = { ...ttTesting, [key]: testType };
try { try {
const endpoint = testType === 'basic' ? 'test' : `test-${testType}`; await api(`/trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}?locale=${getLocale()}`, { method: 'POST' });
snackSuccess(t('snack.targetTestSent')); snackSuccess(t('snack.targetTestSent'));
} catch (err: any) { } catch (err: any) {
snackError(err.message); snackError(err.message);
@@ -190,6 +200,13 @@
ttTesting = { ...ttTesting, [key]: '' }; ttTesting = { ...ttTesting, [key]: '' };
} }
} }
function openTestMenu(ttId: number, event: MouseEvent) {
const btn = event.currentTarget as HTMLElement;
const rect = btn.getBoundingClientRect();
testMenuStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; right:${window.innerWidth - rect.right}px;`;
testMenuOpen = testMenuOpen === String(ttId) ? null : String(ttId);
}
function toggleCollection(collectionId: string) { form.collection_ids = form.collection_ids.includes(collectionId) ? form.collection_ids.filter(id => id !== collectionId) : [...form.collection_ids, collectionId]; } function toggleCollection(collectionId: string) { form.collection_ids = form.collection_ids.includes(collectionId) ? form.collection_ids.filter(id => id !== collectionId) : [...form.collection_ids, collectionId]; }
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
@@ -390,15 +407,11 @@
<option value={0}>— {t('templateConfig.title')} —</option> <option value={0}>— {t('templateConfig.title')} —</option>
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each} {#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select> </select>
<IconButton icon="mdiSend" size={14} title={t('common.test')} <div class="relative">
onclick={() => testTrackerTarget(tracker.id, tt.id, 'basic')} <IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
disabled={!!ttTesting[`${tt.id}_basic`]} /> onclick={(e: MouseEvent) => openTestMenu(tt.id, e)}
<IconButton icon="mdiCalendarClock" size={14} title={t('trackingConfig.periodicSummary')} disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
onclick={() => testTrackerTarget(tracker.id, tt.id, 'periodic')} </div>
disabled={!!ttTesting[`${tt.id}_periodic`]} />
<IconButton icon="mdiHistory" size={14} title={t('trackingConfig.memoryMode')}
onclick={() => testTrackerTarget(tracker.id, tt.id, 'memory')}
disabled={!!ttTesting[`${tt.id}_memory`]} />
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14} <IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
title={tt.enabled ? t('trackers.pause') : t('trackers.resume')} title={tt.enabled ? t('trackers.pause') : t('trackers.resume')}
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} /> onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
@@ -442,6 +455,29 @@
{/if} {/if}
{/if} {/if}
{#if testMenuOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
onclick={() => testMenuOpen = null}
onkeydown={(e) => { if (e.key === 'Escape') testMenuOpen = null; }}>
</div>
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
{#each testTypes as tt}
{@const trackerId = trackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id}
<button
onclick={() => trackerId && testTrackerTarget(trackerId, Number(testMenuOpen), tt.key)}
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
<MdiIcon name={tt.icon} size={14} />
{t(tt.labelKey)}
{#if ttTesting[`${testMenuOpen}_${tt.key}`]}
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
{/if}
</button>
{/each}
</div>
{/if}
{#if linkWarning} {#if linkWarning}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998; background:rgba(0,0,0,0.5);" <div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998; background:rgba(0,0,0,0.5);"
@@ -91,6 +91,14 @@ class NotificationDispatcher:
) -> dict[str, Any]: ) -> dict[str, Any]:
bot_token = target.config.get("bot_token") bot_token = target.config.get("bot_token")
chat_id = target.config.get("chat_id") 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)
chunk_delay = target.config.get("media_delay", 500)
max_size = target.config.get("max_asset_size")
if max_size:
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: if not bot_token or not chat_id:
return {"success": False, "error": "Missing bot_token or chat_id"} return {"success": False, "error": "Missing bot_token or chat_id"}
@@ -98,15 +106,23 @@ class NotificationDispatcher:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
client = TelegramClient(session, bot_token) client = TelegramClient(session, bot_token)
# Build asset list for media sending # Step 1: Send the text message first
# Attach API key header for URLs pointing to the provider (internal or external) text_result = await client.send_message(
chat_id=str(chat_id),
text=message,
disable_web_page_preview=disable_preview or None,
)
if not text_result.get("success"):
return text_result
# Step 2: Send assets as reply to the text message
provider_urls = [] provider_urls = []
if target.provider_internal_url: if target.provider_internal_url:
provider_urls.append(target.provider_internal_url) provider_urls.append(target.provider_internal_url)
if target.provider_external_url: if target.provider_external_url:
provider_urls.append(target.provider_external_url) provider_urls.append(target.provider_external_url)
assets = [] assets = []
for asset in event.added_assets: for asset in event.added_assets[:max_media]:
url = asset.full_url or asset.thumbnail_url url = asset.full_url or asset.thumbnail_url
if url: if url:
asset_type = "video" if asset.type.value == "video" else "photo" asset_type = "video" if asset.type.value == "video" else "photo"
@@ -115,11 +131,21 @@ class NotificationDispatcher:
asset_headers["x-api-key"] = target.provider_api_key asset_headers["x-api-key"] = target.provider_api_key
assets.append({"url": url, "type": asset_type, "headers": asset_headers}) assets.append({"url": url, "type": asset_type, "headers": asset_headers})
return await client.send_notification( if assets:
chat_id=str(chat_id), reply_to = text_result.get("message_id")
caption=message, media_result = await client.send_notification(
assets=assets if assets else None, 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: %s", media_result.get("error"))
return text_result
async def _send_webhook( async def _send_webhook(
self, target: TargetConfig, message: str, event: ServiceEvent self, target: TargetConfig, message: str, event: ServiceEvent
@@ -1,5 +1,7 @@
"""Status/dashboard API route.""" """Status/dashboard API route."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlmodel import func, select from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -89,3 +91,46 @@ async def get_status(
for e in recent_events.all() for e in recent_events.all()
], ],
} }
@router.get("/chart")
async def get_event_chart(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
days: int = Query(14, ge=1, le=90),
):
"""Return daily event counts by type for the last N days."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
day_col = func.date(EventLog.created_at)
query = (
select(
day_col.label("day"),
EventLog.event_type,
func.count().label("count"),
)
.join(Tracker, EventLog.tracker_id == Tracker.id)
.where(Tracker.user_id == user.id, EventLog.created_at >= cutoff)
.group_by(day_col, EventLog.event_type)
.order_by(day_col)
)
rows = (await session.exec(query)).all()
# Build a dict: { "2026-03-15": { "assets_added": 3, ... }, ... }
by_day: dict[str, dict[str, int]] = {}
for row in rows:
day_str = str(row.day)
if day_str not in by_day:
by_day[day_str] = {}
by_day[day_str][row.event_type] = row.count
# Fill in missing days so the frontend gets a continuous series
result = []
for i in range(days):
d = (datetime.now(timezone.utc) - timedelta(days=days - 1 - i)).strftime("%Y-%m-%d")
counts = by_day.get(d, {})
result.append({"date": d, **counts})
return {"days": result}
@@ -11,6 +11,7 @@ from ..auth.dependencies import get_current_user
from ..database.engine import get_session from ..database.engine import get_session
from ..database.models import ( from ..database.models import (
NotificationTarget, NotificationTarget,
ServiceProvider,
TemplateConfig, TemplateConfig,
Tracker, Tracker,
TrackerTarget, TrackerTarget,
@@ -148,16 +149,24 @@ async def delete_tracker_target(
await session.commit() await session.commit()
@router.post("/{tracker_target_id}/test") @router.post("/{tracker_target_id}/test/{test_type}")
async def test_tracker_target( async def test_tracker_target(
tracker_id: int, tracker_id: int,
tracker_target_id: int, tracker_target_id: int,
test_type: str,
locale: str = Query("en"), locale: str = Query("en"),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""Send a test notification to a specific linked target.""" """Send a test notification using real provider data.
await _get_user_tracker(session, tracker_id, user.id)
test_type: basic | periodic | scheduled | memory
"""
valid_types = {"basic", "periodic", "scheduled", "memory"}
if test_type not in valid_types:
raise HTTPException(status_code=400, detail=f"Invalid test type. Must be one of: {', '.join(valid_types)}")
tracker = await _get_user_tracker(session, tracker_id, user.id)
tt = await session.get(TrackerTarget, tracker_target_id) tt = await session.get(TrackerTarget, tracker_target_id)
if not tt or tt.tracker_id != tracker_id: if not tt or tt.tracker_id != tracker_id:
raise HTTPException(status_code=404, detail="Tracker-target link not found") raise HTTPException(status_code=404, detail="Tracker-target link not found")
@@ -166,62 +175,42 @@ async def test_tracker_target(
if not target: if not target:
raise HTTPException(status_code=404, detail="Target not found") raise HTTPException(status_code=404, detail="Target not found")
from ..services.notifier import send_test_notification if test_type == "basic":
r = await send_test_notification(target, locale=locale) from ..services.notifier import send_test_notification
return {"target": target.name, **r} r = await send_test_notification(target, locale=locale)
return {"target": target.name, **r}
@router.post("/{tracker_target_id}/test-periodic")
async def test_periodic_tracker_target(
tracker_id: int,
tracker_target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test periodic summary to a specific linked target."""
await _get_user_tracker(session, tracker_id, user.id)
tt = await session.get(TrackerTarget, tracker_target_id)
if not tt or tt.tracker_id != tracker_id:
raise HTTPException(status_code=404, detail="Tracker-target link not found")
target = await session.get(NotificationTarget, tt.target_id)
if not target:
raise HTTPException(status_code=404, detail="Target not found")
# For periodic/scheduled/memory — fetch real data from provider
template_config = None template_config = None
if tt.template_config_id: if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id) template_config = await session.get(TemplateConfig, tt.template_config_id)
template_str = (template_config.periodic_summary_message if template_config else "") or ""
from ..services.notifier import send_test_template_notification slot_map = {
r = await send_test_template_notification(target, "periodic_summary", template_str) "periodic": "periodic_summary_message",
return {"target": target.name, **r} "scheduled": "scheduled_assets_message",
"memory": "memory_mode_message",
}
template_str = getattr(template_config, slot_map[test_type], "") if template_config else ""
# Load provider and tracker data eagerly before aiohttp context
provider = await session.get(ServiceProvider, tracker.provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
provider_config = dict(provider.config)
collection_ids = list(tracker.collection_ids or [])
@router.post("/{tracker_target_id}/test-memory") # Fetch real data from provider
async def test_memory_tracker_target( from ..services.notifier import send_real_data_notification
tracker_id: int, r = await send_real_data_notification(
tracker_target_id: int, target=target,
user: User = Depends(get_current_user), template_str=template_str,
session: AsyncSession = Depends(get_session), test_type=test_type,
): provider_type=provider.type,
"""Send a test memory/on-this-day notification to a specific linked target.""" provider_config=provider_config,
await _get_user_tracker(session, tracker_id, user.id) collection_ids=collection_ids,
tt = await session.get(TrackerTarget, tracker_target_id) date_format=template_config.date_format if template_config else "%d.%m.%Y, %H:%M UTC",
if not tt or tt.tracker_id != tracker_id: date_only_format=template_config.date_only_format if template_config and hasattr(template_config, "date_only_format") else "%d.%m.%Y",
raise HTTPException(status_code=404, detail="Tracker-target link not found") )
target = await session.get(NotificationTarget, tt.target_id)
if not target:
raise HTTPException(status_code=404, detail="Target not found")
template_config = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
template_str = (template_config.memory_mode_message if template_config else "") or ""
from ..services.notifier import send_test_template_notification
r = await send_test_template_notification(target, "memory_mode", template_str)
return {"target": target.name, **r} return {"target": target.name, **r}
@@ -118,3 +118,210 @@ async def _test_webhook(target: NotificationTarget, locale: str = "en") -> dict:
"message": _get_test_message(locale, "webhook"), "message": _get_test_message(locale, "webhook"),
"event_type": "test", "event_type": "test",
}) })
async def send_real_data_notification(
target: NotificationTarget,
template_str: str,
test_type: str,
provider_type: str,
provider_config: dict,
collection_ids: list[str],
date_format: str = "%d.%m.%Y, %H:%M UTC",
date_only_format: str = "%d.%m.%Y",
) -> dict:
"""Fetch real data from provider, render template, and send notification."""
from datetime import datetime, timezone
from jinja2.sandbox import SandboxedEnvironment
if not template_str:
return {"success": False, "error": f"No template configured for {test_type}"}
# Fetch real data from provider
ctx: dict = {}
try:
ctx = await _build_real_context(
provider_type, provider_config, collection_ids,
test_type, date_format, date_only_format,
)
except Exception as e:
_LOGGER.error("Failed to fetch real data for test: %s", e)
return {"success": False, "error": f"Failed to fetch provider data: {e}"}
ctx["target_type"] = target.type
ctx["date_format"] = date_format
ctx["date_only_format"] = date_only_format
# Render template
try:
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_str)
message = tmpl.render(**ctx)
except Exception as e:
return {"success": False, "error": f"Template render error: {e}"}
# Send
try:
if target.type == "telegram":
return await _test_telegram_with_message(target, message)
elif target.type == "webhook":
return await _test_webhook_with_message(target, message)
return {"success": False, "error": f"Unknown target type: {target.type}"}
except Exception as e:
_LOGGER.error("Test notification failed: %s", e)
return {"success": False, "error": str(e)}
async def _build_real_context(
provider_type: str,
provider_config: dict,
collection_ids: list[str],
test_type: str,
date_format: str,
date_only_format: str,
) -> dict:
"""Build template context from real provider data."""
from datetime import datetime, timezone
if provider_type != "immich":
return {"date": datetime.now(timezone.utc).strftime(date_only_format)}
from notify_bridge_core.providers.immich import ImmichServiceProvider
async with aiohttp.ClientSession() as http_session:
immich = ImmichServiceProvider(
http_session,
provider_config.get("url", ""),
provider_config.get("api_key", ""),
provider_config.get("external_domain"),
"Immich",
)
connected = await immich.connect()
if not connected:
raise RuntimeError("Failed to connect to Immich")
# Fetch album data for all tracked collections
collections = []
all_assets = []
for album_id in collection_ids:
album = await immich.client.get_album(album_id)
if not album:
continue
# Get shared link for public URL
shared_links = await immich.client.get_shared_links(album_id)
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
album_public_url = ""
for link in shared_links:
if link.is_accessible and not link.is_expired and not link.has_password:
album_public_url = f"{ext_domain.rstrip('/')}/share/{link.key}"
break
collections.append({
"name": album.name,
"url": album_public_url or f"{ext_domain.rstrip('/')}/albums/{album_id}",
"public_url": album_public_url,
"asset_count": album.asset_count,
"shared": album.shared,
"photo_count": album.photo_count,
"video_count": album.video_count,
"owner": album.owner,
})
# Collect assets (limited sample)
for asset_id, asset in list(album.assets.items())[:10]:
asset_public_url = f"{album_public_url}/photos/{asset_id}" if album_public_url else ""
all_assets.append({
"id": asset.id,
"filename": asset.filename,
"type": asset.type.upper(),
"created_at": asset.created_at,
"owner": asset.owner_name or "",
"description": asset.description or "",
"people": list(asset.people),
"is_favorite": asset.is_favorite,
"rating": asset.rating,
"city": asset.city or "",
"state": asset.state or "",
"country": asset.country or "",
"public_url": asset_public_url,
"url": f"{ext_domain.rstrip('/')}/api/assets/{asset.id}/original",
"photo_url": f"{ext_domain.rstrip('/')}/api/assets/{asset.id}/thumbnail",
})
# Build context based on test type
now = datetime.now(timezone.utc)
ctx: dict = {
"date": now.strftime(date_only_format),
"timestamp": now.isoformat(),
"service_name": "Immich",
"service_type": "immich",
"collections": collections,
"albums": collections, # alias
"assets": all_assets,
}
# Common date/location for assets
if len(all_assets) > 1:
dates = set()
for a in all_assets:
ca = a.get("created_at", "")
if ca:
dates.add(ca[:10])
if len(dates) == 1:
try:
ctx["common_date"] = datetime.fromisoformat(dates.pop()).strftime(date_only_format)
except (ValueError, TypeError):
ctx["common_date"] = ""
else:
ctx["common_date"] = ""
locations = set()
for a in all_assets:
city = a.get("city", "")
country = a.get("country", "")
if city:
locations.add(f"{city}, {country}" if country else city)
else:
locations.add("")
if len(locations) == 1 and "" not in locations:
ctx["common_location"] = locations.pop()
else:
ctx["common_location"] = ""
else:
ctx["common_date"] = ""
ctx["common_location"] = ""
# Add first collection details as top-level for periodic-style templates
if collections:
first = collections[0]
ctx.update({
"collection_name": first["name"],
"album_name": first["name"],
"public_url": first.get("public_url", ""),
"album_url": first.get("url", ""),
"shared": first.get("shared", False),
"photo_count": first.get("photo_count", 0),
"video_count": first.get("video_count", 0),
"owner": first.get("owner", ""),
})
else:
ctx.update({
"collection_name": "", "album_name": "",
"public_url": "", "album_url": "",
"shared": False, "photo_count": 0, "video_count": 0, "owner": "",
})
# People across all assets
people = set()
for a in all_assets:
people.update(a.get("people", []))
ctx["people"] = list(people)
ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in all_assets)
ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in all_assets)
ctx["added_count"] = len(all_assets)
ctx["added_assets"] = all_assets
ctx["protected_url"] = ""
return ctx