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:
@@ -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...",
|
||||
|
||||
@@ -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": "Загрузка...",
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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]}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user