feat: UX & notification improvements — icons, events, chat names, link validation, templates
- Show entity icons on all cards with fallback defaults (providers, trackers, targets, bots)
- Enrich EventLog with provider_name, tracker_name, assets_count; add DB migration
- Dashboard events: filtering (type, provider, search), sorting, pagination, dynamic page size
- Friendly chat names on telegram target cards (resolve from TelegramChat table)
- Test message button on bot chat items with locale-aware messages
- Album public link validation on tracker save with auto-create dialog
- Support albums without public links: conditional <a href> in templates
- Fetch shared links during poll, enrich events with public_url/protected_url
- Per-asset public_url in template context ({share_url}/photos/{asset_id})
- Common date/location detection: common_date + common_location context vars
- Dual date formats: date_format (datetime) + date_only_format (date only)
- Template clone button, HTML link rendering in template preview
- Fix Telegram asset download 401: pass x-api-key headers through client
- Fix provider external_url matching for API key scoping
- Fix event timestamp timezone (append Z suffix for UTC)
- Localize event filter controls, test messages (EN/RU)
- Template variable UI helpers updated with all new fields
- CLAUDE.md: template system sync rules documentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,3 +52,12 @@ NotificationTarget → template_config_id, type: "telegram"/"webhook", config: J
|
||||
- `user_id=0` on TemplateConfig = system default (EN/RU seeded on first startup)
|
||||
- DB: SQLite + async SQLAlchemy via sqlmodel, auto-created on startup
|
||||
- API: All CRUD routes under `/api/`, auth via JWT Bearer, `NOTIFY_BRIDGE_` env prefix
|
||||
|
||||
## Template System Sync Rules
|
||||
|
||||
**IMPORTANT**: When adding or changing template context variables, you MUST update ALL of these in sync:
|
||||
1. **`packages/core/.../templates/context.py`** — `build_template_context()` where variables are computed
|
||||
2. **`packages/server/.../api/template_configs.py`** — `_SAMPLE_CONTEXT` dict (for preview rendering)
|
||||
3. **`packages/server/.../api/template_configs.py`** — `get_template_variables()` endpoint (`event_vars`, `asset_fields`, `album_fields`, `scheduled_vars`, per-slot variable dicts)
|
||||
4. **`packages/core/.../templates/defaults/{en,ru}/*.jinja2`** — default template files using the new variables
|
||||
5. **`packages/core/.../providers/immich/provider.py`** — `IMMICH_VARIABLES` list (provider-specific variable definitions)
|
||||
|
||||
@@ -46,7 +46,20 @@
|
||||
"assetsRemoved": "assets removed",
|
||||
"collectionRenamed": "collection renamed",
|
||||
"collectionDeleted": "collection deleted",
|
||||
"sharingChanged": "sharing changed"
|
||||
"sharingChanged": "sharing changed",
|
||||
"searchEvents": "Search events...",
|
||||
"allEvents": "All Events",
|
||||
"filterAssetsAdded": "Assets Added",
|
||||
"filterAssetsRemoved": "Assets Removed",
|
||||
"filterRenamed": "Renamed",
|
||||
"filterDeleted": "Deleted",
|
||||
"filterSharingChanged": "Sharing Changed",
|
||||
"allProviders": "All Providers",
|
||||
"newestFirst": "Newest first",
|
||||
"oldestFirst": "Oldest first",
|
||||
"loadingEvents": "Loading events...",
|
||||
"asset": "asset",
|
||||
"assets": "assets"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Providers",
|
||||
@@ -284,7 +297,8 @@
|
||||
"moreMessage": "More message",
|
||||
"peopleFormat": "People format",
|
||||
"dateLocation": "Date & Location",
|
||||
"dateFormat": "Date format",
|
||||
"dateFormat": "Date & time format",
|
||||
"dateOnlyFormat": "Date only format",
|
||||
"commonDate": "Common date",
|
||||
"uniqueDate": "Per-asset date",
|
||||
"locationFormat": "Location format",
|
||||
@@ -417,6 +431,7 @@
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"clone": "Clone",
|
||||
"edit": "Edit",
|
||||
"description": "Description",
|
||||
"close": "Close",
|
||||
|
||||
@@ -46,7 +46,20 @@
|
||||
"assetsRemoved": "удалены файлы",
|
||||
"collectionRenamed": "альбом переименован",
|
||||
"collectionDeleted": "альбом удалён",
|
||||
"sharingChanged": "изменение доступа"
|
||||
"sharingChanged": "изменение доступа",
|
||||
"searchEvents": "Поиск событий...",
|
||||
"allEvents": "Все события",
|
||||
"filterAssetsAdded": "Добавление файлов",
|
||||
"filterAssetsRemoved": "Удаление файлов",
|
||||
"filterRenamed": "Переименование",
|
||||
"filterDeleted": "Удаление",
|
||||
"filterSharingChanged": "Изменение доступа",
|
||||
"allProviders": "Все провайдеры",
|
||||
"newestFirst": "Сначала новые",
|
||||
"oldestFirst": "Сначала старые",
|
||||
"loadingEvents": "Загрузка событий...",
|
||||
"asset": "файл",
|
||||
"assets": "файлов"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Провайдеры",
|
||||
@@ -284,7 +297,8 @@
|
||||
"moreMessage": "Сообщение \"ещё\"",
|
||||
"peopleFormat": "Формат людей",
|
||||
"dateLocation": "Дата и место",
|
||||
"dateFormat": "Формат даты",
|
||||
"dateFormat": "Формат даты и времени",
|
||||
"dateOnlyFormat": "Формат даты",
|
||||
"commonDate": "Общая дата",
|
||||
"uniqueDate": "Дата файла",
|
||||
"locationFormat": "Формат места",
|
||||
@@ -417,6 +431,7 @@
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
"clone": "Копировать",
|
||||
"edit": "Редактировать",
|
||||
"description": "Описание",
|
||||
"close": "Закрыть",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let status = $state<any>(null);
|
||||
let providers = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
@@ -16,6 +17,27 @@
|
||||
let displayTotal = $state(0);
|
||||
let displayTargets = $state(0);
|
||||
|
||||
// Event filters
|
||||
let filterEventType = $state('');
|
||||
let filterProviderId = $state('');
|
||||
let filterSearch = $state('');
|
||||
let filterSort = $state('newest');
|
||||
let eventsLimit = $state(calcPageSize());
|
||||
let eventsOffset = $state(0);
|
||||
let eventsLoading = $state(false);
|
||||
|
||||
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
|
||||
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
|
||||
|
||||
/** Calculate how many event rows fit in the remaining viewport space. */
|
||||
function calcPageSize(): number {
|
||||
if (typeof window === 'undefined') return 8;
|
||||
const EVENT_ROW_HEIGHT = 50; // px per event row (content + gap)
|
||||
const FIXED_OVERHEAD = 390; // header + stats + events header + filters + paginator + padding
|
||||
const available = window.innerHeight - FIXED_OVERHEAD;
|
||||
return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT));
|
||||
}
|
||||
|
||||
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
|
||||
if (to === 0) { setter(0); return; }
|
||||
const start = performance.now();
|
||||
@@ -29,9 +51,67 @@
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
async function loadEvents() {
|
||||
eventsLoading = true;
|
||||
try {
|
||||
status = await api<any>('/status');
|
||||
const params = new URLSearchParams();
|
||||
if (filterEventType) params.set('event_type', filterEventType);
|
||||
if (filterProviderId) params.set('provider_id', filterProviderId);
|
||||
if (filterSearch) params.set('search', filterSearch);
|
||||
params.set('sort', filterSort);
|
||||
params.set('limit', String(eventsLimit));
|
||||
params.set('offset', String(eventsOffset));
|
||||
const qs = params.toString();
|
||||
status = await api<any>(`/status${qs ? '?' + qs : ''}`);
|
||||
} catch (err: any) {
|
||||
error = err.message || t('common.error');
|
||||
} finally {
|
||||
eventsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
eventsOffset = 0;
|
||||
loadEvents();
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
eventsOffset = (page - 1) * eventsLimit;
|
||||
loadEvents();
|
||||
}
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(applyFilters, 300);
|
||||
}
|
||||
|
||||
let resizeTimeout: ReturnType<typeof setTimeout>;
|
||||
function onResize() {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
const newLimit = calcPageSize();
|
||||
if (newLimit !== eventsLimit) {
|
||||
eventsLimit = newLimit;
|
||||
eventsOffset = 0;
|
||||
loadEvents();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
eventsLimit = calcPageSize();
|
||||
window.addEventListener('resize', onResize);
|
||||
loadInitial();
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
async function loadInitial() {
|
||||
try {
|
||||
[status, providers] = await Promise.all([
|
||||
api<any>(`/status?limit=${eventsLimit}`),
|
||||
api<any[]>('/providers'),
|
||||
]);
|
||||
setTimeout(() => {
|
||||
animateCount(0, status.providers, (v) => displayProviders = v);
|
||||
animateCount(0, status.trackers.active, (v) => displayActive = v);
|
||||
@@ -43,7 +123,7 @@
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const statCards = $derived(status ? [
|
||||
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
|
||||
@@ -77,6 +157,15 @@
|
||||
assets_added: '#059669', assets_removed: '#ef4444',
|
||||
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
||||
};
|
||||
|
||||
const eventTypeOptions = $derived([
|
||||
{ value: '', label: t('dashboard.allEvents') },
|
||||
{ value: 'assets_added', label: t('dashboard.filterAssetsAdded') },
|
||||
{ value: 'assets_removed', label: t('dashboard.filterAssetsRemoved') },
|
||||
{ value: 'collection_renamed', label: t('dashboard.filterRenamed') },
|
||||
{ value: 'collection_deleted', label: t('dashboard.filterDeleted') },
|
||||
{ value: 'sharing_changed', label: t('dashboard.filterSharingChanged') },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
||||
@@ -114,8 +203,41 @@
|
||||
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||
<MdiIcon name="mdiPulse" size={18} />
|
||||
{t('dashboard.recentEvents')}
|
||||
{#if status.total_events > 0}
|
||||
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
|
||||
{/if}
|
||||
</h3>
|
||||
{#if status.recent_events.length === 0}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<div class="flex-1 min-w-[150px] max-w-[260px]">
|
||||
<input type="text" bind:value={filterSearch} oninput={onSearchInput}
|
||||
placeholder={t('dashboard.searchEvents')}
|
||||
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<select bind:value={filterEventType} onchange={applyFilters}
|
||||
class="px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
{#each eventTypeOptions as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={filterProviderId} onchange={applyFilters}
|
||||
class="px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="">{t('dashboard.allProviders')}</option>
|
||||
{#each providers as p}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={filterSort} onchange={applyFilters}
|
||||
class="px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="newest">{t('dashboard.newestFirst')}</option>
|
||||
<option value="oldest">{t('dashboard.oldestFirst')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if eventsLoading}
|
||||
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
|
||||
{:else if status.recent_events.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiCalendarBlank" size={40} /></div>
|
||||
@@ -130,19 +252,58 @@
|
||||
{#if i < status.recent_events.length - 1}<div class="event-line"></div>{/if}
|
||||
<div class="event-content">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<span style="color: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; flex-shrink: 0;">
|
||||
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
|
||||
</span>
|
||||
<span class="text-sm font-medium truncate">{event.collection_name}</span>
|
||||
<span class="event-badge">{t(eventLabels[event.event_type] || event.event_type)}</span>
|
||||
{#if event.assets_count > 0}
|
||||
<span class="event-badge" style="background: {eventColors[event.event_type]}20; color: {eventColors[event.event_type]};">{event.assets_count} {event.assets_count === 1 ? t('dashboard.asset') : t('dashboard.assets')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
{#if event.provider_name || event.tracker_name}
|
||||
<div class="flex items-center gap-2 mt-1 text-xs" style="color: var(--color-muted-foreground);">
|
||||
{#if event.provider_name}
|
||||
<span class="flex items-center gap-1"><MdiIcon name="mdiServer" size={12} />{event.provider_name}</span>
|
||||
{/if}
|
||||
{#if event.tracker_name}
|
||||
<span class="flex items-center gap-1"><MdiIcon name="mdiRadar" size={12} />{event.tracker_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Paginator -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-center gap-1 mt-4">
|
||||
<button onclick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1}
|
||||
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
|
||||
<MdiIcon name="mdiChevronLeft" size={16} />
|
||||
</button>
|
||||
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
||||
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
|
||||
<button onclick={() => goToPage(page)}
|
||||
class="px-2.5 py-1 text-xs font-mono rounded-md border transition-colors {page === currentPage
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)] border-[var(--color-primary)]'
|
||||
: 'border-[var(--color-border)] hover:bg-[var(--color-muted)]'}">
|
||||
{page}
|
||||
</button>
|
||||
{:else if page === currentPage - 2 || page === currentPage + 2}
|
||||
<span class="px-1 text-xs text-[var(--color-muted-foreground)]">...</span>
|
||||
{/if}
|
||||
{/each}
|
||||
<button onclick={() => goToPage(currentPage + 1)} disabled={currentPage >= totalPages}
|
||||
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
|
||||
<MdiIcon name="mdiChevronRight" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -155,9 +155,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
|
||||
{#if provider.icon}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={provider.icon} size={20} /></span>
|
||||
{/if}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={provider.icon || 'mdiServer'} size={20} /></span>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{provider.name}</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
async function test(id: number) {
|
||||
try {
|
||||
const res = await api(`/targets/${id}/test`, { method: 'POST' });
|
||||
const res = await api(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||
else snackError(`Failed: ${res.error}`);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
@@ -235,12 +235,16 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if target.icon}<MdiIcon name={target.icon} />{/if}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || (target.type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={20} /></span>
|
||||
<p class="font-medium">{target.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{target.type === 'telegram' ? `Chat: ${target.config?.chat_id || '***'}` : target.config?.url || ''}
|
||||
{#if target.type === 'telegram'}
|
||||
Chat: {#if target.chat_name}{target.chat_name} <span class="font-mono text-xs">({target.config?.chat_id})</span>{:else}{target.config?.chat_id || '***'}{/if}
|
||||
{:else}
|
||||
{target.config?.url || ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
@@ -40,7 +40,7 @@
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/telegram-bots/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name }) });
|
||||
await api(`/telegram-bots/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon }) });
|
||||
snackSuccess(t('snack.botUpdated'));
|
||||
} else {
|
||||
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
||||
@@ -94,12 +94,27 @@
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
let chatTesting = $state<Record<string, boolean>>({});
|
||||
|
||||
function copyChatId(e: Event, chatId: string) {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(chatId);
|
||||
snackInfo(`${t('snack.copied')}: ${chatId}`);
|
||||
}
|
||||
|
||||
async function testChat(e: Event, botId: number, chatId: string) {
|
||||
e.stopPropagation();
|
||||
const key = `${botId}_${chatId}`;
|
||||
if (chatTesting[key]) return;
|
||||
chatTesting = { ...chatTesting, [key]: true };
|
||||
try {
|
||||
const res = await api(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
chatTesting = { ...chatTesting, [key]: false };
|
||||
}
|
||||
|
||||
function chatTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
private: t('telegramBot.private'),
|
||||
@@ -161,7 +176,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if bot.icon}<MdiIcon name={bot.icon} />{/if}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
{#if bot.bot_username}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||
@@ -198,9 +213,14 @@
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiSend" title="Test message" size={14}
|
||||
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
||||
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
const doValidate = async () => {
|
||||
try {
|
||||
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType }) });
|
||||
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 }) });
|
||||
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
|
||||
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
|
||||
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
|
||||
@@ -66,7 +66,7 @@
|
||||
for (const group of templateSlots) {
|
||||
for (const slot of group.slots) {
|
||||
const template = (form as any)[slot.key];
|
||||
if (template && slot.key !== 'date_format') {
|
||||
if (template && slot.key !== 'date_format' && slot.key !== 'date_only_format') {
|
||||
validateSlot(slot.key, template, true);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@
|
||||
scheduled_assets_message: '',
|
||||
memory_mode_message: '',
|
||||
date_format: '%d.%m.%Y, %H:%M UTC',
|
||||
date_only_format: '%d.%m.%Y',
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let previewTargetType = $state('telegram');
|
||||
@@ -103,6 +104,7 @@
|
||||
]},
|
||||
{ group: 'settings', slots: [
|
||||
{ key: 'date_format', label: 'dateFormat', rows: 1 },
|
||||
{ key: 'date_only_format', label: 'dateOnlyFormat', rows: 1 },
|
||||
]},
|
||||
];
|
||||
|
||||
@@ -134,6 +136,33 @@
|
||||
} 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;
|
||||
editing = null;
|
||||
showForm = true;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
function sanitizePreview(html: string): string {
|
||||
// Allow only Telegram-safe HTML tags, escape everything else
|
||||
return html
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Restore allowed tags
|
||||
.replace(/<a href="([^"]*)">/g, '<a href="$1" target="_blank" rel="noopener">')
|
||||
.replace(/<\/a>/g, '</a>')
|
||||
.replace(/<b>/g, '<b>').replace(/<\/b>/g, '</b>')
|
||||
.replace(/<i>/g, '<i>').replace(/<\/i>/g, '</i>')
|
||||
.replace(/<code>/g, '<code>').replace(/<\/code>/g, '</code>')
|
||||
.replace(/<pre>/g, '<pre>').replace(/<\/pre>/g, '</pre>');
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
@@ -198,8 +227,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if slot.key === 'date_format'}
|
||||
{#if slot.key === 'date_format' || slot.key === 'date_only_format'}
|
||||
<input bind:value={(form as any)[slot.key]}
|
||||
oninput={() => { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); }}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
{:else}
|
||||
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v: string) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
|
||||
@@ -211,8 +241,8 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{#if slotPreview[slot.key] && !slotErrors[slot.key]}
|
||||
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
|
||||
<pre class="whitespace-pre-wrap text-xs">{slotPreview[slot.key]}</pre>
|
||||
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm preview-html">
|
||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.key])}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -244,7 +274,7 @@
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if config.icon}<MdiIcon name={config.icon} />{/if}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
|
||||
<p class="font-medium">{config.name}</p>
|
||||
{#if config.user_id === 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">System</span>
|
||||
@@ -255,6 +285,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-4">
|
||||
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
</div>
|
||||
@@ -306,3 +337,13 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
:global(.preview-html a) {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
:global(.preview-html a:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
@@ -29,7 +29,12 @@
|
||||
let toggling = $state<Record<number, boolean>>({});
|
||||
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
|
||||
let ttTesting = $state<Record<string, string>>({});
|
||||
let ttFeedback = $state<Record<string, string>>({});
|
||||
|
||||
// Shared link validation
|
||||
let linkWarning = $state<{ albums: any[], providerId: number } | null>(null);
|
||||
let linkCheckLoading = $state(false);
|
||||
let linkCreating = $state(false);
|
||||
let previousCollectionIds = $state<string[]>([]);
|
||||
|
||||
// Tracker form
|
||||
const defaultForm = () => ({
|
||||
@@ -65,13 +70,14 @@
|
||||
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; }
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
|
||||
async function edit(trk: any) {
|
||||
form = {
|
||||
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,
|
||||
};
|
||||
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||
editing = trk.id; showForm = true;
|
||||
if (form.provider_id) await loadCollections();
|
||||
}
|
||||
@@ -79,6 +85,41 @@
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
if (submitting) return;
|
||||
|
||||
// Check shared links for newly added albums
|
||||
const newAlbumIds = form.collection_ids.filter(id => !previousCollectionIds.includes(id));
|
||||
if (newAlbumIds.length > 0 && form.provider_id) {
|
||||
linkCheckLoading = true;
|
||||
try {
|
||||
const missingAlbums: any[] = [];
|
||||
for (const albumId of newAlbumIds) {
|
||||
const links = await api(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
|
||||
const validLink = (links as any[]).find((l: any) => l.is_accessible && !l.is_expired);
|
||||
if (!validLink) {
|
||||
const album = collections.find(c => c.id === albumId);
|
||||
const problematicLink = (links as any[]).find((l: any) => l.is_expired || l.has_password);
|
||||
missingAlbums.push({
|
||||
id: albumId,
|
||||
name: album?.albumName || album?.name || albumId,
|
||||
issue: problematicLink
|
||||
? (problematicLink.is_expired ? 'expired' : 'password-protected')
|
||||
: 'missing',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (missingAlbums.length > 0) {
|
||||
linkWarning = { albums: missingAlbums, providerId: form.provider_id };
|
||||
linkCheckLoading = false;
|
||||
return; // Show warning, don't save yet
|
||||
}
|
||||
} catch { /* Proceed if check fails */ }
|
||||
linkCheckLoading = false;
|
||||
}
|
||||
|
||||
await doSave();
|
||||
}
|
||||
|
||||
async function doSave() {
|
||||
submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
@@ -88,9 +129,34 @@
|
||||
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.trackerCreated'));
|
||||
}
|
||||
showForm = false; editing = null; await load();
|
||||
showForm = false; editing = null; linkWarning = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
|
||||
}
|
||||
|
||||
async function autoCreateLinks() {
|
||||
if (!linkWarning) return;
|
||||
linkCreating = true;
|
||||
let created = 0;
|
||||
for (const album of linkWarning.albums) {
|
||||
if (album.issue === 'missing') {
|
||||
try {
|
||||
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
|
||||
created++;
|
||||
} catch (err: any) {
|
||||
snackError(`Failed to create link for "${album.name}": ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (created > 0) snackSuccess(`Created ${created} public link(s)`);
|
||||
linkWarning = null;
|
||||
linkCreating = false;
|
||||
await doSave();
|
||||
}
|
||||
|
||||
function dismissLinkWarning() {
|
||||
linkWarning = null;
|
||||
doSave();
|
||||
}
|
||||
async function toggle(tracker: any) {
|
||||
if (toggling[tracker.id]) return;
|
||||
toggling = { ...toggling, [tracker.id]: true };
|
||||
@@ -114,22 +180,26 @@
|
||||
const key = `${ttId}_${testType}`;
|
||||
if (ttTesting[key]) return;
|
||||
ttTesting = { ...ttTesting, [key]: testType };
|
||||
ttFeedback = { ...ttFeedback, [key]: '' };
|
||||
try {
|
||||
const endpoint = testType === 'basic' ? 'test' : `test-${testType}`;
|
||||
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}`, { method: 'POST' });
|
||||
ttFeedback = { ...ttFeedback, [key]: 'ok' };
|
||||
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}?locale=${getLocale()}`, { method: 'POST' });
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
} catch (err: any) {
|
||||
ttFeedback = { ...ttFeedback, [key]: 'error' };
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
ttTesting = { ...ttTesting, [key]: '' };
|
||||
setTimeout(() => { ttFeedback = { ...ttFeedback, [key]: '' }; }, 3000);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
// --- Linked Targets Management ---
|
||||
function toggleExpand(trackerId: number) {
|
||||
if (expandedTracker === trackerId) { expandedTracker = null; return; }
|
||||
@@ -227,6 +297,9 @@
|
||||
<input type="checkbox" checked={form.collection_ids.includes(col.id)} onchange={() => toggleCollection(col.id)} />
|
||||
{col.albumName || col.name} <span class="text-[var(--color-muted-foreground)]">({col.assetCount ?? col.asset_count ?? 0})</span>
|
||||
</span>
|
||||
{#if col.updatedAt || col.updated_at}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] whitespace-nowrap ml-2">{formatDate(col.updatedAt || col.updated_at)}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -243,7 +316,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">{editing ? t('common.save') : t('trackers.createTracker')}</button>
|
||||
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{#if linkCheckLoading}Checking links...{:else}{editing ? t('common.save') : t('trackers.createTracker')}{/if}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -264,7 +339,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if tracker.icon}<MdiIcon name={tracker.icon} />{/if}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
|
||||
<p class="font-medium">{tracker.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||
@@ -295,7 +370,7 @@
|
||||
{#each tracker.tracker_targets as tt}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if tt.target_icon}<MdiIcon name={tt.target_icon} size={16} />{/if}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || (tt.target_type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={16} /></span>
|
||||
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
|
||||
{#if !tt.enabled}
|
||||
@@ -324,13 +399,6 @@
|
||||
<IconButton icon="mdiHistory" size={14} title={t('trackingConfig.memoryMode')}
|
||||
onclick={() => testTrackerTarget(tracker.id, tt.id, 'memory')}
|
||||
disabled={!!ttTesting[`${tt.id}_memory`]} />
|
||||
{#each ['basic', 'periodic', 'memory'] as testType}
|
||||
{#if ttFeedback[`${tt.id}_${testType}`]}
|
||||
<span class="text-xs {ttFeedback[`${tt.id}_${testType}`] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">
|
||||
{ttFeedback[`${tt.id}_${testType}`] === 'ok' ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
||||
title={tt.enabled ? t('trackers.pause') : t('trackers.resume')}
|
||||
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
|
||||
@@ -374,6 +442,48 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if linkWarning}
|
||||
<!-- 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);"
|
||||
onclick={() => { linkWarning = null; }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') linkWarning = null; }}>
|
||||
</div>
|
||||
<div style="position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999; width:28rem; max-width:90vw; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1.5rem; box-shadow:0 20px 60px rgba(0,0,0,0.4);">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span style="color: var(--color-warning-fg);"><MdiIcon name="mdiAlertCircle" size={22} /></span>
|
||||
<h3 class="font-semibold">Albums Missing Public Links</h3>
|
||||
</div>
|
||||
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
|
||||
The following albums don't have valid public shared links. Without public links, notification messages won't include clickable URLs to albums or assets.
|
||||
</p>
|
||||
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
|
||||
{#each linkWarning.albums as album}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<span class="font-medium">{album.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{album.issue === 'expired' ? 'Expired' : album.issue === 'password-protected' ? 'Password Protected' : 'No Link'}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiInformation" size={14} /> Public links allow anyone with the URL to view album contents. Albums without links will still be tracked and assets sent to chats, but messages won't include clickable links.
|
||||
</p>
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<button onclick={dismissLinkWarning}
|
||||
class="px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
|
||||
Save without links
|
||||
</button>
|
||||
{#if linkWarning.albums.some(a => a.issue === 'missing')}
|
||||
<button onclick={autoCreateLinks} disabled={linkCreating}
|
||||
class="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
|
||||
{linkCreating ? 'Creating...' : `Create ${linkWarning.albums.filter(a => a.issue === 'missing').length} link(s)`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
message={t('trackers.confirmDelete')}
|
||||
|
||||
@@ -18,7 +18,9 @@ from .webhook.client import WebhookClient
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TEMPLATE = (
|
||||
'{{ added_count }} new item(s) added to "{{ collection_name }}".'
|
||||
'{{ added_count }} new item(s) added to '
|
||||
'{% if public_url %}<a href="{{ public_url }}">{{ collection_name }}</a>'
|
||||
'{% else %}"{{ collection_name }}"{% endif %}.'
|
||||
'{% if people %}\nPeople: {{ people | join(", ") }}{% endif %}'
|
||||
)
|
||||
|
||||
@@ -30,8 +32,11 @@ class TargetConfig:
|
||||
type: str # "telegram" or "webhook"
|
||||
config: dict[str, Any] # type-specific config
|
||||
template_slots: dict[str, str] | None = None # event_type -> template string
|
||||
date_format: str = "%d.%m.%Y, %H:%M UTC"
|
||||
date_only_format: str = "%d.%m.%Y"
|
||||
provider_api_key: str | None = None # API key for downloading assets from provider
|
||||
provider_internal_url: str | None = None # Internal provider URL for API key scoping
|
||||
provider_external_url: str | None = None # External domain for API key scoping
|
||||
|
||||
|
||||
class NotificationDispatcher:
|
||||
@@ -68,7 +73,11 @@ class NotificationDispatcher:
|
||||
template_str = slot
|
||||
|
||||
# Build context and render
|
||||
ctx = build_template_context(event, target_type=target.type)
|
||||
ctx = build_template_context(
|
||||
event, target_type=target.type,
|
||||
date_format=target.date_format,
|
||||
date_only_format=target.date_only_format,
|
||||
)
|
||||
message = render_template(template_str, ctx)
|
||||
|
||||
if target.type == "telegram":
|
||||
@@ -90,16 +99,19 @@ class NotificationDispatcher:
|
||||
client = TelegramClient(session, bot_token)
|
||||
|
||||
# Build asset list for media sending
|
||||
# Only attach API key header for URLs pointing to the internal provider
|
||||
internal_url = target.provider_internal_url or ""
|
||||
# Attach API key header for URLs pointing to the provider (internal or external)
|
||||
provider_urls = []
|
||||
if target.provider_internal_url:
|
||||
provider_urls.append(target.provider_internal_url)
|
||||
if target.provider_external_url:
|
||||
provider_urls.append(target.provider_external_url)
|
||||
assets = []
|
||||
for asset in event.added_assets:
|
||||
url = asset.full_url or asset.thumbnail_url
|
||||
if url:
|
||||
asset_type = "video" if asset.type.value == "video" else "photo"
|
||||
# Include API key only for internal provider URLs
|
||||
asset_headers = {}
|
||||
if target.provider_api_key and internal_url and url.startswith(internal_url):
|
||||
if target.provider_api_key and any(url.startswith(u) for u in provider_urls):
|
||||
asset_headers["x-api-key"] = target.provider_api_key
|
||||
assets.append({"url": url, "type": asset_type, "headers": asset_headers})
|
||||
|
||||
|
||||
@@ -103,12 +103,14 @@ class TelegramClient:
|
||||
chat_id, assets[0].get("url"), caption, reply_to_message_id,
|
||||
parse_mode, max_asset_data_size, send_large_photos_as_documents,
|
||||
assets[0].get("content_type"), assets[0].get("cache_key"),
|
||||
download_headers=assets[0].get("headers"),
|
||||
)
|
||||
if len(assets) == 1 and assets[0].get("type") == "video":
|
||||
return await self._send_video(
|
||||
chat_id, assets[0].get("url"), caption, reply_to_message_id,
|
||||
parse_mode, max_asset_data_size,
|
||||
assets[0].get("content_type"), assets[0].get("cache_key"),
|
||||
download_headers=assets[0].get("headers"),
|
||||
)
|
||||
if len(assets) == 1 and assets[0].get("type", "document") == "document":
|
||||
url = assets[0].get("url")
|
||||
@@ -116,7 +118,8 @@ class TelegramClient:
|
||||
return {"success": False, "error": "Missing 'url' for document"}
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
async with self._session.get(download_url) as resp:
|
||||
dl_headers = assets[0].get("headers") or {}
|
||||
async with self._session.get(download_url, headers=dl_headers) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
@@ -196,6 +199,7 @@ class TelegramClient:
|
||||
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
|
||||
max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False,
|
||||
content_type: str | None = None, cache_key: str | None = None,
|
||||
download_headers: dict[str, str] | None = None,
|
||||
) -> NotificationResult:
|
||||
if not content_type:
|
||||
content_type = "image/jpeg"
|
||||
@@ -223,7 +227,7 @@ class TelegramClient:
|
||||
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
async with self._session.get(download_url) as resp:
|
||||
async with self._session.get(download_url, headers=download_headers or {}) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
@@ -264,7 +268,7 @@ class TelegramClient:
|
||||
self, chat_id: str, url: str | None, caption: str | None = None,
|
||||
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
|
||||
max_asset_data_size: int | None = None, content_type: str | None = None,
|
||||
cache_key: str | None = None,
|
||||
cache_key: str | None = None, download_headers: dict[str, str] | None = None,
|
||||
) -> NotificationResult:
|
||||
if not content_type:
|
||||
content_type = "video/mp4"
|
||||
@@ -291,7 +295,7 @@ class TelegramClient:
|
||||
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
async with self._session.get(download_url) as resp:
|
||||
async with self._session.get(download_url, headers=download_headers or {}) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
@@ -396,9 +400,9 @@ class TelegramClient:
|
||||
chunk_caption = caption if chunk_idx == 0 else None
|
||||
chunk_reply = reply_to_message_id if chunk_idx == 0 else None
|
||||
if item.get("type") == "photo":
|
||||
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"))
|
||||
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"))
|
||||
elif item.get("type") == "video":
|
||||
result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"))
|
||||
result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"))
|
||||
else:
|
||||
continue
|
||||
if not result.get("success"):
|
||||
@@ -435,7 +439,8 @@ class TelegramClient:
|
||||
else:
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
async with self._session.get(download_url) as resp:
|
||||
dl_headers = item.get("headers") or {}
|
||||
async with self._session.get(download_url, headers=dl_headers) as resp:
|
||||
if resp.status != 200:
|
||||
continue
|
||||
data = await resp.read()
|
||||
|
||||
@@ -184,6 +184,25 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
)
|
||||
|
||||
if event:
|
||||
# Fetch shared links to enrich event with public_url
|
||||
shared_links = await self._client.get_shared_links(album_id)
|
||||
public_link = None
|
||||
protected_link = None
|
||||
for link in shared_links:
|
||||
if link.is_accessible and not link.is_expired:
|
||||
if link.has_password:
|
||||
protected_link = link
|
||||
else:
|
||||
public_link = link
|
||||
break # prefer non-password link
|
||||
|
||||
ext_domain = self._external_domain or self._client.external_url
|
||||
if public_link:
|
||||
event.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
|
||||
elif protected_link:
|
||||
event.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
|
||||
# If no links, public_url stays absent — templates handle gracefully
|
||||
|
||||
events.append(event)
|
||||
|
||||
# Update state
|
||||
@@ -226,6 +245,7 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
"id": a.get("id", ""),
|
||||
"name": a.get("albumName", "Unnamed"),
|
||||
"asset_count": a.get("assetCount", 0),
|
||||
"updated_at": a.get("updatedAt", ""),
|
||||
}
|
||||
for a in albums
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
@@ -10,6 +11,8 @@ from notify_bridge_core.models.events import ServiceEvent
|
||||
def build_template_context(
|
||||
event: ServiceEvent,
|
||||
target_type: str = "webhook",
|
||||
date_format: str = "%d.%m.%Y, %H:%M UTC",
|
||||
date_only_format: str = "%d.%m.%Y",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a flat template context dict from a ServiceEvent.
|
||||
|
||||
@@ -56,6 +59,15 @@ def build_template_context(
|
||||
asset_dict.update(asset.extra)
|
||||
assets.append(asset_dict)
|
||||
|
||||
# Enrich assets with per-asset public URLs if album has a public share link
|
||||
album_public_url = event.extra.get("public_url", "")
|
||||
if album_public_url:
|
||||
for asset_dict in assets:
|
||||
asset_dict["public_url"] = f"{album_public_url}/photos/{asset_dict['id']}"
|
||||
else:
|
||||
for asset_dict in assets:
|
||||
asset_dict.setdefault("public_url", "")
|
||||
|
||||
ctx["assets"] = assets
|
||||
ctx["added_assets"] = assets # alias for backward compat
|
||||
|
||||
@@ -63,9 +75,48 @@ def build_template_context(
|
||||
ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in assets)
|
||||
ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in assets)
|
||||
|
||||
# Date format strings (available to templates for custom formatting)
|
||||
ctx["date_format"] = date_format
|
||||
ctx["date_only_format"] = date_only_format
|
||||
|
||||
# Common date/location — set when ALL assets share the same value
|
||||
ctx["common_date"] = ""
|
||||
ctx["common_location"] = ""
|
||||
if len(assets) > 1:
|
||||
# Date: compare date portion only (YYYY-MM-DD)
|
||||
dates = set()
|
||||
for a in assets:
|
||||
ca = a.get("created_at", "")
|
||||
if ca:
|
||||
dates.add(ca[:10]) # "2026-03-19T..." -> "2026-03-19"
|
||||
if len(dates) == 1:
|
||||
raw_date = dates.pop()
|
||||
try:
|
||||
ctx["common_date"] = datetime.fromisoformat(raw_date).strftime(date_only_format)
|
||||
except (ValueError, TypeError):
|
||||
ctx["common_date"] = raw_date
|
||||
|
||||
# Location: "City, Country" or just "City"
|
||||
locations = set()
|
||||
for a in assets:
|
||||
city = a.get("city", "")
|
||||
country = a.get("country", "")
|
||||
if city:
|
||||
loc = f"{city}, {country}" if country else city
|
||||
locations.add(loc)
|
||||
else:
|
||||
locations.add("") # asset with no location breaks commonality
|
||||
if len(locations) == 1 and "" not in locations:
|
||||
ctx["common_location"] = locations.pop()
|
||||
|
||||
# Provider-specific extras merged at top level
|
||||
ctx.update(event.extra)
|
||||
|
||||
# Ensure URL variables always exist (avoid Jinja2 undefined errors)
|
||||
ctx.setdefault("public_url", "")
|
||||
ctx.setdefault("protected_url", "")
|
||||
ctx.setdefault("album_url", "")
|
||||
|
||||
# Provider-specific aliases for Immich
|
||||
if event.provider_type.value == "immich":
|
||||
ctx.setdefault("album_name", event.collection_name)
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
📷 {{ added_count }} new photo(s) added to album "{{ album_name }}".
|
||||
📷 {{ added_count }} new photo(s) added to album {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
|
||||
{%- if common_date %} 📅 {{ common_date }}{% endif %}
|
||||
{%- if common_location %} 📍 {{ common_location }}{% endif %}
|
||||
{%- if people %}
|
||||
👤 {{ people | join(", ") }}
|
||||
{%- endif %}
|
||||
{%- if added_assets %}
|
||||
{%- for asset in added_assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- if target_type == "telegram" and has_videos %}
|
||||
|
||||
⚠️ Videos may not be sent due to Telegram's 50 MB file size limit.
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
@@ -1 +1 @@
|
||||
🗑️ {{ removed_count }} photo(s) removed from album "{{ album_name }}".
|
||||
🗑️ {{ removed_count }} photo(s) removed from album {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
✏️ Album "{{ old_name }}" renamed to "{{ new_name }}".
|
||||
✏️ Album "{{ old_name }}" renamed to {% if public_url %}<a href="{{ public_url }}">{{ new_name }}</a>{% else %}"{{ new_name }}"{% endif %}.
|
||||
@@ -1 +1 @@
|
||||
🔗 Sharing changed for album "{{ album_name }}".
|
||||
🔗 Sharing changed for album {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
|
||||
@@ -1,15 +1,17 @@
|
||||
📷 {{ added_count }} новых фото добавлено в альбом "{{ album_name }}".
|
||||
📷 {{ added_count }} новых фото добавлено в альбом {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
|
||||
{%- if common_date %} 📅 {{ common_date }}{% endif %}
|
||||
{%- if common_location %} 📍 {{ common_location }}{% endif %}
|
||||
{%- if people %}
|
||||
👤 {{ people | join(", ") }}
|
||||
{%- endif %}
|
||||
{%- if added_assets %}
|
||||
{%- for asset in added_assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- if target_type == "telegram" and has_videos %}
|
||||
|
||||
⚠️ Видео может не отправиться из-за ограничения Telegram в 50 МБ.
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
@@ -1 +1 @@
|
||||
🗑️ {{ removed_count }} фото удалено из альбома "{{ album_name }}".
|
||||
🗑️ {{ removed_count }} фото удалено из альбома {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
✏️ Альбом "{{ old_name }}" переименован в "{{ new_name }}".
|
||||
✏️ Альбом "{{ old_name }}" переименован в {% if public_url %}<a href="{{ public_url }}">{{ new_name }}</a>{% else %}"{{ new_name }}"{% endif %}.
|
||||
@@ -1 +1 @@
|
||||
🔗 Изменён доступ к альбому "{{ album_name }}".
|
||||
🔗 Изменён доступ к альбому {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
|
||||
@@ -216,6 +216,73 @@ async def list_collections(
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/{provider_id}/albums/{album_id}/shared-links")
|
||||
async def get_album_shared_links(
|
||||
provider_id: int,
|
||||
album_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Check shared links for a specific album."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
links = await immich.client.get_shared_links(album_id)
|
||||
return [
|
||||
{
|
||||
"id": link.id,
|
||||
"key": link.key,
|
||||
"has_password": link.has_password,
|
||||
"is_expired": link.is_expired,
|
||||
"is_accessible": link.is_accessible,
|
||||
}
|
||||
for link in links
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/{provider_id}/albums/{album_id}/shared-links")
|
||||
async def create_album_shared_link(
|
||||
provider_id: int,
|
||||
album_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Auto-create a public shared link for an album."""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
success = await immich.client.create_shared_link(album_id)
|
||||
if success:
|
||||
return {"success": True}
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="Failed to create shared link")
|
||||
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="Provider type does not support shared links")
|
||||
|
||||
|
||||
def _provider_response(p: ServiceProvider) -> dict:
|
||||
"""Build a safe response dict for a provider."""
|
||||
config = dict(p.config)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Status/dashboard API route."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import func, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
@@ -15,8 +15,15 @@ router = APIRouter(prefix="/api/status", tags=["status"])
|
||||
async def get_status(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
# Event filtering
|
||||
event_type: str | None = Query(None),
|
||||
provider_id: int | None = Query(None),
|
||||
search: str | None = Query(None),
|
||||
sort: str = Query("newest"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""Get dashboard status data."""
|
||||
"""Get dashboard status data with enriched events."""
|
||||
providers_count = (await session.exec(
|
||||
select(func.count()).select_from(ServiceProvider).where(ServiceProvider.user_id == user.id)
|
||||
)).one()
|
||||
@@ -31,24 +38,53 @@ async def get_status(
|
||||
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||
)).one()
|
||||
|
||||
recent_events = await session.exec(
|
||||
# Build events query with filters
|
||||
events_query = (
|
||||
select(EventLog)
|
||||
.join(Tracker, EventLog.tracker_id == Tracker.id)
|
||||
.where(Tracker.user_id == user.id)
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
if event_type:
|
||||
events_query = events_query.where(EventLog.event_type == event_type)
|
||||
if provider_id is not None:
|
||||
events_query = events_query.where(EventLog.provider_id == provider_id)
|
||||
if search:
|
||||
events_query = events_query.where(
|
||||
EventLog.collection_name.contains(search)
|
||||
| EventLog.tracker_name.contains(search)
|
||||
| EventLog.provider_name.contains(search)
|
||||
)
|
||||
|
||||
# Count total matching events (for pagination)
|
||||
count_query = select(func.count()).select_from(events_query.subquery())
|
||||
total_events = (await session.exec(count_query)).one()
|
||||
|
||||
# Sort
|
||||
if sort == "oldest":
|
||||
events_query = events_query.order_by(EventLog.created_at.asc())
|
||||
else:
|
||||
events_query = events_query.order_by(EventLog.created_at.desc())
|
||||
|
||||
events_query = events_query.offset(offset).limit(limit)
|
||||
recent_events = await session.exec(events_query)
|
||||
|
||||
return {
|
||||
"providers": providers_count,
|
||||
"trackers": {"total": len(trackers), "active": active_count},
|
||||
"targets": targets_count,
|
||||
"total_events": total_events,
|
||||
"recent_events": [
|
||||
{
|
||||
"id": e.id,
|
||||
"event_type": e.event_type,
|
||||
"collection_name": e.collection_name,
|
||||
"created_at": e.created_at.isoformat(),
|
||||
"tracker_name": e.tracker_name or "",
|
||||
"provider_name": e.provider_name or "",
|
||||
"provider_id": e.provider_id,
|
||||
"assets_count": e.assets_count or 0,
|
||||
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
|
||||
"details": e.details or {},
|
||||
}
|
||||
for e in recent_events.all()
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Notification target management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import NotificationTarget, User
|
||||
from ..database.models import NotificationTarget, TelegramBot, TelegramChat, TrackerTarget, User
|
||||
|
||||
router = APIRouter(prefix="/api/targets", tags=["targets"])
|
||||
|
||||
@@ -18,14 +18,12 @@ class TargetCreate(BaseModel):
|
||||
name: str
|
||||
icon: str = ""
|
||||
config: dict[str, Any] = {}
|
||||
template_config_id: int | None = None
|
||||
|
||||
|
||||
class TargetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
config: dict[str, Any] | None = None
|
||||
template_config_id: int | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -37,18 +35,26 @@ async def list_targets(
|
||||
result = await session.exec(
|
||||
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"type": t.type,
|
||||
"name": t.name,
|
||||
"icon": t.icon,
|
||||
"config": _safe_config(t),
|
||||
"template_config_id": t.template_config_id,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
for t in result.all()
|
||||
]
|
||||
targets = result.all()
|
||||
|
||||
# Resolve chat names for telegram targets
|
||||
chat_names: dict[str, str] = {}
|
||||
for tgt in targets:
|
||||
if tgt.type == "telegram" and tgt.config.get("chat_id"):
|
||||
bot_id = tgt.config.get("bot_id")
|
||||
chat_id = str(tgt.config["chat_id"])
|
||||
if bot_id:
|
||||
chat_result = await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)
|
||||
chat = chat_result.first()
|
||||
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]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@@ -69,7 +75,6 @@ async def create_target(
|
||||
name=body.name,
|
||||
icon=body.icon,
|
||||
config=body.config,
|
||||
template_config_id=body.template_config_id,
|
||||
)
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
@@ -85,14 +90,7 @@ async def get_target(
|
||||
):
|
||||
"""Get a specific notification target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
return {
|
||||
"id": target.id,
|
||||
"type": target.type,
|
||||
"name": target.name,
|
||||
"icon": target.icon,
|
||||
"config": _safe_config(target),
|
||||
"template_config_id": target.template_config_id,
|
||||
}
|
||||
return _target_response(target)
|
||||
|
||||
|
||||
@router.put("/{target_id}")
|
||||
@@ -104,14 +102,8 @@ async def update_target(
|
||||
):
|
||||
"""Update a notification target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
if body.name is not None:
|
||||
target.name = body.name
|
||||
if body.icon is not None:
|
||||
target.icon = body.icon
|
||||
if body.config is not None:
|
||||
target.config = body.config
|
||||
if body.template_config_id is not None:
|
||||
target.template_config_id = body.template_config_id
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(target, field, value)
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
await session.refresh(target)
|
||||
@@ -124,8 +116,14 @@ async def delete_target(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a notification target."""
|
||||
"""Delete a notification target and its tracker links."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
# Delete associated tracker-target links
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.target_id == target_id)
|
||||
)
|
||||
for tt in result.all():
|
||||
await session.delete(tt)
|
||||
await session.delete(target)
|
||||
await session.commit()
|
||||
|
||||
@@ -133,16 +131,36 @@ async def delete_target(
|
||||
@router.post("/{target_id}/test")
|
||||
async def test_target(
|
||||
target_id: int,
|
||||
locale: str = Query("en"),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test notification to a target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
from ..services.notifier import send_test_notification
|
||||
result = await send_test_notification(target)
|
||||
result = await send_test_notification(target, locale=locale)
|
||||
return result
|
||||
|
||||
|
||||
def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None) -> dict:
|
||||
resp = {
|
||||
"id": target.id,
|
||||
"type": target.type,
|
||||
"name": target.name,
|
||||
"icon": target.icon,
|
||||
"config": _safe_config(target),
|
||||
"created_at": target.created_at.isoformat(),
|
||||
}
|
||||
# Attach resolved chat name for telegram targets
|
||||
if target.type == "telegram" and chat_names:
|
||||
bot_id = target.config.get("bot_id")
|
||||
chat_id = str(target.config.get("chat_id", ""))
|
||||
key = f"{bot_id}_{chat_id}"
|
||||
if key in chat_names:
|
||||
resp["chat_name"] = chat_names[key]
|
||||
return resp
|
||||
|
||||
|
||||
def _safe_config(target: NotificationTarget) -> dict:
|
||||
"""Return config with sensitive fields masked."""
|
||||
config = dict(target.config)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Telegram bot management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -23,7 +23,7 @@ class BotCreate(BaseModel):
|
||||
|
||||
class BotUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
commands_config: dict | None = None
|
||||
icon: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -69,12 +69,12 @@ async def update_bot(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a bot's display name and/or commands config."""
|
||||
"""Update a bot's display name and icon."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
if body.name is not None:
|
||||
bot.name = body.name
|
||||
if body.commands_config is not None:
|
||||
bot.commands_config = body.commands_config
|
||||
if body.icon is not None:
|
||||
bot.icon = body.icon
|
||||
session.add(bot)
|
||||
await session.commit()
|
||||
await session.refresh(bot)
|
||||
@@ -173,6 +173,37 @@ async def discover_chats(
|
||||
return [_chat_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.post("/{bot_id}/chats/{chat_id}/test")
|
||||
async def test_chat(
|
||||
bot_id: int,
|
||||
chat_id: str,
|
||||
locale: str = Query("en"),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test message to a chat via the bot."""
|
||||
from ..services.notifier import _get_test_message
|
||||
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
message = _get_test_message(locale, "telegram")
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post(
|
||||
f"{TELEGRAM_API_BASE_URL}{bot.token}/sendMessage",
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
},
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
if data.get("ok"):
|
||||
return {"success": True}
|
||||
return {"success": False, "error": data.get("description", "Unknown error")}
|
||||
except aiohttp.ClientError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_chat(
|
||||
bot_id: int,
|
||||
@@ -247,10 +278,10 @@ def _bot_response(b: TelegramBot) -> dict:
|
||||
return {
|
||||
"id": b.id,
|
||||
"name": b.name,
|
||||
"icon": b.icon,
|
||||
"bot_username": b.bot_username,
|
||||
"bot_id": b.bot_id,
|
||||
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
|
||||
"commands_config": b.commands_config,
|
||||
"created_at": b.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ _SAMPLE_ASSET = {
|
||||
"state": "Ile-de-France",
|
||||
"country": "France",
|
||||
"url": "https://immich.example.com/photos/abc123",
|
||||
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"download_url": "https://immich.example.com/api/assets/abc123/original",
|
||||
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
|
||||
}
|
||||
@@ -44,12 +45,14 @@ _SAMPLE_VIDEO_ASSET = {
|
||||
"is_favorite": False,
|
||||
"rating": None,
|
||||
"photo_url": None,
|
||||
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"playback_url": "https://immich.example.com/api/assets/def456/video",
|
||||
}
|
||||
|
||||
_SAMPLE_COLLECTION = {
|
||||
"name": "Family Photos",
|
||||
"url": "https://immich.example.com/share/abc123",
|
||||
"public_url": "https://immich.example.com/share/abc123",
|
||||
"asset_count": 42,
|
||||
"shared": True,
|
||||
}
|
||||
@@ -85,10 +88,24 @@ _SAMPLE_CONTEXT = {
|
||||
"new_name": "New Album",
|
||||
"old_shared": False,
|
||||
"new_shared": True,
|
||||
# Public share URLs (may be empty if no shared link exists)
|
||||
"public_url": "https://immich.example.com/share/abc123",
|
||||
"protected_url": "",
|
||||
"album_url": "https://immich.example.com/albums/b2eeeaa4",
|
||||
# Common date/location (set when all assets share the same value)
|
||||
"common_date": "19.03.2026",
|
||||
"common_location": "Paris, France",
|
||||
# Date format strings (from template config)
|
||||
"date_format": "%d.%m.%Y, %H:%M UTC",
|
||||
"date_only_format": "%d.%m.%Y",
|
||||
# Scheduled/periodic variables (for those templates)
|
||||
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "IMG_002.jpg", "city": "London", "country": "UK"}],
|
||||
"albums": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/abc123/photos/x1y2z3"}],
|
||||
"date": "2026-03-19",
|
||||
"photo_count": 30,
|
||||
"video_count": 5,
|
||||
"owner": "Alice",
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +123,7 @@ class TemplateConfigCreate(BaseModel):
|
||||
scheduled_assets_message: str | None = None
|
||||
memory_mode_message: str | None = None
|
||||
date_format: str | None = None
|
||||
date_only_format: str | None = None
|
||||
|
||||
|
||||
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
|
||||
@@ -142,10 +160,15 @@ async def get_template_variables():
|
||||
"collection_id": "Collection ID (UUID)",
|
||||
"collection_name": "Collection name",
|
||||
"collection_url": "Public share URL (empty if not shared)",
|
||||
"public_url": "Public share link URL (empty if no link exists)",
|
||||
"protected_url": "Password-protected share link URL (empty if none)",
|
||||
"added_count": "Number of assets added",
|
||||
"removed_count": "Number of assets removed",
|
||||
"people": "Detected people names (list, use {{ people | join(', ') }})",
|
||||
"shared": "Whether collection is shared (boolean)",
|
||||
"photo_count": "Total photo count in album",
|
||||
"video_count": "Total video count in album",
|
||||
"owner": "Album owner name",
|
||||
"target_type": "Target type: 'telegram' or 'webhook'",
|
||||
"has_videos": "Whether added assets contain videos (boolean)",
|
||||
"has_photos": "Whether added assets contain photos (boolean)",
|
||||
@@ -177,6 +200,7 @@ async def get_template_variables():
|
||||
"city": "City name",
|
||||
"state": "State/region name",
|
||||
"country": "Country name",
|
||||
"public_url": "Per-asset public share URL (empty if no album link)",
|
||||
"url": "Public viewer URL (if shared)",
|
||||
"download_url": "Direct download URL (if shared)",
|
||||
"photo_url": "Preview image URL (images only, if shared)",
|
||||
@@ -185,6 +209,7 @@ async def get_template_variables():
|
||||
album_fields = {
|
||||
"name": "Collection/album name",
|
||||
"url": "Share URL",
|
||||
"public_url": "Public share link URL",
|
||||
"asset_count": "Total assets in collection",
|
||||
"shared": "Whether collection is shared",
|
||||
}
|
||||
@@ -196,7 +221,12 @@ async def get_template_variables():
|
||||
return {
|
||||
"message_assets_added": {
|
||||
"description": "Notification when new assets are added to a collection",
|
||||
"variables": {**event_vars, "added_assets": "List of asset dicts (use {% for asset in added_assets %})"},
|
||||
"variables": {
|
||||
**event_vars,
|
||||
"added_assets": "List of asset dicts (use {% for asset in added_assets %})",
|
||||
"common_date": "Shared date if all assets have the same date (formatted via date_only_format, empty otherwise)",
|
||||
"common_location": "Shared location if all assets are from the same place (e.g. 'Paris, France', empty otherwise)",
|
||||
},
|
||||
"asset_fields": asset_fields,
|
||||
},
|
||||
"message_assets_removed": {
|
||||
@@ -308,6 +338,8 @@ async def preview_config(
|
||||
class PreviewRequest(BaseModel):
|
||||
template: str
|
||||
target_type: str = "telegram" # "telegram" or "webhook"
|
||||
date_format: str = "%d.%m.%Y, %H:%M UTC"
|
||||
date_only_format: str = "%d.%m.%Y"
|
||||
|
||||
|
||||
@router.post("/preview-raw")
|
||||
@@ -334,7 +366,14 @@ async def preview_raw(
|
||||
|
||||
# Pass 2: render with strict undefined to catch unknown variables
|
||||
try:
|
||||
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type}
|
||||
from datetime import datetime
|
||||
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type,
|
||||
"date_format": body.date_format, "date_only_format": body.date_only_format}
|
||||
# Format common_date using the provided date_only_format
|
||||
try:
|
||||
ctx["common_date"] = datetime(2026, 3, 19).strftime(body.date_only_format)
|
||||
except (ValueError, TypeError):
|
||||
ctx["common_date"] = "19.03.2026"
|
||||
strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
|
||||
tmpl = strict_env.from_string(body.template)
|
||||
rendered = tmpl.render(**ctx)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -152,6 +152,7 @@ async def delete_tracker_target(
|
||||
async def test_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
locale: str = Query("en"),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
@@ -166,7 +167,7 @@ async def test_tracker_target(
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
|
||||
from ..services.notifier import send_test_notification
|
||||
r = await send_test_notification(target)
|
||||
r = await send_test_notification(target, locale=locale)
|
||||
return {"target": target.name, **r}
|
||||
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ async def tracker_history(
|
||||
"collection_id": e.collection_id,
|
||||
"collection_name": e.collection_name,
|
||||
"details": e.details,
|
||||
"created_at": e.created_at.isoformat(),
|
||||
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
|
||||
}
|
||||
for e in result.all()
|
||||
]
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Data migrations for schema changes.
|
||||
|
||||
Handles converting legacy JSON-array relationships to proper junction tables.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
"""Add missing columns to existing tables (SQLite ALTER TABLE ADD COLUMN)."""
|
||||
async with engine.begin() as conn:
|
||||
# Helper to check if column exists
|
||||
async def _has_column(table: str, column: str) -> bool:
|
||||
cols = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text(f"PRAGMA table_info('{table}')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
return column in cols
|
||||
|
||||
# Add batch_duration to tracker if missing
|
||||
if not await _has_column("tracker", "batch_duration"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracker ADD COLUMN batch_duration INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added batch_duration column to tracker table")
|
||||
|
||||
# Add enriched fields to event_log if missing
|
||||
for col, sql in [
|
||||
("tracker_name", "ALTER TABLE event_log ADD COLUMN tracker_name TEXT DEFAULT ''"),
|
||||
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
|
||||
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
|
||||
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
|
||||
]:
|
||||
if not await _has_column("event_log", col):
|
||||
await conn.execute(text(sql))
|
||||
logger.info("Added %s column to event_log table", col)
|
||||
|
||||
# Add date_only_format to template_config if missing
|
||||
if not await _has_column("template_config", "date_only_format"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE template_config ADD COLUMN date_only_format TEXT DEFAULT '%d.%m.%Y'")
|
||||
)
|
||||
logger.info("Added date_only_format column to template_config table")
|
||||
|
||||
# Add collection_name and shared to tracker_state if missing
|
||||
if not await _has_column("tracker_state", "collection_name"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracker_state ADD COLUMN collection_name TEXT DEFAULT ''")
|
||||
)
|
||||
logger.info("Added collection_name column to tracker_state table")
|
||||
if not await _has_column("tracker_state", "shared"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracker_state ADD COLUMN shared INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added shared column to tracker_state table")
|
||||
|
||||
|
||||
async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
"""Migrate legacy Tracker.target_ids JSON arrays to TrackerTarget rows.
|
||||
|
||||
Also migrates:
|
||||
- Tracker.tracking_config_id → TrackerTarget.tracking_config_id
|
||||
- Tracker.quiet_hours_* → TrackerTarget.quiet_hours_*
|
||||
- NotificationTarget.template_config_id → TrackerTarget.template_config_id
|
||||
- TelegramBot.commands_config → TrackerTarget.commands_config (for telegram targets)
|
||||
|
||||
Idempotent: skips if legacy columns don't exist or data already migrated.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
# Check if legacy target_ids column exists on tracker table
|
||||
columns = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text("PRAGMA table_info('tracker')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
if "target_ids" not in columns:
|
||||
logger.debug("No legacy target_ids column found — skipping migration")
|
||||
return
|
||||
|
||||
# Check if tracker_target table already has data (previous migration ran)
|
||||
tt_count = (
|
||||
await conn.execute(text("SELECT COUNT(*) FROM tracker_target"))
|
||||
).scalar()
|
||||
if tt_count and tt_count > 0:
|
||||
logger.debug(
|
||||
"tracker_target table already has %d rows — skipping migration",
|
||||
tt_count,
|
||||
)
|
||||
return
|
||||
|
||||
# Load legacy data
|
||||
trackers = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT id, target_ids, tracking_config_id, "
|
||||
"quiet_hours_start, quiet_hours_end FROM tracker"
|
||||
)
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
if not trackers:
|
||||
logger.debug("No trackers to migrate")
|
||||
return
|
||||
|
||||
# Load template_config_id from targets (legacy field)
|
||||
target_template_map: dict[int, int | None] = {}
|
||||
target_cols = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text("PRAGMA table_info('notification_target')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
if "template_config_id" in target_cols:
|
||||
targets = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT id, template_config_id FROM notification_target"
|
||||
)
|
||||
)
|
||||
).fetchall()
|
||||
for t in targets:
|
||||
target_template_map[t[0]] = t[1]
|
||||
|
||||
# Load commands_config from telegram_bots (legacy field)
|
||||
bot_commands_map: dict[int, str | None] = {}
|
||||
bot_cols = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text("PRAGMA table_info('telegram_bot')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
if "commands_config" in bot_cols:
|
||||
bots = (
|
||||
await conn.execute(
|
||||
text("SELECT id, commands_config FROM telegram_bot")
|
||||
)
|
||||
).fetchall()
|
||||
for b in bots:
|
||||
bot_commands_map[b[0]] = b[1]
|
||||
|
||||
# Build target → bot mapping for commands_config migration
|
||||
target_bot_map: dict[int, int] = {}
|
||||
if bot_commands_map:
|
||||
import json
|
||||
|
||||
tgt_rows = (
|
||||
await conn.execute(
|
||||
text("SELECT id, config FROM notification_target WHERE type='telegram'")
|
||||
)
|
||||
).fetchall()
|
||||
for tgt in tgt_rows:
|
||||
try:
|
||||
cfg = json.loads(tgt[1]) if isinstance(tgt[1], str) else tgt[1]
|
||||
if cfg and "bot_token" in cfg:
|
||||
for bot_id, _ in bot_commands_map.items():
|
||||
bot_row = (
|
||||
await conn.execute(
|
||||
text("SELECT id FROM telegram_bot WHERE id=:bid"),
|
||||
{"bid": bot_id},
|
||||
)
|
||||
).fetchone()
|
||||
if bot_row:
|
||||
# Match by checking if this target uses this bot's token
|
||||
bot_token_row = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT token FROM telegram_bot WHERE id=:bid"
|
||||
),
|
||||
{"bid": bot_id},
|
||||
)
|
||||
).fetchone()
|
||||
if bot_token_row and bot_token_row[0] == cfg.get(
|
||||
"bot_token"
|
||||
):
|
||||
target_bot_map[tgt[0]] = bot_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create TrackerTarget rows
|
||||
import json
|
||||
|
||||
migrated = 0
|
||||
for tracker in trackers:
|
||||
tracker_id = tracker[0]
|
||||
raw_target_ids = tracker[1]
|
||||
tracking_config_id = tracker[2]
|
||||
quiet_hours_start = tracker[3]
|
||||
quiet_hours_end = tracker[4]
|
||||
|
||||
# Parse target_ids JSON
|
||||
if isinstance(raw_target_ids, str):
|
||||
try:
|
||||
target_ids = json.loads(raw_target_ids)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
target_ids = []
|
||||
elif isinstance(raw_target_ids, list):
|
||||
target_ids = raw_target_ids
|
||||
else:
|
||||
target_ids = []
|
||||
|
||||
for target_id in target_ids:
|
||||
template_config_id = target_template_map.get(target_id)
|
||||
|
||||
# Get commands_config if this is a telegram target with a known bot
|
||||
commands_config = None
|
||||
if target_id in target_bot_map:
|
||||
bot_id = target_bot_map[target_id]
|
||||
raw_cmd = bot_commands_map.get(bot_id)
|
||||
if raw_cmd:
|
||||
commands_config = (
|
||||
raw_cmd
|
||||
if isinstance(raw_cmd, str)
|
||||
else json.dumps(raw_cmd)
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO tracker_target "
|
||||
"(tracker_id, target_id, tracking_config_id, "
|
||||
"template_config_id, enabled, quiet_hours_start, "
|
||||
"quiet_hours_end, commands_config) "
|
||||
"VALUES (:tid, :tgtid, :tcid, :tmplid, 1, :qhs, :qhe, :cmd)"
|
||||
),
|
||||
{
|
||||
"tid": tracker_id,
|
||||
"tgtid": target_id,
|
||||
"tcid": tracking_config_id,
|
||||
"tmplid": template_config_id,
|
||||
"qhs": quiet_hours_start,
|
||||
"qhe": quiet_hours_end,
|
||||
"cmd": commands_config,
|
||||
},
|
||||
)
|
||||
migrated += 1
|
||||
|
||||
logger.info("Migrated %d tracker-target links", migrated)
|
||||
@@ -141,6 +141,7 @@ class TemplateConfig(SQLModel, table=True):
|
||||
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)
|
||||
|
||||
@@ -221,8 +222,12 @@ class EventLog(SQLModel, table=True):
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
tracker_id: int | None = Field(default=None, foreign_key="tracker.id")
|
||||
tracker_name: str = Field(default="")
|
||||
provider_id: int | None = Field(default=None)
|
||||
provider_name: str = Field(default="")
|
||||
event_type: str
|
||||
collection_id: str
|
||||
collection_name: str
|
||||
assets_count: int = Field(default=0)
|
||||
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
"""Notify Bridge Server — FastAPI application entry point."""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
# Ensure app-level loggers are visible
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("notify_bridge_server").setLevel(logging.DEBUG)
|
||||
logging.getLogger("notify_bridge_core").setLevel(logging.DEBUG)
|
||||
|
||||
from .database.engine import init_db
|
||||
from .database.models import * # noqa: F401,F403 — ensure all models registered
|
||||
|
||||
from .auth.routes import router as auth_router
|
||||
from .api.providers import router as providers_router
|
||||
from .api.trackers import router as trackers_router
|
||||
from .api.tracker_targets import router as tracker_targets_router
|
||||
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
|
||||
@@ -22,6 +29,12 @@ from .api.template_vars import router as template_vars_router
|
||||
@asynccontextmanager
|
||||
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
|
||||
engine = get_engine()
|
||||
await migrate_schema(engine)
|
||||
await migrate_tracker_targets(engine)
|
||||
await _seed_default_templates()
|
||||
from .services.scheduler import start_scheduler
|
||||
await start_scheduler()
|
||||
@@ -35,6 +48,7 @@ app.include_router(auth_router)
|
||||
app.include_router(template_vars_router)
|
||||
app.include_router(providers_router)
|
||||
app.include_router(trackers_router)
|
||||
app.include_router(tracker_targets_router)
|
||||
app.include_router(tracking_configs_router)
|
||||
app.include_router(template_configs_router)
|
||||
app.include_router(targets_router)
|
||||
@@ -49,7 +63,7 @@ async def health():
|
||||
|
||||
|
||||
async def _seed_default_templates():
|
||||
"""Seed default templates on first startup if no templates exist."""
|
||||
"""Seed default templates on first startup if none exist."""
|
||||
from sqlmodel import func, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from .database.engine import get_engine
|
||||
|
||||
@@ -8,21 +8,37 @@ from ..database.models import NotificationTarget
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_TEST_MESSAGES: dict[str, dict[str, str]] = {
|
||||
"en": {
|
||||
"telegram": "\u2705 Test message from <b>Notify Bridge</b>",
|
||||
"webhook": "Test notification from Notify Bridge",
|
||||
},
|
||||
"ru": {
|
||||
"telegram": "\u2705 Тестовое сообщение от <b>Notify Bridge</b>",
|
||||
"webhook": "Тестовое уведомление от Notify Bridge",
|
||||
},
|
||||
}
|
||||
|
||||
async def send_test_notification(target: NotificationTarget) -> dict:
|
||||
|
||||
def _get_test_message(locale: str, target_type: str) -> str:
|
||||
msgs = _TEST_MESSAGES.get(locale, _TEST_MESSAGES["en"])
|
||||
return msgs.get(target_type, msgs.get("webhook", "Test"))
|
||||
|
||||
|
||||
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
"""Send a simple test message to a notification target."""
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram(target)
|
||||
return await _test_telegram(target, locale)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook(target)
|
||||
return await _test_webhook(target, locale)
|
||||
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 _test_telegram(target: NotificationTarget) -> dict:
|
||||
async def _test_telegram(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
|
||||
bot_token = target.config.get("bot_token")
|
||||
@@ -34,7 +50,7 @@ async def _test_telegram(target: NotificationTarget) -> dict:
|
||||
client = TelegramClient(session, bot_token)
|
||||
return await client.send_notification(
|
||||
chat_id=str(chat_id),
|
||||
caption="Test notification from Notify Bridge",
|
||||
caption=_get_test_message(locale, "telegram"),
|
||||
)
|
||||
|
||||
|
||||
@@ -88,7 +104,7 @@ async def _test_webhook_with_message(target: NotificationTarget, message: str) -
|
||||
return await client.send({"message": message, "event_type": "test_template"})
|
||||
|
||||
|
||||
async def _test_webhook(target: NotificationTarget) -> dict:
|
||||
async def _test_webhook(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
|
||||
url = target.config.get("url")
|
||||
@@ -99,6 +115,6 @@ async def _test_webhook(target: NotificationTarget) -> dict:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({
|
||||
"message": "Test notification from Notify Bridge",
|
||||
"message": _get_test_message(locale, "webhook"),
|
||||
"event_type": "test",
|
||||
})
|
||||
|
||||
@@ -174,11 +174,16 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
session.add(new_ts)
|
||||
|
||||
for event in events:
|
||||
assets_count = event.added_count or event.removed_count or 0
|
||||
log = 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=assets_count,
|
||||
details={
|
||||
"added_count": event.added_count,
|
||||
"removed_count": event.removed_count,
|
||||
@@ -233,8 +238,11 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
type=ld["target_type"],
|
||||
config=ld["target_config"],
|
||||
template_slots=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 hasattr(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", ""),
|
||||
))
|
||||
|
||||
if target_configs:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Feature Context: UX & Notification Improvements
|
||||
|
||||
## Current State
|
||||
Starting implementation. All entity models already have `icon: str` fields. EventLog exists with basic fields. Immich client already has shared link CRUD methods. Frontend uses Svelte 5 + Tailwind v4 with inline form/card pattern on all CRUD pages.
|
||||
|
||||
## Temporary Workarounds
|
||||
- None yet
|
||||
|
||||
## Cross-Phase Dependencies
|
||||
- Phase 3 depends on Phase 2 (enriched event data from API)
|
||||
- Phase 6 logically follows Phase 5 (link validation informs no-link handling)
|
||||
- Phases 1, 4 are fully independent
|
||||
|
||||
## Implementation Notes
|
||||
- All overlays MUST use `position: fixed` with inline styles and `z-index: 9999`
|
||||
- SQLAlchemy async + aiohttp: eager load DB data before aiohttp context
|
||||
- Jinja2 templates use SandboxedEnvironment
|
||||
- Icons stored as MDI icon path names (e.g., `mdiCamera`) from `@mdi/js`
|
||||
- Frontend uses MdiIcon component to render SVG icons
|
||||
@@ -0,0 +1,44 @@
|
||||
# Feature: UX & Notification Improvements
|
||||
|
||||
**Branch:** `feature/ux-notification-improvements`
|
||||
**Base branch:** `feature/entity-relationship-refactor`
|
||||
**Created:** 2026-03-20
|
||||
**Status:** 🟡 In Progress
|
||||
**Strategy:** Incremental
|
||||
**Mode:** Automated
|
||||
**Execution:** Orchestrator
|
||||
|
||||
## Summary
|
||||
Seven UX and notification improvements: show entity icons, enrich event data, dashboard filtering/sorting, friendly Telegram chat names, bot test messages, album public link validation, and graceful degradation for albums without public links.
|
||||
|
||||
## Build & Test Commands
|
||||
- **Build (backend):** `cd packages/server && pip install -e .`
|
||||
- **Build (frontend):** `cd frontend && npx vite build`
|
||||
- **Test (backend):** `cd packages/server && python -m pytest` (if tests exist)
|
||||
- **Lint:** N/A
|
||||
|
||||
## Phases
|
||||
|
||||
- [ ] Phase 1: Show Entity Icons on Cards [domain: frontend] → [subplan](./phase-1-entity-icons.md)
|
||||
- [ ] Phase 2: Enrich Event Data [domain: backend] → [subplan](./phase-2-enrich-events.md)
|
||||
- [ ] Phase 3: Richer Events Display + Filtering & Sorting [domain: frontend] → [subplan](./phase-3-events-ui.md)
|
||||
- [ ] Phase 4: Friendly Chat Names + Test Message for Bots [domain: fullstack] → [subplan](./phase-4-chat-names-test-msg.md)
|
||||
- [ ] Phase 5: Album Public Link Validation [domain: fullstack] → [subplan](./phase-5-link-validation.md)
|
||||
- [ ] Phase 6: Support Albums Without Public Links [domain: backend] → [subplan](./phase-6-no-link-support.md)
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 1: Entity Icons | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 2: Enrich Events | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 3: Events UI | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 4: Chat Names + Test | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 5: Link Validation | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 6: No-Link Support | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
## Final Review
|
||||
- [ ] Comprehensive code review
|
||||
- [ ] Full build passes
|
||||
- [ ] Full test suite passes
|
||||
- [ ] Merged to `feature/entity-relationship-refactor`
|
||||
@@ -0,0 +1,44 @@
|
||||
# Phase 1: Show Entity Icons on Cards
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Display the user-selected icon on every entity card (Providers, Trackers, Targets, Telegram Bots). Currently the IconPicker saves the icon but cards use hardcoded default icons.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Update provider cards in `frontend/src/routes/providers/+page.svelte` to show `provider.icon` (fallback to default server/cloud icon)
|
||||
- [ ] Task 2: Update tracker cards in `frontend/src/routes/trackers/+page.svelte` to show `tracker.icon` (fallback to default radar icon)
|
||||
- [ ] Task 3: Update target cards in `frontend/src/routes/targets/+page.svelte` to show `target.icon` (fallback to type-based default)
|
||||
- [ ] Task 4: Update bot cards in `frontend/src/routes/telegram-bots/+page.svelte` to show `bot.icon` (fallback to robot icon)
|
||||
- [ ] Task 5: Ensure icon rendering uses MdiIcon component with proper sizing consistent with existing card headers
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/src/routes/providers/+page.svelte` — card header icon
|
||||
- `frontend/src/routes/trackers/+page.svelte` — card header icon
|
||||
- `frontend/src/routes/targets/+page.svelte` — card header icon
|
||||
- `frontend/src/routes/telegram-bots/+page.svelte` — card header icon
|
||||
|
||||
## Acceptance Criteria
|
||||
- Each entity card displays the saved icon if set
|
||||
- Falls back to a sensible default icon per entity type when no icon is saved
|
||||
- Icon styling is consistent across all card types
|
||||
- No regressions in existing card layout or functionality
|
||||
|
||||
## Notes
|
||||
- Icons are stored as MDI path constant names (e.g., the string key from `@mdi/js`)
|
||||
- The MdiIcon component already exists in `frontend/src/lib/components/MdiIcon.svelte`
|
||||
- IconPicker component already handles icon selection and stores the value
|
||||
- Need to check exactly how icon values are stored (full path data vs key name) to render correctly
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -0,0 +1,45 @@
|
||||
# Phase 2: Enrich Event Data
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Enrich EventLog with provider name, tracker name, and asset counts so the dashboard can display richer event details. Update the status API to return these fields.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Add `provider_name`, `tracker_name`, `provider_id` columns to EventLog model (or store in details JSON to avoid migration complexity)
|
||||
- [ ] Task 2: Update watcher.py event logging to populate provider_name, tracker_name, and assets_count when creating EventLog entries
|
||||
- [ ] Task 3: Update status.py GET /api/status endpoint to return enriched event fields (provider_name, tracker_name, event_type, collection_name, assets_count, details)
|
||||
- [ ] Task 4: Add pagination/limit support to the events endpoint (query param `limit`, default 20)
|
||||
- [ ] Task 5: Add optional filtering query params: `event_type`, `provider_id`, `search` (name match)
|
||||
- [ ] Task 6: Handle migration for existing EventLog rows (backfill from tracker/provider if possible, or leave empty for old rows)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `packages/server/src/notify_bridge_server/database/models.py` — EventLog model changes
|
||||
- `packages/server/src/notify_bridge_server/services/watcher.py` — populate new fields on event creation
|
||||
- `packages/server/src/notify_bridge_server/api/status.py` — enrich response, add filtering/pagination
|
||||
- `packages/server/src/notify_bridge_server/database/migrations.py` — add columns if using real columns
|
||||
|
||||
## Acceptance Criteria
|
||||
- EventLog entries created after this phase include provider_name, tracker_name, assets_count
|
||||
- GET /api/status returns enriched event data
|
||||
- Filtering by event_type, provider_id, and text search works
|
||||
- Old events without new fields still render (graceful degradation)
|
||||
- No breaking changes to existing API consumers
|
||||
|
||||
## Notes
|
||||
- Storing provider_name/tracker_name as denormalized strings is intentional — the event log should be a historical record even if the tracker/provider is later deleted
|
||||
- The `details` JSON field already exists and could hold extra data, but explicit columns are better for filtering
|
||||
- EventLog.tracker_id already exists as FK — can join for backfill but also store name directly
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -0,0 +1,43 @@
|
||||
# Phase 3: Richer Events Display + Filtering & Sorting
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Update the dashboard to display richer event details (provider name, tracker name, album name, event type, assets count) and add filtering/sorting controls.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Update event timeline items in `+page.svelte` to show: provider name, tracker name, album name, event type badge, and assets count
|
||||
- [ ] Task 2: Add filter controls above the events list: text search input, event type dropdown (all/assets_added/assets_removed/collection_renamed/collection_deleted/sharing_changed), provider dropdown (populated from providers list)
|
||||
- [ ] Task 3: Add sort control: newest first / oldest first toggle
|
||||
- [ ] Task 4: Wire filters to API query params (event_type, provider_id, search) from Phase 2
|
||||
- [ ] Task 5: Add "load more" button or increase default limit for events
|
||||
- [ ] Task 6: Ensure graceful display when enriched fields are empty (old events before Phase 2)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/src/routes/+page.svelte` — event display, filter/sort controls, API calls
|
||||
|
||||
## Acceptance Criteria
|
||||
- Each event shows: provider name, tracker name, album name, event type (badge), assets count
|
||||
- Filtering by text search, event type, and provider works
|
||||
- Sort by time (newest/oldest) works
|
||||
- Old events without enriched data display gracefully (show what's available)
|
||||
- Filter/sort state resets on page load (no persistence needed)
|
||||
- UI is responsive and consistent with existing design
|
||||
|
||||
## Notes
|
||||
- Depends on Phase 2's enriched API response
|
||||
- Provider list for the dropdown can come from existing /api/providers endpoint
|
||||
- Event type badges already have color mapping in the current dashboard code
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -0,0 +1,50 @@
|
||||
# Phase 4: Friendly Chat Names + Test Message for Bots
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Two Telegram UX improvements: (a) show friendly chat names instead of raw IDs on target cards, (b) add a "Send Test Message" button to each chat item on the bots page.
|
||||
|
||||
## Tasks
|
||||
|
||||
### 4a: Friendly Chat Names on Target Cards
|
||||
- [ ] Task 1: Update GET /api/targets in targets.py to resolve chat_id → friendly name by looking up TelegramChat table (match on bot token's bot_id + chat_id)
|
||||
- [ ] Task 2: Include `chat_name` field in target API response alongside chat_id
|
||||
- [ ] Task 3: Update target cards in targets/+page.svelte to display "Chat Name (chat_id)" instead of raw chat_id
|
||||
|
||||
### 4b: Test Message Button for Bot Chats
|
||||
- [ ] Task 4: Add POST /api/telegram-bots/{bot_id}/chats/{chat_id}/test endpoint in telegram_bots.py that sends a simple test message via the bot
|
||||
- [ ] Task 5: Add "Send Test" button to each chat item in telegram-bots/+page.svelte with loading/success/error feedback
|
||||
- [ ] Task 6: Handle edge cases (bot can't reach chat, chat deleted, etc.) with proper error messages
|
||||
|
||||
## Files to Modify/Create
|
||||
- `packages/server/src/notify_bridge_server/api/targets.py` — resolve chat names
|
||||
- `packages/server/src/notify_bridge_server/api/telegram_bots.py` — test message endpoint
|
||||
- `frontend/src/routes/targets/+page.svelte` — display friendly names
|
||||
- `frontend/src/routes/telegram-bots/+page.svelte` — test message button
|
||||
|
||||
## Acceptance Criteria
|
||||
- Target cards show "Chat Title (chat_id)" for telegram targets where chat name is known
|
||||
- Falls back to just chat_id when no matching TelegramChat record exists
|
||||
- Test message button sends a simple "Test message from Notify Bridge" to the chat
|
||||
- Button shows loading state, then success/error feedback
|
||||
- Error messages are user-friendly (not raw API errors)
|
||||
|
||||
## Notes
|
||||
- TelegramChat stores: chat_id (string), title, chat_type, username, bot_id (FK)
|
||||
- NotificationTarget.config stores: bot_token, chat_id
|
||||
- To resolve: need to find TelegramBot by token → get bot_id → lookup TelegramChat by (bot_id, chat_id)
|
||||
- For test message: use TelegramClient directly with bot token from TelegramBot record
|
||||
- The test endpoint on targets already exists (POST /api/targets/{id}/test) — this is a NEW endpoint specifically for bot chat items
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -0,0 +1,48 @@
|
||||
# Phase 5: Album Public Link Validation
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
When saving/updating a tracker with album selections, check if the selected albums have valid public shared links. Warn the user about missing links and offer to auto-create them.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Add GET /api/providers/{id}/albums/{album_id}/shared-links endpoint that wraps ImmichClient.get_shared_links()
|
||||
- [ ] Task 2: Add POST /api/providers/{id}/albums/{album_id}/shared-links endpoint that wraps ImmichClient.create_shared_link()
|
||||
- [ ] Task 3: In trackers/+page.svelte, after album selection changes (on save), call shared-links endpoint for each newly selected album
|
||||
- [ ] Task 4: Show a warning dialog/section listing albums without valid public links (expired, password-protected, or missing)
|
||||
- [ ] Task 5: Add "Auto-create public links" button in the warning dialog that calls the create endpoint for each missing album
|
||||
- [ ] Task 6: Add hints explaining implications: "Public links allow anyone with the URL to view album contents" and "Albums without public links will have limited notification features (no clickable links in messages)"
|
||||
- [ ] Task 7: Allow user to proceed without creating links (dismiss warning and save anyway)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `packages/server/src/notify_bridge_server/api/trackers.py` or new `providers.py` routes — shared link endpoints
|
||||
- `frontend/src/routes/trackers/+page.svelte` — validation UI, warning dialog, auto-create flow
|
||||
|
||||
## Acceptance Criteria
|
||||
- On tracker save with new albums, shared links are checked
|
||||
- Albums without valid links are highlighted with a warning
|
||||
- User can auto-create links with one click
|
||||
- User can dismiss and proceed without links
|
||||
- Hints explain security/privacy implications
|
||||
- Already-valid links are not re-created
|
||||
- Expired or password-protected links are flagged as problematic
|
||||
|
||||
## Notes
|
||||
- ImmichClient already has: get_shared_links(album_id), create_shared_link(album_id, ...), delete_shared_link(), set_shared_link_password()
|
||||
- SharedLinkInfo model has: id, key, has_password, is_expired, is_accessible
|
||||
- A "valid" link = exists AND not expired AND is_accessible (has_password is a warning, not a blocker)
|
||||
- The check should only run for NEWLY selected albums (not all albums on every save)
|
||||
- Use a modal/dialog for the warning — follows project convention of fixed-position overlays
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -0,0 +1,47 @@
|
||||
# Phase 6: Support Albums Without Public Links
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Allow tracking albums even without public links. Templates should conditionally wrap items in `<a href>` when public URLs exist, otherwise show plain text names. Telegram should still send assets to chats regardless of public link status.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: In template context building (context.py), include `has_public_url` / `album_url` / per-asset `public_url` flags so templates can conditionally render links
|
||||
- [ ] Task 2: Update default system templates (EN/RU seeds) to use `{% if album_url %}` / `{% if asset.public_url %}` conditionals — wrap in `<a href>` when URL exists, plain text otherwise
|
||||
- [ ] Task 3: Verify that Telegram notification sending (notifier.py + telegram client) sends assets via direct API download regardless of public link status (it should already work since it uses internal API URLs with api_key headers, not public links)
|
||||
- [ ] Task 4: Ensure the template context correctly distinguishes between internal API URLs (for media download) and public URLs (for user-facing links in messages)
|
||||
- [ ] Task 5: Test that events from albums without public links still generate notifications with asset media but no clickable links in the message text
|
||||
|
||||
## Files to Modify/Create
|
||||
- `packages/core/src/notify_bridge_core/templates/context.py` — add public_url flags to context
|
||||
- `packages/core/src/notify_bridge_core/providers/immich/provider.py` — ensure shared link info flows through events
|
||||
- `packages/server/src/notify_bridge_server/services/notifier.py` — verify asset sending works without public links
|
||||
- Default template seeds (wherever EN/RU templates are defined) — conditional link rendering
|
||||
|
||||
## Acceptance Criteria
|
||||
- Albums without public links can be tracked without errors
|
||||
- Notifications are sent with media assets regardless of public link status
|
||||
- Message text includes clickable links only when public URLs exist
|
||||
- Message text shows plain album/asset names when no public URL
|
||||
- Default EN/RU templates handle both cases
|
||||
- No regressions for albums that DO have public links
|
||||
|
||||
## Notes
|
||||
- Internal asset URLs use format: `{provider_url}/api/assets/{id}/original` with x-api-key header
|
||||
- Public URLs use format: `{external_domain}/share/{key}` (no auth needed)
|
||||
- Telegram client downloads via internal URLs (with headers) — this is independent of public links
|
||||
- The public URL is only relevant for the message text (human-readable links)
|
||||
- Template context already has `album_url` from event.extra — need to make it None/empty when no shared link
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
Reference in New Issue
Block a user