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)
|
- `user_id=0` on TemplateConfig = system default (EN/RU seeded on first startup)
|
||||||
- DB: SQLite + async SQLAlchemy via sqlmodel, auto-created on 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
|
- 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",
|
"assetsRemoved": "assets removed",
|
||||||
"collectionRenamed": "collection renamed",
|
"collectionRenamed": "collection renamed",
|
||||||
"collectionDeleted": "collection deleted",
|
"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": {
|
"providers": {
|
||||||
"title": "Providers",
|
"title": "Providers",
|
||||||
@@ -284,7 +297,8 @@
|
|||||||
"moreMessage": "More message",
|
"moreMessage": "More message",
|
||||||
"peopleFormat": "People format",
|
"peopleFormat": "People format",
|
||||||
"dateLocation": "Date & Location",
|
"dateLocation": "Date & Location",
|
||||||
"dateFormat": "Date format",
|
"dateFormat": "Date & time format",
|
||||||
|
"dateOnlyFormat": "Date only format",
|
||||||
"commonDate": "Common date",
|
"commonDate": "Common date",
|
||||||
"uniqueDate": "Per-asset date",
|
"uniqueDate": "Per-asset date",
|
||||||
"locationFormat": "Location format",
|
"locationFormat": "Location format",
|
||||||
@@ -417,6 +431,7 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"clone": "Clone",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
|||||||
@@ -46,7 +46,20 @@
|
|||||||
"assetsRemoved": "удалены файлы",
|
"assetsRemoved": "удалены файлы",
|
||||||
"collectionRenamed": "альбом переименован",
|
"collectionRenamed": "альбом переименован",
|
||||||
"collectionDeleted": "альбом удалён",
|
"collectionDeleted": "альбом удалён",
|
||||||
"sharingChanged": "изменение доступа"
|
"sharingChanged": "изменение доступа",
|
||||||
|
"searchEvents": "Поиск событий...",
|
||||||
|
"allEvents": "Все события",
|
||||||
|
"filterAssetsAdded": "Добавление файлов",
|
||||||
|
"filterAssetsRemoved": "Удаление файлов",
|
||||||
|
"filterRenamed": "Переименование",
|
||||||
|
"filterDeleted": "Удаление",
|
||||||
|
"filterSharingChanged": "Изменение доступа",
|
||||||
|
"allProviders": "Все провайдеры",
|
||||||
|
"newestFirst": "Сначала новые",
|
||||||
|
"oldestFirst": "Сначала старые",
|
||||||
|
"loadingEvents": "Загрузка событий...",
|
||||||
|
"asset": "файл",
|
||||||
|
"assets": "файлов"
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"title": "Провайдеры",
|
"title": "Провайдеры",
|
||||||
@@ -284,7 +297,8 @@
|
|||||||
"moreMessage": "Сообщение \"ещё\"",
|
"moreMessage": "Сообщение \"ещё\"",
|
||||||
"peopleFormat": "Формат людей",
|
"peopleFormat": "Формат людей",
|
||||||
"dateLocation": "Дата и место",
|
"dateLocation": "Дата и место",
|
||||||
"dateFormat": "Формат даты",
|
"dateFormat": "Формат даты и времени",
|
||||||
|
"dateOnlyFormat": "Формат даты",
|
||||||
"commonDate": "Общая дата",
|
"commonDate": "Общая дата",
|
||||||
"uniqueDate": "Дата файла",
|
"uniqueDate": "Дата файла",
|
||||||
"locationFormat": "Формат места",
|
"locationFormat": "Формат места",
|
||||||
@@ -417,6 +431,7 @@
|
|||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
|
"clone": "Копировать",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
"close": "Закрыть",
|
"close": "Закрыть",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
|
||||||
let status = $state<any>(null);
|
let status = $state<any>(null);
|
||||||
|
let providers = $state<any[]>([]);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
@@ -16,6 +17,27 @@
|
|||||||
let displayTotal = $state(0);
|
let displayTotal = $state(0);
|
||||||
let displayTargets = $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) {
|
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
|
||||||
if (to === 0) { setter(0); return; }
|
if (to === 0) { setter(0); return; }
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
@@ -29,9 +51,67 @@
|
|||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
async function loadEvents() {
|
||||||
|
eventsLoading = true;
|
||||||
try {
|
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(() => {
|
setTimeout(() => {
|
||||||
animateCount(0, status.providers, (v) => displayProviders = v);
|
animateCount(0, status.providers, (v) => displayProviders = v);
|
||||||
animateCount(0, status.trackers.active, (v) => displayActive = v);
|
animateCount(0, status.trackers.active, (v) => displayActive = v);
|
||||||
@@ -43,7 +123,7 @@
|
|||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
const statCards = $derived(status ? [
|
const statCards = $derived(status ? [
|
||||||
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
|
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
|
||||||
@@ -77,6 +157,15 @@
|
|||||||
assets_added: '#059669', assets_removed: '#ef4444',
|
assets_added: '#059669', assets_removed: '#ef4444',
|
||||||
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
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>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
<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">
|
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||||
<MdiIcon name="mdiPulse" size={18} />
|
<MdiIcon name="mdiPulse" size={18} />
|
||||||
{t('dashboard.recentEvents')}
|
{t('dashboard.recentEvents')}
|
||||||
|
{#if status.total_events > 0}
|
||||||
|
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
|
||||||
|
{/if}
|
||||||
</h3>
|
</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>
|
<Card>
|
||||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
<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>
|
<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}
|
{#if i < status.recent_events.length - 1}<div class="event-line"></div>{/if}
|
||||||
<div class="event-content">
|
<div class="event-content">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<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;">
|
<span style="color: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; flex-shrink: 0;">
|
||||||
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
|
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-medium truncate">{event.collection_name}</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>
|
<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>
|
</div>
|
||||||
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
|
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -155,9 +155,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
|
<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 || 'mdiServer'} size={20} /></span>
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={provider.icon} size={20} /></span>
|
|
||||||
{/if}
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<p class="font-medium">{provider.name}</p>
|
<p class="font-medium">{provider.name}</p>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
async function test(id: number) {
|
async function test(id: number) {
|
||||||
try {
|
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'));
|
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||||
else snackError(`Failed: ${res.error}`);
|
else snackError(`Failed: ${res.error}`);
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: any) { snackError(err.message); }
|
||||||
@@ -235,12 +235,16 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<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>
|
<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>
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
e.preventDefault(); error = ''; submitting = true;
|
e.preventDefault(); error = ''; submitting = true;
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
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'));
|
snackSuccess(t('snack.botUpdated'));
|
||||||
} else {
|
} else {
|
||||||
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
||||||
@@ -94,12 +94,27 @@
|
|||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: any) { snackError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let chatTesting = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
function copyChatId(e: Event, chatId: string) {
|
function copyChatId(e: Event, chatId: string) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigator.clipboard.writeText(chatId);
|
navigator.clipboard.writeText(chatId);
|
||||||
snackInfo(`${t('snack.copied')}: ${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 {
|
function chatTypeLabel(type: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
private: t('telegramBot.private'),
|
private: t('telegramBot.private'),
|
||||||
@@ -161,7 +176,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<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>
|
<p class="font-medium">{bot.name}</p>
|
||||||
{#if bot.bot_username}
|
{#if bot.bot_username}
|
||||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
<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 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>
|
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||||
</div>
|
</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}
|
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
const doValidate = async () => {
|
const doValidate = async () => {
|
||||||
try {
|
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 || '' };
|
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
|
||||||
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
|
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
|
||||||
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
|
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
for (const group of templateSlots) {
|
for (const group of templateSlots) {
|
||||||
for (const slot of group.slots) {
|
for (const slot of group.slots) {
|
||||||
const template = (form as any)[slot.key];
|
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);
|
validateSlot(slot.key, template, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,7 @@
|
|||||||
scheduled_assets_message: '',
|
scheduled_assets_message: '',
|
||||||
memory_mode_message: '',
|
memory_mode_message: '',
|
||||||
date_format: '%d.%m.%Y, %H:%M UTC',
|
date_format: '%d.%m.%Y, %H:%M UTC',
|
||||||
|
date_only_format: '%d.%m.%Y',
|
||||||
});
|
});
|
||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
let previewTargetType = $state('telegram');
|
let previewTargetType = $state('telegram');
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
]},
|
]},
|
||||||
{ group: 'settings', slots: [
|
{ group: 'settings', slots: [
|
||||||
{ key: 'date_format', label: 'dateFormat', rows: 1 },
|
{ 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); }
|
} 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) {
|
function remove(id: number) {
|
||||||
confirmDelete = {
|
confirmDelete = {
|
||||||
id,
|
id,
|
||||||
@@ -198,8 +227,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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]}
|
<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" />
|
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||||
{:else}
|
{: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} />
|
<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}
|
{/if}
|
||||||
{#if slotPreview[slot.key] && !slotErrors[slot.key]}
|
{#if slotPreview[slot.key] && !slotErrors[slot.key]}
|
||||||
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
|
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm preview-html">
|
||||||
<pre class="whitespace-pre-wrap text-xs">{slotPreview[slot.key]}</pre>
|
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.key])}</pre>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -244,7 +274,7 @@
|
|||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<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>
|
<p class="font-medium">{config.name}</p>
|
||||||
{#if config.user_id === 0}
|
{#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>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 ml-4">
|
<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="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
@@ -306,3 +337,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</Modal>
|
</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 { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -29,7 +29,12 @@
|
|||||||
let toggling = $state<Record<number, boolean>>({});
|
let toggling = $state<Record<number, boolean>>({});
|
||||||
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
|
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
|
||||||
let ttTesting = $state<Record<string, string>>({});
|
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
|
// Tracker form
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
@@ -65,13 +70,14 @@
|
|||||||
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
|
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) {
|
async function edit(trk: any) {
|
||||||
form = {
|
form = {
|
||||||
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
||||||
collection_ids: [...(trk.collection_ids || [])],
|
collection_ids: [...(trk.collection_ids || [])],
|
||||||
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
|
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
|
||||||
};
|
};
|
||||||
|
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||||
editing = trk.id; showForm = true;
|
editing = trk.id; showForm = true;
|
||||||
if (form.provider_id) await loadCollections();
|
if (form.provider_id) await loadCollections();
|
||||||
}
|
}
|
||||||
@@ -79,6 +85,41 @@
|
|||||||
async function save(e: SubmitEvent) {
|
async function save(e: SubmitEvent) {
|
||||||
e.preventDefault(); error = '';
|
e.preventDefault(); error = '';
|
||||||
if (submitting) return;
|
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;
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@@ -88,9 +129,34 @@
|
|||||||
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||||
snackSuccess(t('snack.trackerCreated'));
|
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; }
|
} 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) {
|
async function toggle(tracker: any) {
|
||||||
if (toggling[tracker.id]) return;
|
if (toggling[tracker.id]) return;
|
||||||
toggling = { ...toggling, [tracker.id]: true };
|
toggling = { ...toggling, [tracker.id]: true };
|
||||||
@@ -114,22 +180,26 @@
|
|||||||
const key = `${ttId}_${testType}`;
|
const key = `${ttId}_${testType}`;
|
||||||
if (ttTesting[key]) return;
|
if (ttTesting[key]) return;
|
||||||
ttTesting = { ...ttTesting, [key]: testType };
|
ttTesting = { ...ttTesting, [key]: testType };
|
||||||
ttFeedback = { ...ttFeedback, [key]: '' };
|
|
||||||
try {
|
try {
|
||||||
const endpoint = testType === 'basic' ? 'test' : `test-${testType}`;
|
const endpoint = testType === 'basic' ? 'test' : `test-${testType}`;
|
||||||
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}`, { method: 'POST' });
|
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}?locale=${getLocale()}`, { method: 'POST' });
|
||||||
ttFeedback = { ...ttFeedback, [key]: 'ok' };
|
|
||||||
snackSuccess(t('snack.targetTestSent'));
|
snackSuccess(t('snack.targetTestSent'));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ttFeedback = { ...ttFeedback, [key]: 'error' };
|
|
||||||
snackError(err.message);
|
snackError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
ttTesting = { ...ttTesting, [key]: '' };
|
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 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 ---
|
// --- Linked Targets Management ---
|
||||||
function toggleExpand(trackerId: number) {
|
function toggleExpand(trackerId: number) {
|
||||||
if (expandedTracker === trackerId) { expandedTracker = null; return; }
|
if (expandedTracker === trackerId) { expandedTracker = null; return; }
|
||||||
@@ -227,6 +297,9 @@
|
|||||||
<input type="checkbox" checked={form.collection_ids.includes(col.id)} onchange={() => toggleCollection(col.id)} />
|
<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>
|
{col.albumName || col.name} <span class="text-[var(--color-muted-foreground)]">({col.assetCount ?? col.asset_count ?? 0})</span>
|
||||||
</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>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -243,7 +316,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,7 +339,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<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>
|
<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)]'}">
|
<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')}
|
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||||
@@ -295,7 +370,7 @@
|
|||||||
{#each tracker.tracker_targets as tt}
|
{#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 justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||||
<div class="flex items-center gap-2">
|
<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="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>
|
<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}
|
{#if !tt.enabled}
|
||||||
@@ -324,13 +399,6 @@
|
|||||||
<IconButton icon="mdiHistory" size={14} title={t('trackingConfig.memoryMode')}
|
<IconButton icon="mdiHistory" size={14} title={t('trackingConfig.memoryMode')}
|
||||||
onclick={() => testTrackerTarget(tracker.id, tt.id, 'memory')}
|
onclick={() => testTrackerTarget(tracker.id, tt.id, 'memory')}
|
||||||
disabled={!!ttTesting[`${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}
|
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
||||||
title={tt.enabled ? t('trackers.pause') : t('trackers.resume')}
|
title={tt.enabled ? t('trackers.pause') : t('trackers.resume')}
|
||||||
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
|
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
|
||||||
@@ -374,6 +442,48 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/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
|
<ConfirmModal
|
||||||
open={!!confirmDelete}
|
open={!!confirmDelete}
|
||||||
message={t('trackers.confirmDelete')}
|
message={t('trackers.confirmDelete')}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ from .webhook.client import WebhookClient
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_TEMPLATE = (
|
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 %}'
|
'{% if people %}\nPeople: {{ people | join(", ") }}{% endif %}'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,8 +32,11 @@ class TargetConfig:
|
|||||||
type: str # "telegram" or "webhook"
|
type: str # "telegram" or "webhook"
|
||||||
config: dict[str, Any] # type-specific config
|
config: dict[str, Any] # type-specific config
|
||||||
template_slots: dict[str, str] | None = None # event_type -> template string
|
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_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_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:
|
class NotificationDispatcher:
|
||||||
@@ -68,7 +73,11 @@ class NotificationDispatcher:
|
|||||||
template_str = slot
|
template_str = slot
|
||||||
|
|
||||||
# Build context and render
|
# 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)
|
message = render_template(template_str, ctx)
|
||||||
|
|
||||||
if target.type == "telegram":
|
if target.type == "telegram":
|
||||||
@@ -90,16 +99,19 @@ class NotificationDispatcher:
|
|||||||
client = TelegramClient(session, bot_token)
|
client = TelegramClient(session, bot_token)
|
||||||
|
|
||||||
# Build asset list for media sending
|
# Build asset list for media sending
|
||||||
# Only attach API key header for URLs pointing to the internal provider
|
# Attach API key header for URLs pointing to the provider (internal or external)
|
||||||
internal_url = target.provider_internal_url or ""
|
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 = []
|
assets = []
|
||||||
for asset in event.added_assets:
|
for asset in event.added_assets:
|
||||||
url = asset.full_url or asset.thumbnail_url
|
url = asset.full_url or asset.thumbnail_url
|
||||||
if url:
|
if url:
|
||||||
asset_type = "video" if asset.type.value == "video" else "photo"
|
asset_type = "video" if asset.type.value == "video" else "photo"
|
||||||
# Include API key only for internal provider URLs
|
|
||||||
asset_headers = {}
|
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
|
asset_headers["x-api-key"] = target.provider_api_key
|
||||||
assets.append({"url": url, "type": asset_type, "headers": asset_headers})
|
assets.append({"url": url, "type": asset_type, "headers": asset_headers})
|
||||||
|
|
||||||
|
|||||||
@@ -103,12 +103,14 @@ class TelegramClient:
|
|||||||
chat_id, assets[0].get("url"), caption, reply_to_message_id,
|
chat_id, assets[0].get("url"), caption, reply_to_message_id,
|
||||||
parse_mode, max_asset_data_size, send_large_photos_as_documents,
|
parse_mode, max_asset_data_size, send_large_photos_as_documents,
|
||||||
assets[0].get("content_type"), assets[0].get("cache_key"),
|
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":
|
if len(assets) == 1 and assets[0].get("type") == "video":
|
||||||
return await self._send_video(
|
return await self._send_video(
|
||||||
chat_id, assets[0].get("url"), caption, reply_to_message_id,
|
chat_id, assets[0].get("url"), caption, reply_to_message_id,
|
||||||
parse_mode, max_asset_data_size,
|
parse_mode, max_asset_data_size,
|
||||||
assets[0].get("content_type"), assets[0].get("cache_key"),
|
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":
|
if len(assets) == 1 and assets[0].get("type", "document") == "document":
|
||||||
url = assets[0].get("url")
|
url = assets[0].get("url")
|
||||||
@@ -116,7 +118,8 @@ class TelegramClient:
|
|||||||
return {"success": False, "error": "Missing 'url' for document"}
|
return {"success": False, "error": "Missing 'url' for document"}
|
||||||
try:
|
try:
|
||||||
download_url = self._resolve_url(url)
|
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:
|
if resp.status != 200:
|
||||||
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
|
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
|
||||||
data = await resp.read()
|
data = await resp.read()
|
||||||
@@ -196,6 +199,7 @@ class TelegramClient:
|
|||||||
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
|
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,
|
max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False,
|
||||||
content_type: str | None = None, cache_key: str | None = None,
|
content_type: str | None = None, cache_key: str | None = None,
|
||||||
|
download_headers: dict[str, str] | None = None,
|
||||||
) -> NotificationResult:
|
) -> NotificationResult:
|
||||||
if not content_type:
|
if not content_type:
|
||||||
content_type = "image/jpeg"
|
content_type = "image/jpeg"
|
||||||
@@ -223,7 +227,7 @@ class TelegramClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
download_url = self._resolve_url(url)
|
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:
|
if resp.status != 200:
|
||||||
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
|
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
|
||||||
data = await resp.read()
|
data = await resp.read()
|
||||||
@@ -264,7 +268,7 @@ class TelegramClient:
|
|||||||
self, chat_id: str, url: str | None, caption: str | None = None,
|
self, chat_id: str, url: str | None, caption: str | None = None,
|
||||||
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
|
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
|
||||||
max_asset_data_size: int | None = None, content_type: str | None = None,
|
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:
|
) -> NotificationResult:
|
||||||
if not content_type:
|
if not content_type:
|
||||||
content_type = "video/mp4"
|
content_type = "video/mp4"
|
||||||
@@ -291,7 +295,7 @@ class TelegramClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
download_url = self._resolve_url(url)
|
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:
|
if resp.status != 200:
|
||||||
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
|
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
|
||||||
data = await resp.read()
|
data = await resp.read()
|
||||||
@@ -396,9 +400,9 @@ class TelegramClient:
|
|||||||
chunk_caption = caption if chunk_idx == 0 else None
|
chunk_caption = caption if chunk_idx == 0 else None
|
||||||
chunk_reply = reply_to_message_id if chunk_idx == 0 else None
|
chunk_reply = reply_to_message_id if chunk_idx == 0 else None
|
||||||
if item.get("type") == "photo":
|
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":
|
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:
|
else:
|
||||||
continue
|
continue
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
@@ -435,7 +439,8 @@ class TelegramClient:
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
download_url = self._resolve_url(url)
|
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:
|
if resp.status != 200:
|
||||||
continue
|
continue
|
||||||
data = await resp.read()
|
data = await resp.read()
|
||||||
|
|||||||
@@ -184,6 +184,25 @@ class ImmichServiceProvider(ServiceProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if event:
|
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)
|
events.append(event)
|
||||||
|
|
||||||
# Update state
|
# Update state
|
||||||
@@ -226,6 +245,7 @@ class ImmichServiceProvider(ServiceProvider):
|
|||||||
"id": a.get("id", ""),
|
"id": a.get("id", ""),
|
||||||
"name": a.get("albumName", "Unnamed"),
|
"name": a.get("albumName", "Unnamed"),
|
||||||
"asset_count": a.get("assetCount", 0),
|
"asset_count": a.get("assetCount", 0),
|
||||||
|
"updated_at": a.get("updatedAt", ""),
|
||||||
}
|
}
|
||||||
for a in albums
|
for a in albums
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from notify_bridge_core.models.events import ServiceEvent
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
@@ -10,6 +11,8 @@ from notify_bridge_core.models.events import ServiceEvent
|
|||||||
def build_template_context(
|
def build_template_context(
|
||||||
event: ServiceEvent,
|
event: ServiceEvent,
|
||||||
target_type: str = "webhook",
|
target_type: str = "webhook",
|
||||||
|
date_format: str = "%d.%m.%Y, %H:%M UTC",
|
||||||
|
date_only_format: str = "%d.%m.%Y",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build a flat template context dict from a ServiceEvent.
|
"""Build a flat template context dict from a ServiceEvent.
|
||||||
|
|
||||||
@@ -56,6 +59,15 @@ def build_template_context(
|
|||||||
asset_dict.update(asset.extra)
|
asset_dict.update(asset.extra)
|
||||||
assets.append(asset_dict)
|
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["assets"] = assets
|
||||||
ctx["added_assets"] = assets # alias for backward compat
|
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_videos"] = any(a.get("type") == "VIDEO" for a in assets)
|
||||||
ctx["has_photos"] = any(a.get("type") == "IMAGE" 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
|
# Provider-specific extras merged at top level
|
||||||
ctx.update(event.extra)
|
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
|
# Provider-specific aliases for Immich
|
||||||
if event.provider_type.value == "immich":
|
if event.provider_type.value == "immich":
|
||||||
ctx.setdefault("album_name", event.collection_name)
|
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 %}
|
{%- if people %}
|
||||||
👤 {{ people | join(", ") }}
|
👤 {{ people | join(", ") }}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if added_assets %}
|
{%- if added_assets %}
|
||||||
{%- for asset in added_assets %}
|
{%- for asset in added_assets %}
|
||||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if target_type == "telegram" and has_videos %}
|
{%- if target_type == "telegram" and has_videos %}
|
||||||
|
|
||||||
⚠️ Videos may not be sent due to Telegram's 50 MB file size limit.
|
⚠️ 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 %}
|
{%- if people %}
|
||||||
👤 {{ people | join(", ") }}
|
👤 {{ people | join(", ") }}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if added_assets %}
|
{%- if added_assets %}
|
||||||
{%- for asset in added_assets %}
|
{%- for asset in added_assets %}
|
||||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if target_type == "telegram" and has_videos %}
|
{%- if target_type == "telegram" and has_videos %}
|
||||||
|
|
||||||
⚠️ Видео может не отправиться из-за ограничения Telegram в 50 МБ.
|
⚠️ Видео может не отправиться из-за ограничения 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 []
|
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:
|
def _provider_response(p: ServiceProvider) -> dict:
|
||||||
"""Build a safe response dict for a provider."""
|
"""Build a safe response dict for a provider."""
|
||||||
config = dict(p.config)
|
config = dict(p.config)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Status/dashboard API route."""
|
"""Status/dashboard API route."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlmodel import func, select
|
from sqlmodel import func, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
@@ -15,8 +15,15 @@ router = APIRouter(prefix="/api/status", tags=["status"])
|
|||||||
async def get_status(
|
async def get_status(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
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(
|
providers_count = (await session.exec(
|
||||||
select(func.count()).select_from(ServiceProvider).where(ServiceProvider.user_id == user.id)
|
select(func.count()).select_from(ServiceProvider).where(ServiceProvider.user_id == user.id)
|
||||||
)).one()
|
)).one()
|
||||||
@@ -31,24 +38,53 @@ async def get_status(
|
|||||||
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||||
)).one()
|
)).one()
|
||||||
|
|
||||||
recent_events = await session.exec(
|
# Build events query with filters
|
||||||
|
events_query = (
|
||||||
select(EventLog)
|
select(EventLog)
|
||||||
.join(Tracker, EventLog.tracker_id == Tracker.id)
|
.join(Tracker, EventLog.tracker_id == Tracker.id)
|
||||||
.where(Tracker.user_id == user.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 {
|
return {
|
||||||
"providers": providers_count,
|
"providers": providers_count,
|
||||||
"trackers": {"total": len(trackers), "active": active_count},
|
"trackers": {"total": len(trackers), "active": active_count},
|
||||||
"targets": targets_count,
|
"targets": targets_count,
|
||||||
|
"total_events": total_events,
|
||||||
"recent_events": [
|
"recent_events": [
|
||||||
{
|
{
|
||||||
"id": e.id,
|
"id": e.id,
|
||||||
"event_type": e.event_type,
|
"event_type": e.event_type,
|
||||||
"collection_name": e.collection_name,
|
"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()
|
for e in recent_events.all()
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Notification target management API routes."""
|
"""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 pydantic import BaseModel
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
@@ -8,7 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
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"])
|
router = APIRouter(prefix="/api/targets", tags=["targets"])
|
||||||
|
|
||||||
@@ -18,14 +18,12 @@ class TargetCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
icon: str = ""
|
icon: str = ""
|
||||||
config: dict[str, Any] = {}
|
config: dict[str, Any] = {}
|
||||||
template_config_id: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class TargetUpdate(BaseModel):
|
class TargetUpdate(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
config: dict[str, Any] | None = None
|
config: dict[str, Any] | None = None
|
||||||
template_config_id: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -37,18 +35,26 @@ async def list_targets(
|
|||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||||
)
|
)
|
||||||
return [
|
targets = result.all()
|
||||||
{
|
|
||||||
"id": t.id,
|
# Resolve chat names for telegram targets
|
||||||
"type": t.type,
|
chat_names: dict[str, str] = {}
|
||||||
"name": t.name,
|
for tgt in targets:
|
||||||
"icon": t.icon,
|
if tgt.type == "telegram" and tgt.config.get("chat_id"):
|
||||||
"config": _safe_config(t),
|
bot_id = tgt.config.get("bot_id")
|
||||||
"template_config_id": t.template_config_id,
|
chat_id = str(tgt.config["chat_id"])
|
||||||
"created_at": t.created_at.isoformat(),
|
if bot_id:
|
||||||
}
|
chat_result = await session.exec(
|
||||||
for t in result.all()
|
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)
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
@@ -69,7 +75,6 @@ async def create_target(
|
|||||||
name=body.name,
|
name=body.name,
|
||||||
icon=body.icon,
|
icon=body.icon,
|
||||||
config=body.config,
|
config=body.config,
|
||||||
template_config_id=body.template_config_id,
|
|
||||||
)
|
)
|
||||||
session.add(target)
|
session.add(target)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -85,14 +90,7 @@ async def get_target(
|
|||||||
):
|
):
|
||||||
"""Get a specific notification target."""
|
"""Get a specific notification target."""
|
||||||
target = await _get_user_target(session, target_id, user.id)
|
target = await _get_user_target(session, target_id, user.id)
|
||||||
return {
|
return _target_response(target)
|
||||||
"id": target.id,
|
|
||||||
"type": target.type,
|
|
||||||
"name": target.name,
|
|
||||||
"icon": target.icon,
|
|
||||||
"config": _safe_config(target),
|
|
||||||
"template_config_id": target.template_config_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{target_id}")
|
@router.put("/{target_id}")
|
||||||
@@ -104,14 +102,8 @@ async def update_target(
|
|||||||
):
|
):
|
||||||
"""Update a notification target."""
|
"""Update a notification target."""
|
||||||
target = await _get_user_target(session, target_id, user.id)
|
target = await _get_user_target(session, target_id, user.id)
|
||||||
if body.name is not None:
|
for field, value in body.model_dump(exclude_unset=True).items():
|
||||||
target.name = body.name
|
setattr(target, field, value)
|
||||||
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
|
|
||||||
session.add(target)
|
session.add(target)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(target)
|
await session.refresh(target)
|
||||||
@@ -124,8 +116,14 @@ async def delete_target(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
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)
|
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.delete(target)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -133,16 +131,36 @@ async def delete_target(
|
|||||||
@router.post("/{target_id}/test")
|
@router.post("/{target_id}/test")
|
||||||
async def test_target(
|
async def test_target(
|
||||||
target_id: int,
|
target_id: int,
|
||||||
|
locale: str = Query("en"),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Send a test notification to a target."""
|
"""Send a test notification to a target."""
|
||||||
target = await _get_user_target(session, target_id, user.id)
|
target = await _get_user_target(session, target_id, user.id)
|
||||||
from ..services.notifier import send_test_notification
|
from ..services.notifier import send_test_notification
|
||||||
result = await send_test_notification(target)
|
result = await send_test_notification(target, locale=locale)
|
||||||
return result
|
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:
|
def _safe_config(target: NotificationTarget) -> dict:
|
||||||
"""Return config with sensitive fields masked."""
|
"""Return config with sensitive fields masked."""
|
||||||
config = dict(target.config)
|
config = dict(target.config)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Telegram bot management API routes."""
|
"""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 pydantic import BaseModel
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
@@ -23,7 +23,7 @@ class BotCreate(BaseModel):
|
|||||||
|
|
||||||
class BotUpdate(BaseModel):
|
class BotUpdate(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
commands_config: dict | None = None
|
icon: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -69,12 +69,12 @@ async def update_bot(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
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)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
if body.name is not None:
|
if body.name is not None:
|
||||||
bot.name = body.name
|
bot.name = body.name
|
||||||
if body.commands_config is not None:
|
if body.icon is not None:
|
||||||
bot.commands_config = body.commands_config
|
bot.icon = body.icon
|
||||||
session.add(bot)
|
session.add(bot)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(bot)
|
await session.refresh(bot)
|
||||||
@@ -173,6 +173,37 @@ async def discover_chats(
|
|||||||
return [_chat_response(c) for c in result.all()]
|
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)
|
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_chat(
|
async def delete_chat(
|
||||||
bot_id: int,
|
bot_id: int,
|
||||||
@@ -247,10 +278,10 @@ def _bot_response(b: TelegramBot) -> dict:
|
|||||||
return {
|
return {
|
||||||
"id": b.id,
|
"id": b.id,
|
||||||
"name": b.name,
|
"name": b.name,
|
||||||
|
"icon": b.icon,
|
||||||
"bot_username": b.bot_username,
|
"bot_username": b.bot_username,
|
||||||
"bot_id": b.bot_id,
|
"bot_id": b.bot_id,
|
||||||
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
|
"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(),
|
"created_at": b.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ _SAMPLE_ASSET = {
|
|||||||
"state": "Ile-de-France",
|
"state": "Ile-de-France",
|
||||||
"country": "France",
|
"country": "France",
|
||||||
"url": "https://immich.example.com/photos/abc123",
|
"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",
|
"download_url": "https://immich.example.com/api/assets/abc123/original",
|
||||||
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
|
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
|
||||||
}
|
}
|
||||||
@@ -44,12 +45,14 @@ _SAMPLE_VIDEO_ASSET = {
|
|||||||
"is_favorite": False,
|
"is_favorite": False,
|
||||||
"rating": None,
|
"rating": None,
|
||||||
"photo_url": 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",
|
"playback_url": "https://immich.example.com/api/assets/def456/video",
|
||||||
}
|
}
|
||||||
|
|
||||||
_SAMPLE_COLLECTION = {
|
_SAMPLE_COLLECTION = {
|
||||||
"name": "Family Photos",
|
"name": "Family Photos",
|
||||||
"url": "https://immich.example.com/share/abc123",
|
"url": "https://immich.example.com/share/abc123",
|
||||||
|
"public_url": "https://immich.example.com/share/abc123",
|
||||||
"asset_count": 42,
|
"asset_count": 42,
|
||||||
"shared": True,
|
"shared": True,
|
||||||
}
|
}
|
||||||
@@ -85,10 +88,24 @@ _SAMPLE_CONTEXT = {
|
|||||||
"new_name": "New Album",
|
"new_name": "New Album",
|
||||||
"old_shared": False,
|
"old_shared": False,
|
||||||
"new_shared": True,
|
"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)
|
# Scheduled/periodic variables (for those templates)
|
||||||
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
"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",
|
"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
|
scheduled_assets_message: str | None = None
|
||||||
memory_mode_message: str | None = None
|
memory_mode_message: str | None = None
|
||||||
date_format: str | None = None
|
date_format: str | None = None
|
||||||
|
date_only_format: str | None = None
|
||||||
|
|
||||||
|
|
||||||
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
|
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
|
||||||
@@ -142,10 +160,15 @@ async def get_template_variables():
|
|||||||
"collection_id": "Collection ID (UUID)",
|
"collection_id": "Collection ID (UUID)",
|
||||||
"collection_name": "Collection name",
|
"collection_name": "Collection name",
|
||||||
"collection_url": "Public share URL (empty if not shared)",
|
"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",
|
"added_count": "Number of assets added",
|
||||||
"removed_count": "Number of assets removed",
|
"removed_count": "Number of assets removed",
|
||||||
"people": "Detected people names (list, use {{ people | join(', ') }})",
|
"people": "Detected people names (list, use {{ people | join(', ') }})",
|
||||||
"shared": "Whether collection is shared (boolean)",
|
"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'",
|
"target_type": "Target type: 'telegram' or 'webhook'",
|
||||||
"has_videos": "Whether added assets contain videos (boolean)",
|
"has_videos": "Whether added assets contain videos (boolean)",
|
||||||
"has_photos": "Whether added assets contain photos (boolean)",
|
"has_photos": "Whether added assets contain photos (boolean)",
|
||||||
@@ -177,6 +200,7 @@ async def get_template_variables():
|
|||||||
"city": "City name",
|
"city": "City name",
|
||||||
"state": "State/region name",
|
"state": "State/region name",
|
||||||
"country": "Country name",
|
"country": "Country name",
|
||||||
|
"public_url": "Per-asset public share URL (empty if no album link)",
|
||||||
"url": "Public viewer URL (if shared)",
|
"url": "Public viewer URL (if shared)",
|
||||||
"download_url": "Direct download URL (if shared)",
|
"download_url": "Direct download URL (if shared)",
|
||||||
"photo_url": "Preview image URL (images only, if shared)",
|
"photo_url": "Preview image URL (images only, if shared)",
|
||||||
@@ -185,6 +209,7 @@ async def get_template_variables():
|
|||||||
album_fields = {
|
album_fields = {
|
||||||
"name": "Collection/album name",
|
"name": "Collection/album name",
|
||||||
"url": "Share URL",
|
"url": "Share URL",
|
||||||
|
"public_url": "Public share link URL",
|
||||||
"asset_count": "Total assets in collection",
|
"asset_count": "Total assets in collection",
|
||||||
"shared": "Whether collection is shared",
|
"shared": "Whether collection is shared",
|
||||||
}
|
}
|
||||||
@@ -196,7 +221,12 @@ async def get_template_variables():
|
|||||||
return {
|
return {
|
||||||
"message_assets_added": {
|
"message_assets_added": {
|
||||||
"description": "Notification when new assets are added to a collection",
|
"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,
|
"asset_fields": asset_fields,
|
||||||
},
|
},
|
||||||
"message_assets_removed": {
|
"message_assets_removed": {
|
||||||
@@ -308,6 +338,8 @@ async def preview_config(
|
|||||||
class PreviewRequest(BaseModel):
|
class PreviewRequest(BaseModel):
|
||||||
template: str
|
template: str
|
||||||
target_type: str = "telegram" # "telegram" or "webhook"
|
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")
|
@router.post("/preview-raw")
|
||||||
@@ -334,7 +366,14 @@ async def preview_raw(
|
|||||||
|
|
||||||
# Pass 2: render with strict undefined to catch unknown variables
|
# Pass 2: render with strict undefined to catch unknown variables
|
||||||
try:
|
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)
|
strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
|
||||||
tmpl = strict_env.from_string(body.template)
|
tmpl = strict_env.from_string(body.template)
|
||||||
rendered = tmpl.render(**ctx)
|
rendered = tmpl.render(**ctx)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
@@ -152,6 +152,7 @@ async def delete_tracker_target(
|
|||||||
async def test_tracker_target(
|
async def test_tracker_target(
|
||||||
tracker_id: int,
|
tracker_id: int,
|
||||||
tracker_target_id: int,
|
tracker_target_id: int,
|
||||||
|
locale: str = Query("en"),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
@@ -166,7 +167,7 @@ async def test_tracker_target(
|
|||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail="Target not found")
|
||||||
|
|
||||||
from ..services.notifier import send_test_notification
|
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}
|
return {"target": target.name, **r}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ async def tracker_history(
|
|||||||
"collection_id": e.collection_id,
|
"collection_id": e.collection_id,
|
||||||
"collection_name": e.collection_name,
|
"collection_name": e.collection_name,
|
||||||
"details": e.details,
|
"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()
|
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="")
|
memory_mode_message: str = Field(default="")
|
||||||
|
|
||||||
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
|
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)
|
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)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
tracker_id: int | None = Field(default=None, foreign_key="tracker.id")
|
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
|
event_type: str
|
||||||
collection_id: str
|
collection_id: str
|
||||||
collection_name: str
|
collection_name: str
|
||||||
|
assets_count: int = Field(default=0)
|
||||||
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||||
created_at: datetime = Field(default_factory=_utcnow)
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
"""Notify Bridge Server — FastAPI application entry point."""
|
"""Notify Bridge Server — FastAPI application entry point."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
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.engine import init_db
|
||||||
from .database.models import * # noqa: F401,F403 — ensure all models registered
|
from .database.models import * # noqa: F401,F403 — ensure all models registered
|
||||||
|
|
||||||
from .auth.routes import router as auth_router
|
from .auth.routes import router as auth_router
|
||||||
from .api.providers import router as providers_router
|
from .api.providers import router as providers_router
|
||||||
from .api.trackers import router as trackers_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.tracking_configs import router as tracking_configs_router
|
||||||
from .api.template_configs import router as template_configs_router
|
from .api.template_configs import router as template_configs_router
|
||||||
from .api.targets import router as targets_router
|
from .api.targets import router as targets_router
|
||||||
@@ -22,6 +29,12 @@ from .api.template_vars import router as template_vars_router
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await init_db()
|
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()
|
await _seed_default_templates()
|
||||||
from .services.scheduler import start_scheduler
|
from .services.scheduler import start_scheduler
|
||||||
await start_scheduler()
|
await start_scheduler()
|
||||||
@@ -35,6 +48,7 @@ app.include_router(auth_router)
|
|||||||
app.include_router(template_vars_router)
|
app.include_router(template_vars_router)
|
||||||
app.include_router(providers_router)
|
app.include_router(providers_router)
|
||||||
app.include_router(trackers_router)
|
app.include_router(trackers_router)
|
||||||
|
app.include_router(tracker_targets_router)
|
||||||
app.include_router(tracking_configs_router)
|
app.include_router(tracking_configs_router)
|
||||||
app.include_router(template_configs_router)
|
app.include_router(template_configs_router)
|
||||||
app.include_router(targets_router)
|
app.include_router(targets_router)
|
||||||
@@ -49,7 +63,7 @@ async def health():
|
|||||||
|
|
||||||
|
|
||||||
async def _seed_default_templates():
|
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 import func, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from .database.engine import get_engine
|
from .database.engine import get_engine
|
||||||
|
|||||||
@@ -8,21 +8,37 @@ from ..database.models import NotificationTarget
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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."""
|
"""Send a simple test message to a notification target."""
|
||||||
try:
|
try:
|
||||||
if target.type == "telegram":
|
if target.type == "telegram":
|
||||||
return await _test_telegram(target)
|
return await _test_telegram(target, locale)
|
||||||
elif target.type == "webhook":
|
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}"}
|
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error("Test notification failed: %s", e)
|
_LOGGER.error("Test notification failed: %s", e)
|
||||||
return {"success": False, "error": str(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
|
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||||
|
|
||||||
bot_token = target.config.get("bot_token")
|
bot_token = target.config.get("bot_token")
|
||||||
@@ -34,7 +50,7 @@ async def _test_telegram(target: NotificationTarget) -> dict:
|
|||||||
client = TelegramClient(session, bot_token)
|
client = TelegramClient(session, bot_token)
|
||||||
return await client.send_notification(
|
return await client.send_notification(
|
||||||
chat_id=str(chat_id),
|
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"})
|
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
|
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||||
|
|
||||||
url = target.config.get("url")
|
url = target.config.get("url")
|
||||||
@@ -99,6 +115,6 @@ async def _test_webhook(target: NotificationTarget) -> dict:
|
|||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
client = WebhookClient(session, url, headers)
|
client = WebhookClient(session, url, headers)
|
||||||
return await client.send({
|
return await client.send({
|
||||||
"message": "Test notification from Notify Bridge",
|
"message": _get_test_message(locale, "webhook"),
|
||||||
"event_type": "test",
|
"event_type": "test",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -174,11 +174,16 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
|||||||
session.add(new_ts)
|
session.add(new_ts)
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
|
assets_count = event.added_count or event.removed_count or 0
|
||||||
log = EventLog(
|
log = EventLog(
|
||||||
tracker_id=tracker_id,
|
tracker_id=tracker_id,
|
||||||
|
tracker_name=tracker.name,
|
||||||
|
provider_id=provider.id,
|
||||||
|
provider_name=provider_name,
|
||||||
event_type=event.event_type.value,
|
event_type=event.event_type.value,
|
||||||
collection_id=event.collection_id,
|
collection_id=event.collection_id,
|
||||||
collection_name=event.collection_name,
|
collection_name=event.collection_name,
|
||||||
|
assets_count=assets_count,
|
||||||
details={
|
details={
|
||||||
"added_count": event.added_count,
|
"added_count": event.added_count,
|
||||||
"removed_count": event.removed_count,
|
"removed_count": event.removed_count,
|
||||||
@@ -233,8 +238,11 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
|||||||
type=ld["target_type"],
|
type=ld["target_type"],
|
||||||
config=ld["target_config"],
|
config=ld["target_config"],
|
||||||
template_slots=slots,
|
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_api_key=provider_config.get("api_key"),
|
||||||
provider_internal_url=provider_config.get("url", ""),
|
provider_internal_url=provider_config.get("url", ""),
|
||||||
|
provider_external_url=provider_config.get("external_domain", ""),
|
||||||
))
|
))
|
||||||
|
|
||||||
if target_configs:
|
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