feat: provider-strict configs, slot-based templates, broadcast targets, email bots, command templates

Major architectural improvements:
- Provider-type enforcement: configs validated against provider type at assignment
- TemplateConfig migrated to slot-based pattern (TemplateSlot child table)
- Broadcast targets: TargetReceiver child table for multi-receiver dispatch
- EmailBot: first-class email sender entity with SMTP config, test connection
- CommandTemplateConfig: generic slot-based command response templates
- Provider capability registry: dynamic slot/event/command definitions per provider
- CommandTracker play/pause button matches NotificationTracker style
This commit is contained in:
2026-03-21 16:33:24 +03:00
parent 371ea70756
commit 846d480d38
27 changed files with 2355 additions and 205 deletions
+38 -1
View File
@@ -13,6 +13,7 @@
"targets": "Targets",
"commandConfigs": "Cmd Configs",
"commandTrackers": "Cmd Trackers",
"cmdTemplateConfigs": "Cmd Templates",
"users": "Users",
"settings": "Settings",
"logout": "Logout"
@@ -478,6 +479,36 @@
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit."
},
"emailBot": {
"title": "Email Bots",
"description": "SMTP email senders for notifications",
"addBot": "Add Email Bot",
"name": "Name",
"namePlaceholder": "Family Notifications",
"email": "From Email",
"smtpHost": "SMTP Host",
"smtpPort": "Port",
"smtpUsername": "Username",
"smtpUsernamePlaceholder": "Same as email or app password",
"smtpPassword": "Password",
"passwordUnchanged": "(unchanged)",
"useTls": "Use TLS/SSL",
"testConnection": "Send test email",
"noBots": "No email bots yet.",
"confirmDelete": "Delete this email bot?"
},
"cmdTemplateConfig": {
"title": "Command Templates",
"description": "Customize command response messages with Jinja2 templates",
"newConfig": "New Config",
"name": "Name",
"namePlaceholder": "Default Immich Commands",
"descriptionPlaceholder": "e.g. Custom response formats for family bot",
"noConfigs": "No command template configs yet.",
"confirmDelete": "Delete this command template config?",
"commandResponses": "Command Responses",
"commandResponsesHint": "Leave a slot empty to use the default hardcoded response."
},
"commandConfig": {
"title": "Command Configs",
"description": "Define command settings for Telegram bot interactions",
@@ -558,7 +589,13 @@
"commandTrackerEnabled": "Command tracker enabled",
"commandTrackerDisabled": "Command tracker disabled",
"listenerAdded": "Listener added",
"listenerRemoved": "Listener removed"
"listenerRemoved": "Listener removed",
"cmdTemplateSaved": "Command template saved",
"cmdTemplateDeleted": "Command template deleted",
"emailBotCreated": "Email bot created",
"emailBotUpdated": "Email bot updated",
"emailBotDeleted": "Email bot deleted",
"emailBotTestSent": "Test email sent successfully"
},
"common": {
"loading": "Loading...",
+38 -1
View File
@@ -13,6 +13,7 @@
"targets": "Получатели",
"commandConfigs": "Конф. команд",
"commandTrackers": "Трекеры команд",
"cmdTemplateConfigs": "Шаблоны команд",
"users": "Пользователи",
"settings": "Настройки",
"logout": "Выход"
@@ -478,6 +479,36 @@
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
},
"emailBot": {
"title": "Email боты",
"description": "SMTP отправители для уведомлений по email",
"addBot": "Добавить Email бот",
"name": "Название",
"namePlaceholder": "Семейные уведомления",
"email": "Email отправителя",
"smtpHost": "SMTP сервер",
"smtpPort": "Порт",
"smtpUsername": "Имя пользователя",
"smtpUsernamePlaceholder": "Как email или пароль приложения",
"smtpPassword": "Пароль",
"passwordUnchanged": "(без изменений)",
"useTls": "Использовать TLS/SSL",
"testConnection": "Отправить тестовое письмо",
"noBots": "Email ботов пока нет.",
"confirmDelete": "Удалить этот email бот?"
},
"cmdTemplateConfig": {
"title": "Шаблоны команд",
"description": "Настройте ответы команд с помощью Jinja2 шаблонов",
"newConfig": "Новый шаблон",
"name": "Название",
"namePlaceholder": "Команды Immich по умолчанию",
"descriptionPlaceholder": "Например, пользовательские форматы ответов",
"noConfigs": "Шаблонов команд пока нет.",
"confirmDelete": "Удалить этот шаблон команд?",
"commandResponses": "Ответы команд",
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
},
"commandConfig": {
"title": "Конфигурации команд",
"description": "Настройки команд для взаимодействия с Telegram-ботами",
@@ -558,7 +589,13 @@
"commandTrackerEnabled": "Трекер команд включён",
"commandTrackerDisabled": "Трекер команд отключён",
"listenerAdded": "Слушатель добавлен",
"listenerRemoved": "Слушатель удалён"
"listenerRemoved": "Слушатель удалён",
"cmdTemplateSaved": "Шаблон команд сохранён",
"cmdTemplateDeleted": "Шаблон команд удалён",
"emailBotCreated": "Email бот создан",
"emailBotUpdated": "Email бот обновлён",
"emailBotDeleted": "Email бот удалён",
"emailBotTestSent": "Тестовое письмо отправлено"
},
"common": {
"loading": "Загрузка...",
+165
View File
@@ -0,0 +1,165 @@
/** Shared TypeScript interfaces for API entities. */
export interface ServiceProvider {
id: number;
type: string;
name: string;
icon: string;
config: Record<string, any>;
created_at: string;
}
export interface EmailBot {
id: number;
name: string;
icon: string;
email: string;
smtp_host: string;
smtp_port: number;
smtp_username: string;
smtp_password: string;
smtp_use_tls: boolean;
created_at: string;
}
export interface TelegramBot {
id: number;
name: string;
icon: string;
bot_username: string;
bot_id: number;
webhook_path_id: string;
commands_config: Record<string, any>;
token_preview: string;
created_at: string;
}
export interface TelegramChat {
id: number;
chat_id: string;
title: string;
type: string;
username: string;
discovered_at: string;
}
export interface TrackerTarget {
id: number;
target_id: number;
target_name: string | null;
target_type: string | null;
target_icon: string | null;
tracking_config_id: number | null;
template_config_id: number | null;
enabled: boolean;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
commands_config: Record<string, any> | null;
created_at: string;
}
export interface Tracker {
id: number;
name: string;
icon: string;
provider_id: number;
collection_ids: string[];
scan_interval: number;
batch_duration: number;
enabled: boolean;
tracker_targets: TrackerTarget[];
created_at: string;
}
export interface NotificationTarget {
id: number;
type: string;
name: string;
icon: string;
config: Record<string, any>;
chat_name?: string;
created_at: string;
}
export interface TrackingConfig {
id: number;
provider_type: string;
name: string;
icon: string;
track_assets_added: boolean;
track_assets_removed: boolean;
track_collection_renamed: boolean;
track_collection_deleted: boolean;
track_sharing_changed: boolean;
track_images: boolean;
track_videos: boolean;
notify_favorites_only: boolean;
include_tags: boolean;
include_asset_details: boolean;
max_assets_to_show: number;
assets_order_by: string;
assets_order: string;
periodic_enabled: boolean;
periodic_interval_days: number;
periodic_start_date: string;
periodic_times: string;
scheduled_enabled: boolean;
scheduled_times: string;
scheduled_collection_mode: string;
scheduled_limit: number;
scheduled_favorite_only: boolean;
scheduled_asset_type: string;
scheduled_min_rating: number;
scheduled_order_by: string;
scheduled_order: string;
memory_enabled: boolean;
memory_source: string;
memory_times: string;
memory_collection_mode: string;
memory_limit: number;
memory_favorite_only: boolean;
memory_asset_type: string;
memory_min_rating: number;
created_at: string;
}
export interface TemplateConfig {
id: number;
user_id: number;
provider_type: string;
name: string;
description: string;
icon: string;
slots: Record<string, string>;
date_format: string;
date_only_format: string;
created_at: string;
}
export interface EventLog {
id: number;
event_type: string;
collection_id: string;
collection_name: string;
tracker_name: string;
provider_name: string;
provider_id: number | null;
assets_count: number;
details: Record<string, any>;
created_at: string;
}
export interface User {
id: number;
username: string;
role: string;
created_at: string;
}
export interface DashboardStatus {
providers: number;
trackers: { total: number; active: number };
targets: number;
total_events: number;
recent_events: EventLog[];
}
+1
View File
@@ -46,6 +46,7 @@
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
{ href: '/command-trackers', key: 'nav.commandTrackers', icon: 'mdiConsoleLine' },
{ href: '/command-configs', key: 'nav.commandConfigs', icon: 'mdiCog' },
{ href: '/command-template-configs', key: 'nav.cmdTemplateConfigs', icon: 'mdiCodeBracesBox' },
];
const navItems = $derived(auth.isAdmin
? [...baseNavItems, { href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' }, { href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' }]
@@ -0,0 +1,298 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
interface CmdTemplateConfig {
id: number;
user_id: number;
provider_type: string;
name: string;
description: string;
icon: string;
slots: Record<string, string>;
created_at: string;
}
interface SlotDef {
name: string;
description: string;
}
let configs = $state<CmdTemplateConfig[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({});
let slotErrorTypes = $state<Record<string, string>>({});
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
// Load command slot definitions from capabilities
let commandSlots = $state<SlotDef[]>([]);
const defaultForm = () => ({
provider_type: 'immich',
name: '',
description: '',
icon: '',
slots: {} as Record<string, string>,
});
let form = $state(defaultForm());
onMount(load);
async function load() {
try {
const [cfgs, caps] = await Promise.all([
api('/command-template-configs'),
api('/providers/capabilities/immich'),
]);
configs = cfgs;
commandSlots = caps.command_slots || [];
} catch (err: any) {
error = err.message || t('common.loadError');
snackError(error);
} finally {
loaded = true;
}
}
function validateSlot(slotKey: string, template: string, immediate = false) {
if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]);
if (!template) {
slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
const { [slotKey]: _, ...rest } = slotPreview;
slotPreview = rest;
return;
}
const doValidate = async () => {
try {
const res = await api('/command-template-configs/preview-raw', {
method: 'POST',
body: JSON.stringify({ template }),
});
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
if (res.rendered) {
slotPreview = { ...slotPreview, [slotKey]: res.rendered };
} else {
const { [slotKey]: _, ...rest } = slotPreview;
slotPreview = rest;
}
} catch {
slotErrors = { ...slotErrors, [slotKey]: '' };
}
};
if (immediate) doValidate();
else validateTimers[slotKey] = setTimeout(doValidate, 800);
}
function refreshAllPreviews() {
for (const slot of commandSlots) {
const template = form.slots[slot.name] || '';
if (template) validateSlot(slot.name, template, true);
}
}
function openNew() {
form = defaultForm();
editing = null;
showForm = true;
slotPreview = {};
slotErrors = {};
}
function edit(c: CmdTemplateConfig) {
form = {
provider_type: c.provider_type,
name: c.name,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
};
editing = c.id;
showForm = true;
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
}
async function save(e: SubmitEvent) {
e.preventDefault();
error = '';
try {
if (editing) {
await api(`/command-template-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
} else {
await api('/command-template-configs', { method: 'POST', body: JSON.stringify(form) });
}
showForm = false;
editing = null;
await load();
snackSuccess(t('snack.cmdTemplateSaved'));
} catch (err: any) {
error = err.message;
snackError(err.message);
}
}
function clone(c: CmdTemplateConfig) {
form = {
provider_type: c.provider_type,
name: `${c.name} (Copy)`,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
};
editing = null;
showForm = true;
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try {
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.cmdTemplateDeleted'));
} catch (err: any) {
error = err.message;
snackError(err.message);
} finally {
confirmDelete = null;
}
},
};
}
</script>
<PageHeader title={t('cmdTemplateConfig.title')} description={t('cmdTemplateConfig.description')}>
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? t('common.cancel') : t('cmdTemplateConfig.newConfig')}
</button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<div in:slide={{ duration: 200 }}>
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-5">
<div>
<label for="ct-name" class="block text-sm font-medium mb-1">{t('cmdTemplateConfig.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="ct-name" bind:value={form.name} required placeholder={t('cmdTemplateConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="ct-desc" class="block text-sm font-medium mb-1">{t('common.description')}</label>
<input id="ct-desc" bind:value={form.description} placeholder={t('cmdTemplateConfig.descriptionPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
<p class="text-xs text-[var(--color-muted-foreground)] mb-3">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<div class="space-y-3 mt-2">
{#each commandSlots as slot}
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs text-[var(--color-muted-foreground)]">/{slot.name}</label>
<span class="text-xs text-[var(--color-muted-foreground)]">{slot.description}</span>
</div>
<JinjaEditor
value={form.slots[slot.name] || ''}
onchange={(v: string) => { form.slots[slot.name] = v; validateSlot(slot.name, v); }}
rows={3}
errorLine={slotErrorLines[slot.name] || null}
/>
{#if slotErrors[slot.name]}
{#if slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">{t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
{/if}
{/if}
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
<pre class="whitespace-pre-wrap text-xs">{slotPreview[slot.name]}</pre>
</div>
{/if}
</div>
{/each}
</div>
</fieldset>
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{editing ? t('common.save') : t('common.create')}
</button>
</form>
</Card>
</div>
{/if}
{#if configs.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiConsoleLine" message={t('cmdTemplateConfig.noConfigs')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each configs as config}
<Card hover>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{config.name}</p>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">System</span>
{/if}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} slots</span>
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
@@ -210,11 +210,6 @@
</select>
</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" bind:checked={form.enabled} />
{t('commandTracker.enabled')}
</label>
<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">
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
@@ -252,10 +247,7 @@
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
<button onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]}
class="text-xs px-2 py-1 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] disabled:opacity-50">
{trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
</button>
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
<button onclick={() => toggleListeners(trk.id)}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('commandTracker.listeners')} {expandedTracker === trk.id ? '▲' : '▼'}
@@ -227,6 +227,16 @@
// tracker_targets already loaded in tracker response
}
function getProviderType(tracker: any): string {
const p = providers.find(p => p.id === tracker.provider_id);
return p?.type || '';
}
function configsForTracker(tracker: any, configs: (TrackingConfig | TemplateConfig)[]): any[] {
const pt = getProviderType(tracker);
return pt ? configs.filter((c: any) => c.provider_type === pt) : configs;
}
function getUnlinkedTargets(tracker: any): any[] {
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id));
return targets.filter(t => !linkedIds.has(t.id));
@@ -399,13 +409,13 @@
onchange={(e: Event) => updateTargetLink(tracker.id, tt, 'tracking_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}>— {t('trackingConfig.title')} —</option>
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
{#each configsForTracker(tracker, trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select value={tt.template_config_id || 0}
onchange={(e: Event) => updateTargetLink(tracker.id, tt, 'template_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}>— {t('templateConfig.title')} —</option>
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
{#each configsForTracker(tracker, templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<div class="relative">
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
@@ -433,12 +443,12 @@
<select bind:value={newLinkTrackingConfigId[tracker.id]}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('trackingConfig.title')} —</option>
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
{#each configsForTracker(tracker, trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select bind:value={newLinkTemplateConfigId[tracker.id]}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('templateConfig.title')} —</option>
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
{#each configsForTracker(tracker, templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<button onclick={() => addTargetLink(tracker.id)}
disabled={!newLinkTargetId[tracker.id] || addingTarget[tracker.id]}
+165 -2
View File
@@ -12,9 +12,10 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
import type { TelegramBot, TelegramChat } from '$lib/types';
import type { TelegramBot, TelegramChat, EmailBot } from '$lib/types';
let bots = $state<TelegramBot[]>([]);
let emailBots = $state<EmailBot[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
@@ -37,9 +38,10 @@
onMount(load);
async function load() {
try {
[bots, settings] = await Promise.all([
[bots, settings, emailBots] = await Promise.all([
api('/telegram-bots'),
api('/settings'),
api('/email-bots'),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; }
@@ -214,6 +216,68 @@
};
return map[type] || type;
}
// --- Email Bot state ---
let showEmailForm = $state(false);
let editingEmail = $state<number | null>(null);
let emailSubmitting = $state(false);
let emailTesting = $state<Record<number, boolean>>({});
let confirmDeleteEmail = $state<any>(null);
const defaultEmailForm = () => ({
name: '', icon: '', email: '', smtp_host: '', smtp_port: 587,
smtp_username: '', smtp_password: '', smtp_use_tls: true,
});
let emailForm = $state(defaultEmailForm());
function openNewEmail() { emailForm = defaultEmailForm(); editingEmail = null; showEmailForm = true; }
function editEmailBot(bot: EmailBot) {
emailForm = {
name: bot.name, icon: bot.icon || '', email: bot.email,
smtp_host: bot.smtp_host, smtp_port: bot.smtp_port,
smtp_username: bot.smtp_username, smtp_password: '',
smtp_use_tls: bot.smtp_use_tls,
};
editingEmail = bot.id; showEmailForm = true;
}
async function saveEmailBot(e: SubmitEvent) {
e.preventDefault(); error = ''; emailSubmitting = true;
try {
const body = { ...emailForm };
if (editingEmail) {
// Don't send empty password on update (means "keep current")
if (!body.smtp_password) delete (body as any).smtp_password;
await api(`/email-bots/${editingEmail}`, { method: 'PUT', body: JSON.stringify(body) });
snackSuccess(t('snack.emailBotUpdated'));
} else {
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.emailBotCreated'));
}
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { emailSubmitting = false; }
}
function removeEmail(id: number) {
confirmDeleteEmail = {
id,
onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.emailBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDeleteEmail = null; }
}
};
}
async function testEmailBot(botId: number) {
emailTesting = { ...emailTesting, [botId]: true };
try {
const res = await api(`/email-bots/${botId}/test`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
emailTesting = { ...emailTesting, [botId]: false };
}
</script>
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}>
@@ -434,7 +498,106 @@
</div>
{/if}
<!-- ======= Email Bots Section ======= -->
<div class="mt-8">
<PageHeader title={t('emailBot.title')} description={t('emailBot.description')}>
<button onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showEmailForm ? t('common.cancel') : t('emailBot.addBot')}
</button>
</PageHeader>
{#if showEmailForm}
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={saveEmailBot} class="space-y-3">
<div>
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
<input id="ebot-name" bind:value={emailForm.name} required placeholder={t('emailBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="ebot-email" class="block text-sm font-medium mb-1">{t('emailBot.email')}</label>
<input id="ebot-email" bind:value={emailForm.email} required type="email" placeholder="notify@example.com"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="ebot-host" class="block text-sm font-medium mb-1">{t('emailBot.smtpHost')}</label>
<input id="ebot-host" bind:value={emailForm.smtp_host} required placeholder="smtp.gmail.com"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="ebot-port" class="block text-sm font-medium mb-1">{t('emailBot.smtpPort')}</label>
<input id="ebot-port" bind:value={emailForm.smtp_port} type="number" min="1" max="65535"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="ebot-user" class="block text-sm font-medium mb-1">{t('emailBot.smtpUsername')}</label>
<input id="ebot-user" bind:value={emailForm.smtp_username} placeholder={t('emailBot.smtpUsernamePlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="ebot-pass" class="block text-sm font-medium mb-1">{t('emailBot.smtpPassword')}</label>
<input id="ebot-pass" bind:value={emailForm.smtp_password} type="password" placeholder={editingEmail ? t('emailBot.passwordUnchanged') : ''}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" bind:checked={emailForm.smtp_use_tls} />
{t('emailBot.useTls')}
</label>
<button type="submit" disabled={emailSubmitting}
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">
{emailSubmitting ? t('common.loading') : (editingEmail ? t('common.save') : t('emailBot.addBot'))}
</button>
</form>
</Card>
{/if}
{#if emailBots.length === 0 && !showEmailForm}
<Card>
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each emailBots as bot}
<Card hover>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
{#if bot.smtp_use_tls}
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500">TLS</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiEmailSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
</div>
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
@@ -39,8 +39,8 @@
const res = await api('/template-configs/preview-date-format', {
method: 'POST',
body: JSON.stringify({
date_format: (form as any).date_format,
date_only_format: (form as any).date_only_format,
date_format: form.date_format,
date_only_format: form.date_only_format,
}),
});
dateFormatPreview = res;
@@ -63,7 +63,7 @@
const doValidate = async () => {
try {
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType, date_format: (form as any).date_format, date_only_format: (form as any).date_only_format }) });
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType, date_format: form.date_format, date_only_format: form.date_only_format }) });
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
@@ -86,8 +86,9 @@
function refreshAllPreviews() {
for (const group of templateSlots) {
for (const slot of group.slots) {
const template = (form as any)[slot.key];
if (template && slot.key !== 'date_format' && slot.key !== 'date_only_format') {
if (slot.isDateFormat) continue;
const template = form.slots[slot.key] || '';
if (template) {
validateSlot(slot.key, template, true);
}
}
@@ -97,14 +98,7 @@
const defaultForm = () => ({
provider_type: 'immich', name: '', description: '', icon: '',
message_assets_added: '',
message_assets_removed: '',
message_collection_renamed: '',
message_collection_deleted: '',
message_sharing_changed: '',
periodic_summary_message: '',
scheduled_assets_message: '',
memory_mode_message: '',
slots: {} as Record<string, string>,
date_format: '%d.%m.%Y, %H:%M UTC',
date_only_format: '%d.%m.%Y',
});
@@ -125,8 +119,8 @@
{ key: 'memory_mode_message', label: 'memoryMode', rows: 6 },
]},
{ group: 'settings', slots: [
{ key: 'date_format', label: 'dateFormat', rows: 1 },
{ key: 'date_only_format', label: 'dateOnlyFormat', rows: 1 },
{ key: 'date_format', label: 'dateFormat', rows: 1, isDateFormat: true },
{ key: 'date_only_format', label: 'dateOnlyFormat', rows: 1, isDateFormat: true },
]},
];
@@ -142,8 +136,17 @@
}
function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); }
function edit(c: any) {
form = { ...defaultForm(), ...c }; editing = c.id; showForm = true;
function edit(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
name: c.name,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = c.id; showForm = true;
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
setTimeout(() => refreshAllPreviews(), 100);
}
@@ -158,11 +161,16 @@
} catch (err: any) { error = err.message; snackError(err.message); }
}
function clone(c: any) {
form = { ...defaultForm(), ...c, name: `${c.name} (Copy)`, description: c.description || '' };
delete (form as any).id;
delete (form as any).user_id;
delete (form as any).created_at;
function clone(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
name: `${c.name} (Copy)`,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = null;
showForm = true;
slotPreview = {};
@@ -249,9 +257,9 @@
{/if}
</div>
</div>
{#if slot.key === 'date_format' || slot.key === 'date_only_format'}
<input bind:value={(form as any)[slot.key]}
oninput={() => { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }}
{#if slot.isDateFormat}
<input value={(form as any)[slot.key]}
oninput={(e: Event) => { (form as any)[slot.key] = (e.target as HTMLInputElement).value; clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{#if dateFormatPreview[slot.key]}
<p class="mt-1 text-xs font-mono" style="color: var(--color-muted-foreground);">{t('templateConfig.preview')}: <span style="color: var(--color-foreground);">{dateFormatPreview[slot.key]}</span></p>
@@ -259,7 +267,7 @@
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
{/if}
{: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.slots[slot.key] || ''} onchange={(v: string) => { form.slots[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
{#if slotErrors[slot.key]}
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
+1
View File
@@ -10,6 +10,7 @@ requires-python = ">=3.12"
dependencies = [
"aiohttp>=3.9",
"jinja2>=3.1",
"aiosmtplib>=3.0",
]
[project.optional-dependencies]
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
import aiohttp
@@ -25,14 +25,16 @@ DEFAULT_TEMPLATE = '{{ event_type }}: "{{ collection_name }}"'
class TargetConfig:
"""Configuration for a notification target."""
type: str # "telegram" or "webhook"
config: dict[str, Any] # type-specific config
type: str # "telegram", "webhook", or "email"
config: dict[str, Any] # target-level config (bot_token, settings, etc.)
template_slots: dict[str, str] | None = None # event_type -> template string
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
provider_api_key: str | None = None # API key for downloading assets from provider
provider_internal_url: str | None = None # Internal provider URL for API key scoping
provider_external_url: str | None = None # External domain for API key scoping
# Broadcast receivers — if non-empty, sends to each receiver instead of config
receivers: list[dict[str, Any]] = field(default_factory=list)
class NotificationDispatcher:
@@ -69,7 +71,7 @@ class NotificationDispatcher:
async def _send_to_target(
self, event: ServiceEvent, target: TargetConfig
) -> dict[str, Any]:
"""Send event to a single target."""
"""Send event to a single target (potentially multiple receivers)."""
# Select template
template_str = DEFAULT_TEMPLATE
if target.template_slots:
@@ -77,7 +79,7 @@ class NotificationDispatcher:
if slot:
template_str = slot
# Build context and render
# Build context and render ONCE
ctx = build_template_context(
event, target_type=target.type,
date_format=target.date_format,
@@ -89,13 +91,14 @@ class NotificationDispatcher:
return await self._send_telegram(target, message, event)
elif target.type == "webhook":
return await self._send_webhook(target, message, event)
elif target.type == "email":
return await self._send_email(target, message, event)
return {"success": False, "error": f"Unknown target type: {target.type}"}
async def _send_telegram(
self, target: TargetConfig, message: str, event: ServiceEvent
) -> dict[str, Any]:
bot_token = target.config.get("bot_token")
chat_id = target.config.get("chat_id")
disable_preview = target.config.get("disable_url_preview", False)
max_media = target.config.get("max_media_to_send", 50)
max_group = target.config.get("max_media_per_group", 10)
@@ -105,9 +108,29 @@ class NotificationDispatcher:
max_size = max_size * 1024 * 1024 # MB to bytes
send_large_as_docs = target.config.get("send_large_photos_as_documents", False)
if not bot_token or not chat_id:
return {"success": False, "error": "Missing bot_token or chat_id"}
if not bot_token:
return {"success": False, "error": "Missing bot_token"}
# Resolve receivers — broadcast to each, or fall back to legacy chat_id in config
receivers = target.receivers or [{"chat_id": target.config.get("chat_id")}]
# Prepare assets list once (shared across receivers)
provider_urls = []
if target.provider_internal_url:
provider_urls.append(target.provider_internal_url)
if target.provider_external_url:
provider_urls.append(target.provider_external_url)
assets = []
for asset in event.added_assets[:max_media]:
url = asset.full_url or asset.thumbnail_url
if url:
asset_type = "video" if asset.type.value == "video" else "photo"
asset_headers = {}
if target.provider_api_key and any(url.startswith(u) for u in provider_urls):
asset_headers["x-api-key"] = target.provider_api_key
assets.append({"url": url, "type": asset_type, "headers": asset_headers})
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
client = TelegramClient(
session, bot_token,
@@ -115,55 +138,55 @@ class NotificationDispatcher:
asset_cache=self._asset_cache,
)
# Step 1: Send the text message first
text_result = await client.send_message(
chat_id=str(chat_id),
text=message,
disable_web_page_preview=bool(disable_preview),
)
if not text_result.get("success"):
return text_result
for receiver in receivers:
chat_id = receiver.get("chat_id")
if not chat_id:
results.append({"success": False, "error": "Missing chat_id in receiver"})
continue
# Step 2: Send assets as reply to the text message
provider_urls = []
if target.provider_internal_url:
provider_urls.append(target.provider_internal_url)
if target.provider_external_url:
provider_urls.append(target.provider_external_url)
assets = []
for asset in event.added_assets[:max_media]:
url = asset.full_url or asset.thumbnail_url
if url:
asset_type = "video" if asset.type.value == "video" else "photo"
asset_headers = {}
if target.provider_api_key and any(url.startswith(u) for u in provider_urls):
asset_headers["x-api-key"] = target.provider_api_key
assets.append({"url": url, "type": asset_type, "headers": asset_headers})
if assets:
reply_to = text_result.get("message_id")
media_result = await client.send_notification(
# Step 1: Send the text message
text_result = await client.send_message(
chat_id=str(chat_id),
assets=assets,
reply_to_message_id=reply_to,
max_group_size=max_group,
chunk_delay=chunk_delay,
max_asset_data_size=max_size,
send_large_photos_as_documents=send_large_as_docs,
text=message,
disable_web_page_preview=bool(disable_preview),
)
if not media_result.get("success"):
_LOGGER.warning("Text sent OK but media failed: %s", media_result.get("error"))
if not text_result.get("success"):
_LOGGER.warning("Failed to send to chat %s: %s", chat_id, text_result.get("error"))
results.append(text_result)
continue
return text_result
# Step 2: Send assets as reply
if assets:
reply_to = text_result.get("message_id")
media_result = await client.send_notification(
chat_id=str(chat_id),
assets=assets,
reply_to_message_id=reply_to,
max_group_size=max_group,
chunk_delay=chunk_delay,
max_asset_data_size=max_size,
send_large_photos_as_documents=send_large_as_docs,
)
if not media_result.get("success"):
_LOGGER.warning("Text sent OK but media failed for chat %s: %s", chat_id, media_result.get("error"))
results.append(text_result)
# Return aggregate result
successes = sum(1 for r in results if r.get("success"))
if successes == len(results):
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0] # All failed — return first error
return {"success": False, "error": "No receivers configured"}
async def _send_webhook(
self, target: TargetConfig, message: str, event: ServiceEvent
) -> dict[str, Any]:
url = target.config.get("url")
headers = target.config.get("headers", {})
if not url:
return {"success": False, "error": "Missing url in target config"}
# Resolve receivers — broadcast to each, or fall back to legacy url in config
receivers = target.receivers or [{"url": target.config.get("url"), "headers": target.config.get("headers", {})}]
payload = {
"message": message,
@@ -174,6 +197,68 @@ class NotificationDispatcher:
"timestamp": event.timestamp.isoformat(),
}
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
client = WebhookClient(session, url, headers)
return await client.send(payload)
for receiver in receivers:
url = receiver.get("url")
headers = receiver.get("headers", {})
if not url:
results.append({"success": False, "error": "Missing url in receiver"})
continue
client = WebhookClient(session, url, headers)
results.append(await client.send(payload))
successes = sum(1 for r in results if r.get("success"))
if successes == len(results):
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No receivers configured"}
async def _send_email(
self, target: TargetConfig, message: str, event: ServiceEvent
) -> dict[str, Any]:
from .email.client import EmailClient, SmtpConfig
smtp_cfg = target.config.get("smtp", {})
if not smtp_cfg.get("host"):
return {"success": False, "error": "SMTP not configured"}
client = EmailClient(SmtpConfig(
host=smtp_cfg["host"],
port=int(smtp_cfg.get("port", 587)),
username=smtp_cfg.get("username", ""),
password=smtp_cfg.get("password", ""),
from_address=smtp_cfg.get("from_address", ""),
from_name=smtp_cfg.get("from_name", "Notify Bridge"),
use_tls=smtp_cfg.get("use_tls", True),
))
# Resolve receivers
receivers = target.receivers or [{"email": target.config.get("email", "")}]
subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}"
results: list[dict[str, Any]] = []
for receiver in receivers:
email = receiver.get("email")
if not email:
results.append({"success": False, "error": "Missing email in receiver"})
continue
result = await client.send(
to_email=email,
subject=subject,
body_text=message,
to_name=receiver.get("name", ""),
)
results.append(result)
successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and results:
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No receivers configured"}
@@ -0,0 +1,5 @@
"""Email notification client."""
from .client import EmailClient
__all__ = ["EmailClient"]
@@ -0,0 +1,76 @@
"""Async email client using aiosmtplib."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any
_LOGGER = logging.getLogger(__name__)
@dataclass
class SmtpConfig:
"""SMTP connection settings."""
host: str
port: int = 587
username: str = ""
password: str = ""
from_address: str = ""
from_name: str = "Notify Bridge"
use_tls: bool = True
class EmailClient:
"""Sends email notifications via SMTP."""
def __init__(self, smtp_config: SmtpConfig) -> None:
self._config = smtp_config
async def send(
self,
to_email: str,
subject: str,
body_text: str,
body_html: str | None = None,
to_name: str = "",
) -> dict[str, Any]:
"""Send an email. Returns {"success": True} or {"success": False, "error": "..."}."""
try:
import aiosmtplib
except ImportError:
return {"success": False, "error": "aiosmtplib not installed. Run: pip install aiosmtplib"}
cfg = self._config
if not cfg.host or not cfg.from_address:
return {"success": False, "error": "SMTP not configured (missing host or from_address)"}
# Build email message
msg = MIMEMultipart("alternative")
msg["From"] = f"{cfg.from_name} <{cfg.from_address}>" if cfg.from_name else cfg.from_address
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
msg["Subject"] = subject
msg.attach(MIMEText(body_text, "plain", "utf-8"))
if body_html:
msg.attach(MIMEText(body_html, "html", "utf-8"))
try:
await aiosmtplib.send(
msg,
hostname=cfg.host,
port=cfg.port,
username=cfg.username or None,
password=cfg.password or None,
use_tls=cfg.use_tls,
start_tls=not cfg.use_tls and cfg.port != 25,
)
_LOGGER.info("Email sent to %s", to_email)
return {"success": True}
except Exception as e:
_LOGGER.error("Failed to send email to %s: %s", to_email, e)
return {"success": False, "error": str(e)}
@@ -0,0 +1,107 @@
"""Provider capability registry.
Defines what events, template slots, commands, and variables each provider type supports.
Used by the frontend to dynamically show relevant UI elements.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ProviderCapabilities:
"""What a provider type supports."""
provider_type: str
display_name: str
# Notification template slots (used in TemplateConfig)
notification_slots: list[dict[str, str]] = field(default_factory=list)
# Command template slots (used in CommandTemplateConfig)
command_slots: list[dict[str, str]] = field(default_factory=list)
# Events the provider can generate
events: list[dict[str, str]] = field(default_factory=list)
# Commands the provider supports
commands: list[dict[str, str]] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Immich provider capabilities
# ---------------------------------------------------------------------------
IMMICH_CAPABILITIES = ProviderCapabilities(
provider_type="immich",
display_name="Immich",
notification_slots=[
{"name": "message_assets_added", "description": "New assets added to album"},
{"name": "message_assets_removed", "description": "Assets removed from album"},
{"name": "message_collection_renamed", "description": "Album renamed"},
{"name": "message_collection_deleted", "description": "Album deleted"},
{"name": "message_sharing_changed", "description": "Sharing status changed"},
{"name": "periodic_summary_message", "description": "Periodic album summary"},
{"name": "scheduled_assets_message", "description": "Scheduled asset delivery"},
{"name": "memory_mode_message", "description": "On This Day memories"},
],
command_slots=[
{"name": "start", "description": "/start greeting message"},
{"name": "help", "description": "/help command listing"},
{"name": "status", "description": "/status tracker summary"},
{"name": "albums", "description": "/albums tracked albums list"},
{"name": "events", "description": "/events recent events"},
{"name": "people", "description": "/people detected people"},
{"name": "search", "description": "/search results (also /find, /person, /place)"},
{"name": "latest", "description": "/latest recent photos"},
{"name": "favorites", "description": "/favorites starred items"},
{"name": "random", "description": "/random random photos"},
{"name": "summary", "description": "/summary album summary"},
{"name": "memory", "description": "/memory On This Day photos"},
{"name": "rate_limited", "description": "Rate limit warning message"},
{"name": "no_results", "description": "Empty results fallback"},
],
events=[
{"name": "assets_added", "description": "New assets detected in album"},
{"name": "assets_removed", "description": "Assets removed from album"},
{"name": "collection_renamed", "description": "Album was renamed"},
{"name": "collection_deleted", "description": "Album was deleted"},
{"name": "sharing_changed", "description": "Album sharing status changed"},
],
commands=[
{"name": "status", "description": "Show tracker status"},
{"name": "albums", "description": "List tracked albums"},
{"name": "events", "description": "Show recent events"},
{"name": "summary", "description": "Send album summary"},
{"name": "latest", "description": "Show latest photos"},
{"name": "memory", "description": "On This Day memories"},
{"name": "random", "description": "Random photos"},
{"name": "search", "description": "Search assets"},
{"name": "find", "description": "Find assets by name"},
{"name": "person", "description": "Find photos by person"},
{"name": "place", "description": "Find photos by location"},
{"name": "favorites", "description": "Show favorites"},
{"name": "people", "description": "List detected people"},
{"name": "help", "description": "Show commands"},
],
)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, ProviderCapabilities] = {
"immich": IMMICH_CAPABILITIES,
}
def get_capabilities(provider_type: str) -> ProviderCapabilities | None:
"""Get capabilities for a provider type."""
return _REGISTRY.get(provider_type)
def get_all_capabilities() -> dict[str, ProviderCapabilities]:
"""Get all registered provider capabilities."""
return dict(_REGISTRY)
@@ -0,0 +1,230 @@
"""Command template configuration CRUD API routes.
Template content is stored in CommandTemplateSlot child rows (one per slot_name).
Slot names correspond to command names (e.g. 'status', 'help', 'albums').
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from jinja2.sandbox import SandboxedEnvironment
from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import CommandTemplateConfig, CommandTemplateSlot, User
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/command-template-configs", tags=["command-template-configs"])
class CommandTemplateConfigCreate(BaseModel):
provider_type: str
name: str
description: str | None = None
icon: str | None = None
slots: dict[str, str] = {} # slot_name -> template text
class CommandTemplateConfigUpdate(BaseModel):
name: str | None = None
description: str | None = None
icon: str | None = None
slots: dict[str, str] | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, str]:
result = await session.exec(
select(CommandTemplateSlot).where(CommandTemplateSlot.config_id == config_id)
)
return {s.slot_name: s.template for s in result.all()}
async def _save_slots(session: AsyncSession, config_id: int, slots: dict[str, str]) -> None:
for slot_name, template_text in slots.items():
result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config_id,
CommandTemplateSlot.slot_name == slot_name,
)
)
existing = result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=config_id,
slot_name=slot_name,
template=template_text,
))
async def _response(session: AsyncSession, c: CommandTemplateConfig) -> dict[str, Any]:
slots = await _load_slots(session, c.id)
return {
"id": c.id,
"user_id": c.user_id,
"provider_type": c.provider_type,
"name": c.name,
"description": c.description,
"icon": c.icon,
"slots": slots,
"created_at": c.created_at.isoformat(),
}
async def _get(session: AsyncSession, config_id: int, user_id: int) -> CommandTemplateConfig:
config = await session.get(CommandTemplateConfig, config_id)
if not config or (config.user_id != user_id and config.user_id != 0):
raise HTTPException(status_code=404, detail="Command template config not found")
return config
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("")
async def list_configs(
provider_type: str | None = None,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
from sqlalchemy import or_
query = select(CommandTemplateConfig).where(
or_(CommandTemplateConfig.user_id == user.id, CommandTemplateConfig.user_id == 0)
)
if provider_type:
query = query.where(CommandTemplateConfig.provider_type == provider_type)
result = await session.exec(query)
return [await _response(session, c) for c in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_config(
body: CommandTemplateConfigCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = CommandTemplateConfig(
user_id=user.id,
provider_type=body.provider_type,
name=body.name,
description=body.description or "",
icon=body.icon or "",
)
session.add(config)
await session.flush()
if body.slots:
await _save_slots(session, config.id, body.slots)
await session.commit()
await session.refresh(config)
return await _response(session, config)
@router.get("/{config_id}")
async def get_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
return await _response(session, config)
@router.put("/{config_id}")
async def update_config(
config_id: int,
body: CommandTemplateConfigUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items():
if value is not None:
setattr(config, field, value)
session.add(config)
if body.slots is not None:
await _save_slots(session, config.id, body.slots)
await session.commit()
await session.refresh(config)
return await _response(session, config)
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
slot_result = await session.exec(
select(CommandTemplateSlot).where(CommandTemplateSlot.config_id == config.id)
)
for slot in slot_result.all():
await session.delete(slot)
await session.delete(config)
await session.commit()
class PreviewRequest(BaseModel):
template: str
@router.post("/preview-raw")
async def preview_raw(
body: PreviewRequest,
user: User = Depends(get_current_user),
):
"""Render arbitrary Jinja2 template text with sample command context."""
sample_ctx = {
"trackers_active": 2,
"trackers_total": 3,
"total_albums": 5,
"last_event": "2026-03-19 14:30",
"albums": [
{"name": "Family Photos", "asset_count": 142, "url": "https://example.com/albums/1"},
{"name": "Vacation 2025", "asset_count": 87, "url": "https://example.com/albums/2"},
],
"events": [
{"type": "assets_added", "album": "Family Photos", "count": 3, "date": "2026-03-19 14:30"},
{"type": "assets_removed", "album": "Vacation 2025", "count": 1, "date": "2026-03-19 12:00"},
],
"people": ["Alice", "Bob", "Charlie"],
"assets": [
{"filename": "IMG_001.jpg", "type": "IMAGE", "created_at": "2026-03-19T14:30:00"},
{"filename": "VID_002.mp4", "type": "VIDEO", "created_at": "2026-03-19T15:00:00"},
],
"search_query": "sunset",
"search_results_count": 5,
"command": "status",
"bot_name": "NotifyBridgeBot",
"locale": "en",
}
try:
env = SandboxedEnvironment(autoescape=False)
env.from_string(body.template)
except TemplateSyntaxError as e:
return {"rendered": None, "error": e.message, "error_line": e.lineno}
try:
strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
tmpl = strict_env.from_string(body.template)
rendered = tmpl.render(**sample_ctx)
return {"rendered": rendered}
except UndefinedError as e:
return {"rendered": None, "error": str(e), "error_line": None, "error_type": "undefined"}
except Exception as e:
return {"rendered": None, "error": str(e), "error_line": None}
@@ -0,0 +1,148 @@
"""Email bot management API routes."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import EmailBot, User
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/email-bots", tags=["email-bots"])
class EmailBotCreate(BaseModel):
name: str
icon: str = ""
email: str
smtp_host: str
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_use_tls: bool = True
class EmailBotUpdate(BaseModel):
name: str | None = None
icon: str | None = None
email: str | None = None
smtp_host: str | None = None
smtp_port: int | None = None
smtp_username: str | None = None
smtp_password: str | None = None
smtp_use_tls: bool | None = None
@router.get("")
async def list_email_bots(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
result = await session.exec(
select(EmailBot).where(EmailBot.user_id == user.id)
)
return [_response(b) for b in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_email_bot(
body: EmailBotCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
bot = EmailBot(user_id=user.id, **body.model_dump())
session.add(bot)
await session.commit()
await session.refresh(bot)
return _response(bot)
@router.get("/{bot_id}")
async def get_email_bot(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
bot = await _get_user_bot(session, bot_id, user.id)
return _response(bot)
@router.put("/{bot_id}")
async def update_email_bot(
bot_id: int,
body: EmailBotUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
bot = await _get_user_bot(session, bot_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(bot, field, value)
session.add(bot)
await session.commit()
await session.refresh(bot)
return _response(bot)
@router.delete("/{bot_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_email_bot(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
bot = await _get_user_bot(session, bot_id, user.id)
await session.delete(bot)
await session.commit()
@router.post("/{bot_id}/test")
async def test_email_bot(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test email to the bot's own address to verify SMTP connection."""
bot = await _get_user_bot(session, bot_id, user.id)
from notify_bridge_core.notifications.email.client import EmailClient, SmtpConfig
client = EmailClient(SmtpConfig(
host=bot.smtp_host,
port=bot.smtp_port,
username=bot.smtp_username,
password=bot.smtp_password,
from_address=bot.email,
from_name=bot.name,
use_tls=bot.smtp_use_tls,
))
result = await client.send(
to_email=bot.email,
subject="Notify Bridge — Test Connection",
body_text="This is a test email from Notify Bridge. Your SMTP settings are working correctly.",
)
return result
def _response(bot: EmailBot) -> dict:
return {
"id": bot.id,
"name": bot.name,
"icon": bot.icon,
"email": bot.email,
"smtp_host": bot.smtp_host,
"smtp_port": bot.smtp_port,
"smtp_username": bot.smtp_username,
"smtp_password": "***" if bot.smtp_password else "",
"smtp_use_tls": bot.smtp_use_tls,
"created_at": bot.created_at.isoformat(),
}
async def _get_user_bot(session: AsyncSession, bot_id: int, user_id: int) -> EmailBot:
bot = await session.get(EmailBot, bot_id)
if not bot or bot.user_id != user_id:
raise HTTPException(status_code=404, detail="Email bot not found")
return bot
@@ -16,6 +16,7 @@ from ..database.models import (
NotificationTrackerTarget,
ServiceProvider,
TemplateConfig,
TemplateSlot,
TrackingConfig,
User,
)
@@ -65,7 +66,7 @@ async def create_notification_tracker_target(
session: AsyncSession = Depends(get_session),
):
"""Link a target to a notification tracker with per-link configuration."""
await _get_user_tracker(session, tracker_id, user.id)
tracker = await _get_user_tracker(session, tracker_id, user.id)
# Validate target exists and belongs to user
target = await session.get(NotificationTarget, body.target_id)
@@ -85,15 +86,30 @@ async def create_notification_tracker_target(
detail="Target is already linked to this tracker",
)
# Validate config ownership
# Resolve tracker's provider type for config validation
provider = await session.get(ServiceProvider, tracker.provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# Validate config ownership + provider type match
if body.tracking_config_id:
tc = await session.get(TrackingConfig, body.tracking_config_id)
if not tc or tc.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracking config not found")
if tc.provider_type != provider.type:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Tracking config provider type '{tc.provider_type}' does not match tracker provider '{provider.type}'",
)
if body.template_config_id:
tpc = await session.get(TemplateConfig, body.template_config_id)
if not tpc or (tpc.user_id != user.id and tpc.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
if tpc.provider_type != provider.type:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Template config provider type '{tpc.provider_type}' does not match tracker provider '{provider.type}'",
)
tt = NotificationTrackerTarget(tracker_id=tracker_id, **body.model_dump())
session.add(tt)
@@ -111,21 +127,34 @@ async def update_notification_tracker_target(
session: AsyncSession = Depends(get_session),
):
"""Update a notification tracker-target link's configuration."""
await _get_user_tracker(session, tracker_id, user.id)
tracker = await _get_user_tracker(session, tracker_id, user.id)
tt = await session.get(NotificationTrackerTarget, tracker_target_id)
if not tt or tt.tracker_id != tracker_id:
raise HTTPException(status_code=404, detail="Tracker-target link not found")
provider = await session.get(ServiceProvider, tracker.provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
updates = body.model_dump(exclude_unset=True)
# Validate config ownership if being changed
# Validate config ownership + provider type match if being changed
if "tracking_config_id" in updates and updates["tracking_config_id"]:
tc = await session.get(TrackingConfig, updates["tracking_config_id"])
if not tc or tc.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracking config not found")
if tc.provider_type != provider.type:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Tracking config provider type '{tc.provider_type}' does not match tracker provider '{provider.type}'",
)
if "template_config_id" in updates and updates["template_config_id"]:
tpc = await session.get(TemplateConfig, updates["template_config_id"])
if not tpc or (tpc.user_id != user.id and tpc.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
if tpc.provider_type != provider.type:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Template config provider type '{tpc.provider_type}' does not match tracker provider '{provider.type}'",
)
for field, value in updates.items():
setattr(tt, field, value)
@@ -183,15 +212,24 @@ async def test_notification_tracker_target(
# For periodic/scheduled/memory — fetch real data from provider
template_config = None
template_str = ""
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
slot_map = {
"periodic": "periodic_summary_message",
"scheduled": "scheduled_assets_message",
"memory": "memory_mode_message",
}
template_str = getattr(template_config, slot_map[test_type], "") if template_config else ""
if template_config:
slot_map = {
"periodic": "periodic_summary_message",
"scheduled": "scheduled_assets_message",
"memory": "memory_mode_message",
}
slot_name = slot_map[test_type]
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == template_config.id,
TemplateSlot.slot_name == slot_name,
)
)
slot = slot_result.first()
template_str = slot.template if slot else ""
# Load provider and tracker data eagerly before aiohttp context
provider = await session.get(ServiceProvider, tracker.provider_id)
@@ -94,6 +94,40 @@ async def create_provider(
return _provider_response(provider)
@router.get("/capabilities")
async def list_provider_capabilities():
"""List capabilities for all registered provider types."""
from notify_bridge_core.providers.capabilities import get_all_capabilities
result = {}
for pt, caps in get_all_capabilities().items():
result[pt] = {
"provider_type": caps.provider_type,
"display_name": caps.display_name,
"notification_slots": caps.notification_slots,
"command_slots": caps.command_slots,
"events": caps.events,
"commands": caps.commands,
}
return result
@router.get("/capabilities/{provider_type}")
async def get_provider_capabilities(provider_type: str):
"""Get capabilities for a provider type (events, slots, commands)."""
from notify_bridge_core.providers.capabilities import get_capabilities
caps = get_capabilities(provider_type)
if not caps:
raise HTTPException(status_code=404, detail=f"Unknown provider type: {provider_type}")
return {
"provider_type": caps.provider_type,
"display_name": caps.display_name,
"notification_slots": caps.notification_slots,
"command_slots": caps.command_slots,
"events": caps.events,
"commands": caps.commands,
}
@router.get("/{provider_id}")
async def get_provider(
provider_id: int,
@@ -0,0 +1,147 @@
"""Target receiver management API routes (nested under targets)."""
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, TargetReceiver, User
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/targets/{target_id}/receivers", tags=["target-receivers"])
class ReceiverCreate(BaseModel):
name: str = ""
config: dict[str, Any] = {}
enabled: bool = True
class ReceiverUpdate(BaseModel):
name: str | None = None
config: dict[str, Any] | None = None
enabled: bool | None = None
def _receiver_key(target_type: str, config: dict[str, Any]) -> str:
"""Derive a unique key for deduplication from receiver config."""
if target_type == "telegram":
return str(config.get("chat_id", ""))
elif target_type == "webhook":
return config.get("url", "")
elif target_type == "email":
return config.get("email", "")
return ""
@router.get("")
async def list_receivers(
target_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await _get_user_target(session, target_id, user.id)
result = await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == target.id)
)
return [_response(r) for r in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_receiver(
target_id: int,
body: ReceiverCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
target = await _get_user_target(session, target_id, user.id)
key = _receiver_key(target.type, body.config)
if not key:
raise HTTPException(status_code=400, detail="Receiver config must include a delivery endpoint (chat_id, url, or email)")
# Check for duplicate
existing = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.receiver_key == key,
)
)
if existing.first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Receiver already exists for this target")
receiver = TargetReceiver(
target_id=target.id,
name=body.name,
config=body.config,
receiver_key=key,
enabled=body.enabled,
)
session.add(receiver)
await session.commit()
await session.refresh(receiver)
return _response(receiver)
@router.put("/{receiver_id}")
async def update_receiver(
target_id: int,
receiver_id: int,
body: ReceiverUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
await _get_user_target(session, target_id, user.id)
receiver = await session.get(TargetReceiver, receiver_id)
if not receiver or receiver.target_id != target_id:
raise HTTPException(status_code=404, detail="Receiver not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(receiver, field, value)
# Update receiver_key if config changed
if body.config is not None:
target = await session.get(NotificationTarget, target_id)
receiver.receiver_key = _receiver_key(target.type, receiver.config)
session.add(receiver)
await session.commit()
await session.refresh(receiver)
return _response(receiver)
@router.delete("/{receiver_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_receiver(
target_id: int,
receiver_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
await _get_user_target(session, target_id, user.id)
receiver = await session.get(TargetReceiver, receiver_id)
if not receiver or receiver.target_id != target_id:
raise HTTPException(status_code=404, detail="Receiver not found")
await session.delete(receiver)
await session.commit()
def _response(r: TargetReceiver) -> dict:
return {
"id": r.id,
"target_id": r.target_id,
"name": r.name,
"config": dict(r.config),
"receiver_key": r.receiver_key,
"enabled": r.enabled,
"created_at": r.created_at.isoformat(),
}
async def _get_user_target(session: AsyncSession, target_id: int, user_id: int) -> NotificationTarget:
target = await session.get(NotificationTarget, target_id)
if not target or target.user_id != user_id:
raise HTTPException(status_code=404, detail="Target not found")
return target
@@ -10,7 +10,7 @@ from typing import Any
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, NotificationTrackerTarget, TelegramBot, TelegramChat, User
from ..database.models import NotificationTarget, NotificationTrackerTarget, TargetReceiver, TelegramBot, TelegramChat, User
from ..services.notifier import send_test_notification
_LOGGER = logging.getLogger(__name__)
@@ -61,7 +61,15 @@ async def list_targets(
if chat:
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
return [_target_response(t, chat_names) for t in targets]
# Load receiver counts
receiver_counts: dict[int, int] = {}
for tgt in targets:
recv_result = await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == tgt.id)
)
receiver_counts[tgt.id] = len(recv_result.all())
return [_target_response(t, chat_names, receiver_counts.get(t.id, 0)) for t in targets]
@router.post("", status_code=status.HTTP_201_CREATED)
@@ -71,10 +79,10 @@ async def create_target(
session: AsyncSession = Depends(get_session),
):
"""Create a new notification target."""
if body.type not in ("telegram", "webhook"):
if body.type not in ("telegram", "webhook", "email"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Type must be 'telegram' or 'webhook'",
detail="Type must be 'telegram', 'webhook', or 'email'",
)
target = NotificationTarget(
user_id=user.id,
@@ -124,7 +132,7 @@ async def delete_target(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a notification target and its tracker links."""
"""Delete a notification target, its tracker links, and receivers."""
target = await _get_user_target(session, target_id, user.id)
# Delete associated tracker-target links
result = await session.exec(
@@ -132,6 +140,12 @@ async def delete_target(
)
for tt in result.all():
await session.delete(tt)
# Delete receivers
recv_result = await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == target_id)
)
for r in recv_result.all():
await session.delete(r)
await session.delete(target)
await session.commit()
@@ -149,7 +163,7 @@ async def test_target(
return result
def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None) -> dict:
def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None, receiver_count: int = 0) -> dict:
resp = {
"id": target.id,
"type": target.type,
@@ -157,6 +171,7 @@ def _target_response(target: NotificationTarget, chat_names: dict[str, str] | No
"icon": target.icon,
"config": _safe_config(target),
"chat_action": target.chat_action,
"receiver_count": receiver_count,
"created_at": target.created_at.isoformat(),
}
# Attach resolved chat name for telegram targets
@@ -1,6 +1,11 @@
"""Template configuration CRUD API routes."""
"""Template configuration CRUD API routes.
Template content is stored in TemplateSlot child rows (one per slot_name).
The API exposes slots as a flat dict in create/update/response payloads.
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
@@ -12,7 +17,7 @@ from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TemplateConfig, User
from ..database.models import TemplateConfig, TemplateSlot, User
from ..services.sample_context import _SAMPLE_CONTEXT
_LOGGER = logging.getLogger(__name__)
@@ -25,21 +30,83 @@ class TemplateConfigCreate(BaseModel):
name: str
description: str | None = None
icon: str | None = None
message_assets_added: str | None = None
message_assets_removed: str | None = None
message_collection_renamed: str | None = None
message_collection_deleted: str | None = None
message_sharing_changed: str | None = None
periodic_summary_message: str | None = None
scheduled_assets_message: str | None = None
memory_mode_message: str | None = None
date_format: str | None = None
date_only_format: str | None = None
slots: dict[str, str] = {} # slot_name -> template text
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
class TemplateConfigUpdate(BaseModel):
name: str | None = None
description: str | None = None
icon: str | None = None
date_format: str | None = None
date_only_format: str | None = None
slots: dict[str, str] | None = None # partial update: only provided slots change
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, str]:
"""Load all template slots for a config as a dict."""
result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == config_id)
)
return {s.slot_name: s.template for s in result.all()}
async def _save_slots(
session: AsyncSession, config_id: int, slots: dict[str, str]
) -> None:
"""Create or update template slots for a config."""
for slot_name, template_text in slots.items():
result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config_id,
TemplateSlot.slot_name == slot_name,
)
)
existing = result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config_id,
slot_name=slot_name,
template=template_text,
))
async def _response(session: AsyncSession, c: TemplateConfig) -> dict[str, Any]:
"""Build API response dict for a TemplateConfig, including its slots."""
slots = await _load_slots(session, c.id)
return {
"id": c.id,
"user_id": c.user_id,
"provider_type": c.provider_type,
"name": c.name,
"description": c.description,
"icon": c.icon,
"date_format": c.date_format,
"date_only_format": c.date_only_format,
"slots": slots,
"created_at": c.created_at.isoformat(),
}
async def _get(session: AsyncSession, config_id: int, user_id: int) -> TemplateConfig:
config = await session.get(TemplateConfig, config_id)
if not config or (config.user_id != user_id and config.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
return config
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("")
async def list_configs(
provider_type: str | None = None,
@@ -53,7 +120,7 @@ async def list_configs(
if provider_type:
query = query.where(TemplateConfig.provider_type == provider_type)
result = await session.exec(query)
return [_response(c) for c in result.all()]
return [await _response(session, c) for c in result.all()]
@router.get("/variables")
@@ -180,12 +247,22 @@ async def create_config(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
data = {k: v for k, v in body.model_dump().items() if v is not None}
config = TemplateConfig(user_id=user.id, **data)
config = TemplateConfig(
user_id=user.id,
provider_type=body.provider_type,
name=body.name,
description=body.description or "",
icon=body.icon or "",
date_format=body.date_format or "%d.%m.%Y, %H:%M UTC",
date_only_format=body.date_only_format or "%d.%m.%Y",
)
session.add(config)
await session.flush() # get config.id
if body.slots:
await _save_slots(session, config.id, body.slots)
await session.commit()
await session.refresh(config)
return _response(config)
return await _response(session, config)
@router.get("/{config_id}")
@@ -194,7 +271,8 @@ async def get_config(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
return _response(await _get(session, config_id, user.id))
config = await _get(session, config_id, user.id)
return await _response(session, config)
@router.put("/{config_id}")
@@ -205,13 +283,15 @@ async def update_config(
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items():
if value is not None:
setattr(config, field, value)
session.add(config)
if body.slots is not None:
await _save_slots(session, config.id, body.slots)
await session.commit()
await session.refresh(config)
return _response(config)
return await _response(session, config)
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -221,6 +301,12 @@ async def delete_config(
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
# Delete child slots first
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == config.id)
)
for slot in slot_result.all():
await session.delete(slot)
await session.delete(config)
await session.commit()
@@ -234,9 +320,10 @@ async def preview_config(
):
"""Render a specific template slot with sample data."""
config = await _get(session, config_id, user.id)
template_body = getattr(config, slot, None)
if template_body is None:
raise HTTPException(status_code=400, detail=f"Unknown slot: {slot}")
slots = await _load_slots(session, config.id)
template_body = slots.get(slot, "")
if not template_body:
raise HTTPException(status_code=400, detail=f"Slot '{slot}' has no template")
try:
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_body)
@@ -320,17 +407,3 @@ async def preview_raw(
return {"rendered": None, "error": str(e), "error_line": None, "error_type": "undefined"}
except Exception as e:
return {"rendered": None, "error": str(e), "error_line": None}
def _response(c: TemplateConfig) -> dict:
return {k: getattr(c, k) for k in TemplateConfig.model_fields if k not in ("user_id", "created_at")} | {
"user_id": c.user_id,
"created_at": c.created_at.isoformat(),
}
async def _get(session: AsyncSession, config_id: int, user_id: int) -> TemplateConfig:
config = await session.get(TemplateConfig, config_id)
if not config or (config.user_id != user_id and config.user_id != 0):
raise HTTPException(status_code=404, detail="Template config not found")
return config
@@ -17,6 +17,8 @@ from ..database.engine import get_engine
from ..services import make_immich_provider
from ..database.models import (
CommandConfig,
CommandTemplateConfig,
CommandTemplateSlot,
CommandTracker,
CommandTrackerListener,
EventLog,
@@ -51,6 +53,23 @@ def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int
return None
def _render_cmd_template(
templates: dict[str, str], slot_name: str, context: dict[str, Any]
) -> str | None:
"""Try to render a command template. Returns None if no template or error."""
template_str = templates.get(slot_name)
if not template_str:
return None
try:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_str)
return tmpl.render(**context)
except Exception as e:
_LOGGER.warning("Failed to render command template '%s': %s", slot_name, e)
return None
async def _resolve_command_context(
bot: TelegramBot,
) -> list[tuple[CommandTracker, CommandConfig, ServiceProvider]]:
@@ -87,7 +106,20 @@ async def _resolve_command_context(
continue
tuples.append((tracker, config, provider))
return tuples
# Load command template slots from the first config that has one
cmd_template_slots: dict[str, str] = {}
for _, config, _ in tuples:
if config.command_template_config_id:
slot_result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config.command_template_config_id
)
)
cmd_template_slots = {s.slot_name: s.template for s in slot_result.all()}
if cmd_template_slots:
break
return tuples, cmd_template_slots
def _merge_command_context(
@@ -125,10 +157,13 @@ async def handle_command(
if not cmd:
return None
ctx = await _resolve_command_context(bot)
enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx)
ctx_tuples, cmd_templates = await _resolve_command_context(bot)
enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples)
if cmd == "start":
result = _render_cmd_template(cmd_templates, "start", {"locale": locale, "bot_name": bot.name})
if result:
return result
msgs = {
"en": "Hi! I'm your Notify Bridge bot. Use /help to see available commands.",
"ru": "Привет! Я бот Notify Bridge. Используйте /help для списка команд.",
@@ -141,6 +176,9 @@ async def handle_command(
# Rate limit check
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
if wait is not None:
result = _render_cmd_template(cmd_templates, "rate_limited", {"wait": wait, "locale": locale})
if result:
return result
msgs = {
"en": f"Please wait {wait}s before using this command again.",
"ru": f"Подождите {wait} сек. перед повторным использованием.",
@@ -151,7 +189,7 @@ async def handle_command(
# Build providers map from command context
providers_map: dict[int, ServiceProvider] = {}
for _, _, provider in ctx:
for _, _, provider in ctx_tuples:
providers_map[provider.id] = provider
# Dispatch
@@ -105,6 +105,14 @@ async def migrate_schema(engine: AsyncEngine) -> None:
)
logger.info("Added update_mode column to telegram_bot table")
# Add command_template_config_id to command_config if missing
if await _has_table(conn, "command_config"):
if not await _has_column(conn, "command_config", "command_template_config_id"):
await conn.execute(
text("ALTER TABLE command_config ADD COLUMN command_template_config_id INTEGER")
)
logger.info("Added command_template_config_id column to command_config table")
# Add date_only_format to template_config if missing
if await _has_table(conn, "template_config"):
if not await _has_column(conn, "template_config", "date_only_format"):
@@ -537,3 +545,171 @@ async def migrate_entity_refactor(engine: AsyncEngine) -> None:
# or notification_tracker_target. SQLite doesn't support DROP COLUMN in
# all versions, and SQLModel will simply ignore columns not defined on
# the model class. The columns will remain in the DB but are unused.
# ---------------------------------------------------------------------------
# Template slot migration
# ---------------------------------------------------------------------------
# Old column names that existed on template_config before the slot refactor
_LEGACY_TEMPLATE_COLUMNS = [
"message_assets_added",
"message_assets_removed",
"message_collection_renamed",
"message_collection_deleted",
"message_sharing_changed",
"periodic_summary_message",
"scheduled_assets_message",
"memory_mode_message",
]
async def migrate_template_slots(engine: AsyncEngine) -> None:
"""Migrate legacy TemplateConfig column-based templates to TemplateSlot rows.
Reads the old per-column template values via raw SQL (since they're no longer
on the SQLModel class) and inserts them as TemplateSlot rows.
Idempotent: skips if template_slot table already has data or legacy columns
don't exist.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "template_config"):
return
# Check if the legacy columns still exist in the DB
has_legacy = await _has_column(conn, "template_config", "message_assets_added")
if not has_legacy:
logger.debug("No legacy template columns found — skipping slot migration")
return
# Check if template_slot table exists and already has data
if await _has_table(conn, "template_slot"):
slot_count = (await conn.execute(text("SELECT COUNT(*) FROM template_slot"))).scalar()
if slot_count and slot_count > 0:
logger.debug("template_slot table already has %d rows — skipping migration", slot_count)
return
# Create template_slot table if it doesn't exist yet
# (SQLModel.metadata.create_all may have already created it, but be safe)
if not await _has_table(conn, "template_slot"):
await conn.execute(text(
"CREATE TABLE template_slot ("
" id INTEGER PRIMARY KEY,"
" config_id INTEGER NOT NULL REFERENCES template_config(id),"
" slot_name TEXT NOT NULL,"
" template TEXT DEFAULT '',"
" UNIQUE(config_id, slot_name)"
")"
))
logger.info("Created template_slot table")
# Read all template configs with their legacy column values
col_list = ", ".join(_LEGACY_TEMPLATE_COLUMNS)
rows = (await conn.execute(
text(f"SELECT id, {col_list} FROM template_config")
)).fetchall()
migrated = 0
for row in rows:
config_id = row[0]
for i, col_name in enumerate(_LEGACY_TEMPLATE_COLUMNS):
template_text = row[i + 1] or ""
if template_text.strip():
await conn.execute(
text(
"INSERT INTO template_slot (config_id, slot_name, template) "
"VALUES (:cid, :sn, :tmpl)"
),
{"cid": config_id, "sn": col_name, "tmpl": template_text},
)
migrated += 1
if migrated:
logger.info("Migrated %d template slots from legacy columns", migrated)
# ---------------------------------------------------------------------------
# Target receiver migration
# ---------------------------------------------------------------------------
async def migrate_target_receivers(engine: AsyncEngine) -> None:
"""Migrate single chat_id/url from NotificationTarget.config to TargetReceiver rows.
For each existing target that has a chat_id or url in its config JSON and
no receivers yet, creates a TargetReceiver row.
Idempotent: skips targets that already have receivers.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "notification_target"):
return
# Create target_receiver table if it doesn't exist yet
if not await _has_table(conn, "target_receiver"):
await conn.execute(text(
"CREATE TABLE target_receiver ("
" id INTEGER PRIMARY KEY,"
" target_id INTEGER NOT NULL REFERENCES notification_target(id),"
" name TEXT DEFAULT '',"
" config TEXT DEFAULT '{}',"
" receiver_key TEXT DEFAULT '',"
" enabled INTEGER DEFAULT 1,"
" created_at TIMESTAMP,"
" UNIQUE(target_id, receiver_key)"
")"
))
logger.info("Created target_receiver table")
# Check if any receivers already exist
if await _has_table(conn, "target_receiver"):
recv_count = (await conn.execute(text("SELECT COUNT(*) FROM target_receiver"))).scalar()
if recv_count and recv_count > 0:
logger.debug("target_receiver already has %d rows — skipping migration", recv_count)
return
# Read all targets
targets = (await conn.execute(
text("SELECT id, type, config FROM notification_target")
)).fetchall()
migrated = 0
for row in targets:
target_id, target_type, raw_config = row[0], row[1], row[2]
try:
cfg = json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
except (json.JSONDecodeError, TypeError):
cfg = {}
receiver_key = ""
receiver_config = {}
receiver_name = ""
if target_type == "telegram":
chat_id = cfg.get("chat_id", "")
if chat_id:
receiver_key = str(chat_id)
receiver_config = {"chat_id": str(chat_id)}
receiver_name = f"Chat {chat_id}"
elif target_type == "webhook":
url = cfg.get("url", "")
if url:
receiver_key = url
receiver_config = {"url": url, "headers": cfg.get("headers", {})}
receiver_name = url[:50]
if receiver_key:
await conn.execute(
text(
"INSERT INTO target_receiver (target_id, name, config, receiver_key, enabled, created_at) "
"VALUES (:tid, :name, :cfg, :rk, 1, CURRENT_TIMESTAMP)"
),
{
"tid": target_id,
"name": receiver_name,
"cfg": json.dumps(receiver_config),
"rk": receiver_key,
},
)
migrated += 1
if migrated:
logger.info("Migrated %d target receivers from legacy config", migrated)
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import UniqueConstraint
from sqlalchemy import UniqueConstraint, Text
from sqlmodel import JSON, Column, Field, SQLModel
@@ -53,6 +53,24 @@ class TelegramBot(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
class EmailBot(SQLModel, table=True):
"""Email sender — SMTP connection for sending email notifications."""
__tablename__ = "email_bot"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
name: str
icon: str = Field(default="")
email: str # From address
smtp_host: str
smtp_port: int = Field(default=587)
smtp_username: str = Field(default="")
smtp_password: str = Field(default="")
smtp_use_tls: bool = Field(default=True)
created_at: datetime = Field(default_factory=_utcnow)
class TelegramChat(SQLModel, table=True):
__tablename__ = "telegram_chat"
@@ -124,7 +142,10 @@ class TrackingConfig(SQLModel, table=True):
class TemplateConfig(SQLModel, table=True):
"""Jinja2 message templates. Tied to a provider type."""
"""Jinja2 message templates. Tied to a provider type.
Template content is stored in TemplateSlot child rows (one per slot).
"""
__tablename__ = "template_config"
@@ -135,32 +156,41 @@ class TemplateConfig(SQLModel, table=True):
description: str = Field(default="")
icon: str = Field(default="")
# Event-driven notification templates
message_assets_added: str = Field(default="")
message_assets_removed: str = Field(default="")
message_collection_renamed: str = Field(default="")
message_collection_deleted: str = Field(default="")
message_sharing_changed: str = Field(default="")
# Scheduled notification templates
periodic_summary_message: str = Field(default="")
scheduled_assets_message: str = Field(default="")
memory_mode_message: str = Field(default="")
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
date_only_format: str = Field(default="%d.%m.%Y")
created_at: datetime = Field(default_factory=_utcnow)
class TemplateSlot(SQLModel, table=True):
"""One Jinja2 template for a specific slot within a TemplateConfig.
Slot names are provider-specific (e.g. 'message_assets_added' for Immich).
"""
__tablename__ = "template_slot"
__table_args__ = (
UniqueConstraint("config_id", "slot_name", name="uq_template_slot"),
)
id: int | None = Field(default=None, primary_key=True)
config_id: int = Field(foreign_key="template_config.id", index=True)
slot_name: str
template: str = Field(default="", sa_column=Column(Text, default=""))
class NotificationTarget(SQLModel, table=True):
"""Where to send notifications. Pure delivery endpoint."""
"""Where to send notifications. Pure delivery endpoint.
Target-level config holds connection/display settings (e.g. bot_token,
disable_url_preview). Actual delivery endpoints live in TargetReceiver rows.
"""
__tablename__ = "notification_target"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
type: str # "telegram" or "webhook"
type: str # "telegram", "webhook", or "email"
name: str
icon: str = Field(default="")
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
@@ -168,6 +198,28 @@ class NotificationTarget(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
class TargetReceiver(SQLModel, table=True):
"""One delivery endpoint within a NotificationTarget (broadcast support).
For Telegram: config = {"chat_id": "12345"}
For Webhook: config = {"url": "https://...", "headers": {...}}
For Email: config = {"email": "user@example.com", "name": "..."}
"""
__tablename__ = "target_receiver"
__table_args__ = (
UniqueConstraint("target_id", "receiver_key", name="uq_target_receiver"),
)
id: int | None = Field(default=None, primary_key=True)
target_id: int = Field(foreign_key="notification_target.id", index=True)
name: str = Field(default="")
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
receiver_key: str = Field(default="") # dedup key (e.g. chat_id, url, email)
enabled: bool = Field(default=True)
created_at: datetime = Field(default_factory=_utcnow)
class NotificationTracker(SQLModel, table=True):
"""Watches a provider's collections for changes."""
@@ -246,9 +298,43 @@ class CommandConfig(SQLModel, table=True):
response_mode: str = Field(default="media") # "media" or "text"
default_count: int = Field(default=5)
rate_limits: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
command_template_config_id: int | None = Field(
default=None, foreign_key="command_template_config.id"
)
created_at: datetime = Field(default_factory=_utcnow)
class CommandTemplateConfig(SQLModel, table=True):
"""Jinja2 templates for command responses. Provider-specific via slots."""
__tablename__ = "command_template_config"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(default=0) # 0 = system-owned
provider_type: str
name: str
description: str = Field(default="")
icon: str = Field(default="")
created_at: datetime = Field(default_factory=_utcnow)
class CommandTemplateSlot(SQLModel, table=True):
"""One Jinja2 template for a specific command response slot.
Slot names match command names (e.g. 'status', 'help', 'albums').
"""
__tablename__ = "command_template_slot"
__table_args__ = (
UniqueConstraint("config_id", "slot_name", name="uq_command_template_slot"),
)
id: int | None = Field(default=None, primary_key=True)
config_id: int = Field(foreign_key="command_template_config.id", index=True)
slot_name: str
template: str = Field(default="", sa_column=Column(Text, default=""))
class CommandTracker(SQLModel, table=True):
"""Links a provider to a command config for interactive bot commands."""
@@ -20,13 +20,16 @@ from .api.notification_tracker_targets import router as notification_tracker_tar
from .api.tracking_configs import router as tracking_configs_router
from .api.template_configs import router as template_configs_router
from .api.targets import router as targets_router
from .api.target_receivers import router as target_receivers_router
from .api.telegram_bots import router as telegram_bots_router
from .api.email_bots import router as email_bots_router
from .api.users import router as users_router
from .api.status import router as status_router
from .api.template_vars import router as template_vars_router
from .api.app_settings import router as app_settings_router
from .api.command_configs import router as command_configs_router
from .api.command_trackers import router as command_trackers_router
from .api.command_template_configs import router as command_template_configs_router
from .commands.webhook import router as webhook_router, set_webhook_secret
@@ -35,11 +38,13 @@ async def lifespan(app: FastAPI):
await init_db()
# Run data migrations (idempotent)
from .database.engine import get_engine
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers
engine = get_engine()
await migrate_schema(engine)
await migrate_tracker_targets(engine)
await migrate_entity_refactor(engine)
await migrate_template_slots(engine)
await migrate_target_receivers(engine)
await _seed_default_templates()
# Configure webhook secret from DB setting (falls back to env var)
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
@@ -63,12 +68,15 @@ app.include_router(notification_tracker_targets_router)
app.include_router(tracking_configs_router)
app.include_router(template_configs_router)
app.include_router(targets_router)
app.include_router(target_receivers_router)
app.include_router(telegram_bots_router)
app.include_router(email_bots_router)
app.include_router(users_router)
app.include_router(status_router)
app.include_router(app_settings_router)
app.include_router(command_configs_router)
app.include_router(command_trackers_router)
app.include_router(command_template_configs_router)
app.include_router(webhook_router)
@@ -78,11 +86,14 @@ async def health():
async def _seed_default_templates():
"""Seed or update default (system-owned) templates on startup."""
"""Seed or update default (system-owned) templates on startup.
Uses TemplateSlot child rows for template content.
"""
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
from .database.models import TemplateConfig
from .database.models import TemplateConfig, TemplateSlot
from notify_bridge_core.templates.defaults import load_default_templates
engine = get_engine()
@@ -102,9 +113,15 @@ async def _seed_default_templates():
provider_type="immich",
name=name,
description=f"Default Immich templates ({locale.upper()})",
**slots,
)
session.add(config)
await session.flush() # get config.id
for slot_name, template_text in slots.items():
session.add(TemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
else:
# Update existing system-owned templates from files
result = await session.exec(
@@ -116,9 +133,24 @@ async def _seed_default_templates():
slots = load_default_templates(locale)
if not slots:
continue
for key, value in slots.items():
setattr(config, key, value)
session.add(config)
for slot_name, template_text in slots.items():
# Upsert: find existing slot or create new
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
await session.commit()
@@ -5,7 +5,11 @@ from typing import Any
import aiohttp
from ..database.models import NotificationTarget
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import NotificationTarget, TargetReceiver
_LOGGER = logging.getLogger(__name__)
@@ -26,52 +30,162 @@ def _get_test_message(locale: str, target_type: str) -> str:
return msgs.get(target_type, msgs.get("webhook", "Test"))
async def _load_receivers(target_id: int) -> list[dict]:
"""Load enabled receivers for a target from DB."""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target_id,
TargetReceiver.enabled == True,
)
)
return [dict(r.config) for r in result.all()]
async def send_to_target(target: NotificationTarget, message: str) -> dict:
"""Send a message to a target, respecting all target config settings.
"""Send a message to a target, broadcasting to all receivers.
This is the SINGLE send path used by dispatch, test, and real-data notifications.
"""
try:
receivers = await _load_receivers(target.id)
if target.type == "telegram":
return await _send_telegram(target, message)
return await _send_telegram_broadcast(target, message, receivers)
elif target.type == "webhook":
return await _send_webhook(target, message)
return await _send_webhook_broadcast(target, message, receivers)
elif target.type == "email":
return await _send_email_broadcast(target, message, receivers)
return {"success": False, "error": f"Unknown target type: {target.type}"}
except Exception as e:
_LOGGER.error("Send failed: %s", e)
return {"success": False, "error": str(e)}
async def _send_telegram(target: NotificationTarget, message: str) -> dict:
async def _send_telegram_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
from notify_bridge_core.notifications.telegram.client import TelegramClient
bot_token = target.config.get("bot_token")
chat_id = target.config.get("chat_id")
disable_preview = target.config.get("disable_url_preview", False)
if not bot_token or not chat_id:
return {"success": False, "error": "Missing bot_token or chat_id"}
if not bot_token:
return {"success": False, "error": "Missing bot_token"}
# Fall back to legacy chat_id if no receivers
if not receivers:
chat_id = target.config.get("chat_id")
if chat_id:
receivers = [{"chat_id": str(chat_id)}]
else:
return {"success": False, "error": "No receivers configured"}
results: list[dict] = []
async with aiohttp.ClientSession() as session:
client = TelegramClient(session, bot_token)
return await client.send_message(
chat_id=str(chat_id),
text=message,
disable_web_page_preview=bool(disable_preview),
)
for recv in receivers:
chat_id = recv.get("chat_id")
if not chat_id:
continue
result = await client.send_message(
chat_id=str(chat_id),
text=message,
disable_web_page_preview=bool(disable_preview),
)
results.append(result)
successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and results:
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No valid receivers"}
async def _send_webhook(target: NotificationTarget, message: str, event_type: str = "notification") -> dict:
async def _send_webhook_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
from notify_bridge_core.notifications.webhook.client import WebhookClient
url = target.config.get("url")
headers = target.config.get("headers", {})
if not url:
return {"success": False, "error": "Missing url in target config"}
# Fall back to legacy url if no receivers
if not receivers:
url = target.config.get("url")
headers = target.config.get("headers", {})
if url:
receivers = [{"url": url, "headers": headers}]
else:
return {"success": False, "error": "No receivers configured"}
results: list[dict] = []
async with aiohttp.ClientSession() as session:
client = WebhookClient(session, url, headers)
return await client.send({"message": message, "event_type": event_type})
for recv in receivers:
url = recv.get("url")
headers = recv.get("headers", {})
if not url:
continue
client = WebhookClient(session, url, headers)
results.append(await client.send({"message": message, "event_type": "notification"}))
successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and results:
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No valid receivers"}
async def _send_email_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
from notify_bridge_core.notifications.email.client import EmailClient, SmtpConfig
from ..database.models import EmailBot
email_bot_id = target.config.get("email_bot_id")
if not email_bot_id:
return {"success": False, "error": "No email bot configured for this target"}
engine = get_engine()
async with AsyncSession(engine) as session:
email_bot = await session.get(EmailBot, email_bot_id)
if not email_bot:
return {"success": False, "error": "Email bot not found"}
smtp_cfg = SmtpConfig(
host=email_bot.smtp_host,
port=email_bot.smtp_port,
username=email_bot.smtp_username,
password=email_bot.smtp_password,
from_address=email_bot.email,
from_name=email_bot.name,
use_tls=email_bot.smtp_use_tls,
)
if not smtp_cfg.host or not smtp_cfg.from_address:
return {"success": False, "error": "Email bot SMTP not configured"}
if not receivers:
return {"success": False, "error": "No email receivers configured"}
client = EmailClient(smtp_cfg)
results: list[dict] = []
for recv in receivers:
email = recv.get("email")
if not email:
continue
result = await client.send(
to_email=email,
subject="Notification from Notify Bridge",
body_text=message,
to_name=recv.get("name", ""),
)
results.append(result)
successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and results:
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No valid email receivers"}
# --- Public API used by routes ---
@@ -17,13 +17,16 @@ from notify_bridge_core.storage import JsonFileBackend
from ..database.engine import get_engine
from ..database.models import (
EmailBot,
EventLog,
NotificationTarget,
NotificationTracker,
NotificationTrackerState,
NotificationTrackerTarget,
ServiceProvider,
TargetReceiver,
TemplateConfig,
TemplateSlot,
TrackingConfig,
)
@@ -129,19 +132,59 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
if not target:
continue
# Load receivers for this target
recv_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.enabled == True,
)
)
receivers = [dict(r.config) for r in recv_result.all()]
tracking_config = None
if tt.tracking_config_id:
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
template_config = None
template_slots: dict[str, str] | None = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
if template_config:
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == template_config.id)
)
raw_slots = {s.slot_name: s.template for s in slot_result.all()}
# Map slot names to event_type values for dispatcher lookup
template_slots = {}
for slot_name, tmpl_text in raw_slots.items():
# Strip "message_" prefix for event-type slots
event_key = slot_name.removeprefix("message_") if slot_name.startswith("message_") else slot_name
template_slots[event_key] = tmpl_text
target_config = dict(target.config)
# Inject SMTP config for email targets from EmailBot
if target.type == "email":
email_bot_id = target.config.get("email_bot_id")
if email_bot_id:
email_bot = await session.get(EmailBot, email_bot_id)
if email_bot:
target_config["smtp"] = {
"host": email_bot.smtp_host,
"port": email_bot.smtp_port,
"username": email_bot.smtp_username,
"password": email_bot.smtp_password,
"from_address": email_bot.email,
"from_name": email_bot.name,
"use_tls": email_bot.smtp_use_tls,
}
link_data.append({
"target_type": target.type,
"target_config": dict(target.config),
"target_config": target_config,
"receivers": receivers,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
})
# Snapshot the data we need
@@ -249,26 +292,17 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
_LOGGER.info(" Skipped by tracking config filter")
continue
# Build template slots from template config
tmpl = ld["template_config"]
slots = None
if tmpl:
slots = {
"assets_added": tmpl.message_assets_added,
"assets_removed": tmpl.message_assets_removed,
"collection_renamed": tmpl.message_collection_renamed,
"collection_deleted": tmpl.message_collection_deleted,
"sharing_changed": tmpl.message_sharing_changed,
}
target_configs.append(TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=slots,
template_slots=ld["template_slots"],
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
provider_api_key=provider_config.get("api_key"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("external_domain", ""),
receivers=ld["receivers"],
))
if target_configs: