feat: default tracker configs, email validation, expandable target links
- Tracker now has default_tracking_config_id and default_template_config_id that apply to all linked targets unless overridden per-target - Dispatch falls back to tracker defaults when per-link configs are null - Email bot creation validates SMTP connection before saving - Email notifications sent as HTML (links render properly) - Linked target items are expandable: collapsed shows config CrossLinks, expanded shows config selectors; action buttons always visible - Fix email bot test button icon (mdiEmailSend → mdiSend) - Fix target type icons in LinkedTargetsSection for all types - Provider filter moved above search in sidebar
This commit is contained in:
@@ -207,6 +207,8 @@
|
|||||||
"quietHoursStart": "Quiet hours start",
|
"quietHoursStart": "Quiet hours start",
|
||||||
"quietHoursEnd": "Quiet hours end",
|
"quietHoursEnd": "Quiet hours end",
|
||||||
"batchDuration": "Batch duration (seconds)",
|
"batchDuration": "Batch duration (seconds)",
|
||||||
|
"defaultTrackingConfig": "Default tracking config",
|
||||||
|
"defaultTemplateConfig": "Default template config",
|
||||||
"linkedTargets": "targets",
|
"linkedTargets": "targets",
|
||||||
"noLinkedTargets": "No targets linked. Add a target below.",
|
"noLinkedTargets": "No targets linked. Add a target below.",
|
||||||
"addTarget": "Add target",
|
"addTarget": "Add target",
|
||||||
@@ -540,7 +542,8 @@
|
|||||||
"albumFields": "Album fields (in {% for album in albums %})",
|
"albumFields": "Album fields (in {% for album in albums %})",
|
||||||
"confirmDelete": "Delete this template config?",
|
"confirmDelete": "Delete this template config?",
|
||||||
"invalidFormat": "Invalid format string",
|
"invalidFormat": "Invalid format string",
|
||||||
"filterSlots": "Filter slots..."
|
"filterSlots": "Filter slots...",
|
||||||
|
"slots": "slots"
|
||||||
},
|
},
|
||||||
"templateVars": {
|
"templateVars": {
|
||||||
"message_assets_added": {
|
"message_assets_added": {
|
||||||
@@ -645,6 +648,8 @@
|
|||||||
"templateConfig": "Controls the message format. Uses default templates if not set.",
|
"templateConfig": "Controls the message format. Uses default templates if not set.",
|
||||||
"scanInterval": "How often to poll the provider for changes, in seconds. Lower = faster detection but more API calls.",
|
"scanInterval": "How often to poll the provider for changes, in seconds. Lower = faster detection but more API calls.",
|
||||||
"batchDuration": "Time to accumulate changes before dispatching notifications. 0 = send immediately.",
|
"batchDuration": "Time to accumulate changes before dispatching notifications. 0 = send immediately.",
|
||||||
|
"defaultTrackingConfig": "Applied to all linked targets unless overridden per target.",
|
||||||
|
"defaultTemplateConfig": "Applied to all linked targets unless overridden per target.",
|
||||||
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
|
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
|
||||||
"responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.",
|
"responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.",
|
||||||
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
|
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
|
||||||
|
|||||||
@@ -207,6 +207,8 @@
|
|||||||
"quietHoursStart": "Тихие часы начало",
|
"quietHoursStart": "Тихие часы начало",
|
||||||
"quietHoursEnd": "Тихие часы конец",
|
"quietHoursEnd": "Тихие часы конец",
|
||||||
"batchDuration": "Длительность пакета (секунды)",
|
"batchDuration": "Длительность пакета (секунды)",
|
||||||
|
"defaultTrackingConfig": "Конфигурация отслеживания по умолчанию",
|
||||||
|
"defaultTemplateConfig": "Шаблон уведомлений по умолчанию",
|
||||||
"linkedTargets": "получатели",
|
"linkedTargets": "получатели",
|
||||||
"noLinkedTargets": "Нет привязанных получателей. Добавьте получателя ниже.",
|
"noLinkedTargets": "Нет привязанных получателей. Добавьте получателя ниже.",
|
||||||
"addTarget": "Добавить получателя",
|
"addTarget": "Добавить получателя",
|
||||||
@@ -540,7 +542,8 @@
|
|||||||
"albumFields": "Поля альбома (в {% for album in albums %})",
|
"albumFields": "Поля альбома (в {% for album in albums %})",
|
||||||
"confirmDelete": "Удалить эту конфигурацию шаблона?",
|
"confirmDelete": "Удалить эту конфигурацию шаблона?",
|
||||||
"invalidFormat": "Некорректная строка формата",
|
"invalidFormat": "Некорректная строка формата",
|
||||||
"filterSlots": "Фильтр слотов..."
|
"filterSlots": "Фильтр слотов...",
|
||||||
|
"slots": "слотов"
|
||||||
},
|
},
|
||||||
"templateVars": {
|
"templateVars": {
|
||||||
"message_assets_added": {
|
"message_assets_added": {
|
||||||
@@ -645,6 +648,8 @@
|
|||||||
"templateConfig": "Управляет форматом сообщений. Используются шаблоны по умолчанию, если не задано.",
|
"templateConfig": "Управляет форматом сообщений. Используются шаблоны по умолчанию, если не задано.",
|
||||||
"scanInterval": "Как часто опрашивать провайдер на предмет изменений (в секундах). Меньше = быстрее обнаружение, но больше запросов к API.",
|
"scanInterval": "Как часто опрашивать провайдер на предмет изменений (в секундах). Меньше = быстрее обнаружение, но больше запросов к API.",
|
||||||
"batchDuration": "Время накопления изменений перед отправкой уведомлений. 0 = отправлять сразу.",
|
"batchDuration": "Время накопления изменений перед отправкой уведомлений. 0 = отправлять сразу.",
|
||||||
|
"defaultTrackingConfig": "Применяется ко всем привязанным получателям, если не переопределено.",
|
||||||
|
"defaultTemplateConfig": "Применяется ко всем привязанным получателям, если не переопределено.",
|
||||||
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
|
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
|
||||||
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
|
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
|
||||||
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export interface Tracker {
|
|||||||
collection_ids: string[];
|
collection_ids: string[];
|
||||||
scan_interval: number;
|
scan_interval: number;
|
||||||
batch_duration: number;
|
batch_duration: number;
|
||||||
|
default_tracking_config_id: number | null;
|
||||||
|
default_template_config_id: number | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
tracker_targets: TrackerTarget[];
|
tracker_targets: TrackerTarget[];
|
||||||
|
|||||||
@@ -35,10 +35,12 @@
|
|||||||
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
|
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
|
||||||
]);
|
]);
|
||||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
||||||
|
let _syncingFilter = false;
|
||||||
|
|
||||||
// Sync filter value → store
|
// Sync filter value → store
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const v = providerFilterValue;
|
const v = providerFilterValue;
|
||||||
|
if (_syncingFilter) return;
|
||||||
globalProviderFilter.set(v === 0 ? null : v);
|
globalProviderFilter.set(v === 0 ? null : v);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +48,9 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const storeId = globalProviderFilter.id;
|
const storeId = globalProviderFilter.id;
|
||||||
if (storeId === null && providerFilterValue !== 0) {
|
if (storeId === null && providerFilterValue !== 0) {
|
||||||
|
_syncingFilter = true;
|
||||||
providerFilterValue = 0;
|
providerFilterValue = 0;
|
||||||
|
_syncingFilter = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,6 +89,11 @@
|
|||||||
ptype ? items.filter(i => i.provider_type === ptype) : items;
|
ptype ? items.filter(i => i.provider_type === ptype) : items;
|
||||||
|
|
||||||
const targets = targetsCache.items;
|
const targets = targetsCache.items;
|
||||||
|
// Single pass to count targets by type
|
||||||
|
const targetsByType = new Map<string, number>();
|
||||||
|
for (const t of targets) {
|
||||||
|
targetsByType.set(t.type, (targetsByType.get(t.type) || 0) + 1);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
providers: pid ? 1 : providersCache.items.length,
|
providers: pid ? 1 : providersCache.items.length,
|
||||||
notification_trackers: filterById(notificationTrackersCache.items as any[]).length,
|
notification_trackers: filterById(notificationTrackersCache.items as any[]).length,
|
||||||
@@ -97,14 +106,14 @@
|
|||||||
telegram_bots: telegramBotsCache.items.length,
|
telegram_bots: telegramBotsCache.items.length,
|
||||||
email_bots: emailBotsCache.items.length,
|
email_bots: emailBotsCache.items.length,
|
||||||
matrix_bots: matrixBotsCache.items.length,
|
matrix_bots: matrixBotsCache.items.length,
|
||||||
targets_telegram: targets.filter(t => t.type === 'telegram').length,
|
targets_telegram: targetsByType.get('telegram') || 0,
|
||||||
targets_webhook: targets.filter(t => t.type === 'webhook').length,
|
targets_webhook: targetsByType.get('webhook') || 0,
|
||||||
targets_email: targets.filter(t => t.type === 'email').length,
|
targets_email: targetsByType.get('email') || 0,
|
||||||
targets_discord: targets.filter(t => t.type === 'discord').length,
|
targets_discord: targetsByType.get('discord') || 0,
|
||||||
targets_slack: targets.filter(t => t.type === 'slack').length,
|
targets_slack: targetsByType.get('slack') || 0,
|
||||||
targets_ntfy: targets.filter(t => t.type === 'ntfy').length,
|
targets_ntfy: targetsByType.get('ntfy') || 0,
|
||||||
targets_matrix: targets.filter(t => t.type === 'matrix').length,
|
targets_matrix: targetsByType.get('matrix') || 0,
|
||||||
targets_broadcast: targets.filter(t => t.type === 'broadcast').length,
|
targets_broadcast: targetsByType.get('broadcast') || 0,
|
||||||
} as Record<string, number>;
|
} as Record<string, number>;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<IconButton icon="mdiEmailSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
|
<IconButton icon="mdiSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
|
||||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
|
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
|
||||||
scan_interval: 60, batch_duration: 0,
|
scan_interval: 60, batch_duration: 0,
|
||||||
|
default_tracking_config_id: 0, default_template_config_id: 0,
|
||||||
filters: {} as Record<string, any>,
|
filters: {} as Record<string, any>,
|
||||||
});
|
});
|
||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
@@ -143,6 +144,8 @@
|
|||||||
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
||||||
collection_ids: [...(trk.collection_ids || [])],
|
collection_ids: [...(trk.collection_ids || [])],
|
||||||
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
|
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
|
||||||
|
default_tracking_config_id: (trk as any).default_tracking_config_id || 0,
|
||||||
|
default_template_config_id: (trk as any).default_template_config_id || 0,
|
||||||
filters: trk.filters || {},
|
filters: trk.filters || {},
|
||||||
};
|
};
|
||||||
previousCollectionIds = [...(trk.collection_ids || [])];
|
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||||
@@ -179,11 +182,16 @@
|
|||||||
async function doSave() {
|
async function doSave() {
|
||||||
submitting = true;
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
default_tracking_config_id: form.default_tracking_config_id || null,
|
||||||
|
default_template_config_id: form.default_template_config_id || null,
|
||||||
|
};
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
snackSuccess(t('snack.trackerUpdated'));
|
snackSuccess(t('snack.trackerUpdated'));
|
||||||
} else {
|
} else {
|
||||||
await api('/notification-trackers', { method: 'POST', body: JSON.stringify(form) });
|
await api('/notification-trackers', { method: 'POST', body: JSON.stringify(payload) });
|
||||||
snackSuccess(t('snack.trackerCreated'));
|
snackSuccess(t('snack.trackerCreated'));
|
||||||
}
|
}
|
||||||
showForm = false; editing = null; linkWarning = null; await load();
|
showForm = false; editing = null; linkWarning = null; await load();
|
||||||
@@ -371,6 +379,8 @@
|
|||||||
{providerItems}
|
{providerItems}
|
||||||
{collections}
|
{collections}
|
||||||
bind:collectionFilter
|
bind:collectionFilter
|
||||||
|
trackingConfigItems={trackingConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiCog' }))}
|
||||||
|
templateConfigItems={templateConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiFileDocumentEdit' }))}
|
||||||
{editing}
|
{editing}
|
||||||
{submitting}
|
{submitting}
|
||||||
{linkCheckLoading}
|
{linkCheckLoading}
|
||||||
@@ -406,7 +416,7 @@
|
|||||||
</Card>
|
</Card>
|
||||||
{:else if !showForm}
|
{:else if !showForm}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="space-y-3 stagger-children">
|
||||||
{#each notificationTrackers as tracker}
|
{#each notificationTrackers as tracker (tracker.id)}
|
||||||
<Card hover entityId={tracker.id}>
|
<Card hover entityId={tracker.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,10 +3,16 @@
|
|||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
|
import CrossLink from '$lib/components/CrossLink.svelte';
|
||||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||||
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
|
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
|
||||||
import type { Tracker, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
|
import type { Tracker, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
|
||||||
|
|
||||||
|
const TARGET_TYPE_ICONS: Record<string, string> = {
|
||||||
|
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
|
||||||
|
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix', broadcast: 'mdiBullhorn',
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tracker: Tracker;
|
tracker: Tracker;
|
||||||
trackingConfigs: TrackingConfig[];
|
trackingConfigs: TrackingConfig[];
|
||||||
@@ -47,6 +53,8 @@
|
|||||||
onchangeNewTemplateConfig,
|
onchangeNewTemplateConfig,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let expandedTt = $state<number | null>(null);
|
||||||
|
|
||||||
function toItems(configs: any[]): EntityItem[] {
|
function toItems(configs: any[]): EntityItem[] {
|
||||||
return configsForTracker(configs).map(c => ({
|
return configsForTracker(configs).map(c => ({
|
||||||
value: c.id,
|
value: c.id,
|
||||||
@@ -55,55 +63,86 @@
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configName(configs: any[], id: number | null): string {
|
||||||
|
if (!id) return '';
|
||||||
|
const c = configs.find((x: any) => x.id === id);
|
||||||
|
return c?.name || '';
|
||||||
|
}
|
||||||
|
|
||||||
const trackingConfigItems = $derived(toItems(trackingConfigs));
|
const trackingConfigItems = $derived(toItems(trackingConfigs));
|
||||||
const templateConfigItems = $derived(toItems(templateConfigs));
|
const templateConfigItems = $derived(toItems(templateConfigs));
|
||||||
const linkedTargetIds = $derived(new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id)));
|
const linkedTargetIds = $derived(new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id)));
|
||||||
const targetItems = $derived<EntityItem[]>(unlinkedTargets.map(tgt => ({
|
const targetItems = $derived<EntityItem[]>(unlinkedTargets.map(tgt => ({
|
||||||
value: tgt.id,
|
value: tgt.id,
|
||||||
label: tgt.name,
|
label: tgt.name,
|
||||||
icon: tgt.icon || (tgt.type === 'telegram' ? 'mdiSend' : 'mdiWebhook'),
|
icon: tgt.icon || TARGET_TYPE_ICONS[tgt.type] || 'mdiTarget',
|
||||||
desc: tgt.type,
|
desc: tgt.type,
|
||||||
disabled: linkedTargetIds.has(tgt.id),
|
disabled: linkedTargetIds.has(tgt.id),
|
||||||
disabledHint: linkedTargetIds.has(tgt.id) ? t('notificationTracker.alreadyLinked') : undefined,
|
disabledHint: linkedTargetIds.has(tgt.id) ? t('notificationTracker.alreadyLinked') : undefined,
|
||||||
})));
|
})));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
|
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-1" in:slide>
|
||||||
{#if (tracker.tracker_targets || []).length === 0}
|
{#if (tracker.tracker_targets || []).length === 0}
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('notificationTracker.noLinkedTargets')}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)]">{t('notificationTracker.noLinkedTargets')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each tracker.tracker_targets as tt}
|
{#each tracker.tracker_targets as tt (tt.id)}
|
||||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
{@const isExpanded = expandedTt === tt.id}
|
||||||
<div class="flex items-center gap-2">
|
<div class="rounded-md bg-[var(--color-muted)]/30 overflow-hidden">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || (tt.target_type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={16} /></span>
|
<!-- Header row — always visible -->
|
||||||
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
|
<div class="flex items-center justify-between text-sm px-2.5 py-1.5">
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
{#if !tt.enabled}
|
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || TARGET_TYPE_ICONS[tt.target_type ?? ''] || 'mdiTarget'} size={16} /></span>
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
|
<button type="button" class="flex items-center gap-1 hover:text-[var(--color-primary)] transition-colors cursor-pointer"
|
||||||
{/if}
|
onclick={() => expandedTt = isExpanded ? null : tt.id}>
|
||||||
</div>
|
<span class="font-medium truncate">{tt.target_name || `Target #${tt.target_id}`}</span>
|
||||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
<MdiIcon name={isExpanded ? 'mdiChevronUp' : 'mdiChevronDown'} size={14} />
|
||||||
<div class="min-w-[140px]">
|
</button>
|
||||||
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
|
||||||
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
{#if !tt.enabled}
|
||||||
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
|
||||||
|
{/if}
|
||||||
|
<!-- Show overridden config badges when collapsed -->
|
||||||
|
{#if !isExpanded}
|
||||||
|
{#if tt.tracking_config_id}
|
||||||
|
<CrossLink href="/tracking-configs" icon="mdiCog" label={configName(trackingConfigs, tt.tracking_config_id)} entityId={tt.tracking_config_id} />
|
||||||
|
{/if}
|
||||||
|
{#if tt.template_config_id}
|
||||||
|
<CrossLink href="/template-configs" icon="mdiFileDocumentEdit" label={configName(templateConfigs, tt.template_config_id)} entityId={tt.template_config_id} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-[140px]">
|
<div class="flex items-center gap-1">
|
||||||
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
|
|
||||||
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
|
||||||
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
|
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
|
||||||
onclick={(e: MouseEvent) => onopenTestMenu(tt.id, e)}
|
onclick={(e: MouseEvent) => onopenTestMenu(tt.id, e)}
|
||||||
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
|
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
|
||||||
|
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
||||||
|
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||||
|
onclick={() => onupdateLink(tt, 'enabled', !tt.enabled)} />
|
||||||
|
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
|
||||||
|
onclick={() => onremoveLink(tt.id)} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
|
||||||
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
|
||||||
onclick={() => onupdateLink(tt, 'enabled', !tt.enabled)} />
|
|
||||||
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
|
|
||||||
onclick={() => onremoveLink(tt.id)} variant="danger" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded config selectors -->
|
||||||
|
{#if isExpanded}
|
||||||
|
<div class="px-2.5 pb-2.5" in:slide={{ duration: 150 }}>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('trackingConfig.title')}</label>
|
||||||
|
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
|
||||||
|
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||||
|
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('templateConfig.title')}</label>
|
||||||
|
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
|
||||||
|
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||||
|
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -116,16 +155,6 @@
|
|||||||
placeholder={t('notificationTracker.addTarget')} size="sm"
|
placeholder={t('notificationTracker.addTarget')} size="sm"
|
||||||
onselect={(v) => onchangeNewTarget(Number(v) || 0)} />
|
onselect={(v) => onchangeNewTarget(Number(v) || 0)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-[140px]">
|
|
||||||
<EntitySelect items={trackingConfigItems} value={newLinkTrackingConfigId || null}
|
|
||||||
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
|
||||||
onselect={(v) => onchangeNewTrackingConfig(Number(v) || 0)} />
|
|
||||||
</div>
|
|
||||||
<div class="min-w-[140px]">
|
|
||||||
<EntitySelect items={templateConfigItems} value={newLinkTemplateConfigId || null}
|
|
||||||
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
|
||||||
onselect={(v) => onchangeNewTemplateConfig(Number(v) || 0)} />
|
|
||||||
</div>
|
|
||||||
<button onclick={onaddLink}
|
<button onclick={onaddLink}
|
||||||
disabled={!newLinkTargetId || addingTarget}
|
disabled={!newLinkTargetId || addingTarget}
|
||||||
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90 disabled:opacity-50">
|
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90 disabled:opacity-50">
|
||||||
|
|||||||
@@ -16,11 +16,15 @@
|
|||||||
collection_ids: string[];
|
collection_ids: string[];
|
||||||
scan_interval: number;
|
scan_interval: number;
|
||||||
batch_duration: number;
|
batch_duration: number;
|
||||||
|
default_tracking_config_id: number;
|
||||||
|
default_template_config_id: number;
|
||||||
filters: Record<string, any>;
|
filters: Record<string, any>;
|
||||||
};
|
};
|
||||||
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
||||||
collections: any[];
|
collections: any[];
|
||||||
collectionFilter?: string;
|
collectionFilter?: string;
|
||||||
|
trackingConfigItems?: { value: number; label: string; icon: string }[];
|
||||||
|
templateConfigItems?: { value: number; label: string; icon: string }[];
|
||||||
editing: number | null;
|
editing: number | null;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
linkCheckLoading: boolean;
|
linkCheckLoading: boolean;
|
||||||
@@ -36,6 +40,8 @@
|
|||||||
providerItems,
|
providerItems,
|
||||||
collections,
|
collections,
|
||||||
collectionFilter = $bindable(),
|
collectionFilter = $bindable(),
|
||||||
|
trackingConfigItems = [],
|
||||||
|
templateConfigItems = [],
|
||||||
editing,
|
editing,
|
||||||
submitting,
|
submitting,
|
||||||
linkCheckLoading,
|
linkCheckLoading,
|
||||||
@@ -175,6 +181,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Default configs -->
|
||||||
|
{#if trackingConfigItems.length > 0 || templateConfigItems.length > 0}
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
{#if trackingConfigItems.length > 0}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">{t('notificationTracker.defaultTrackingConfig')}<Hint text={t('hints.defaultTrackingConfig')} /></label>
|
||||||
|
<EntitySelect items={[{value: 0, label: t('common.none'), icon: 'mdiMinus'}, ...trackingConfigItems]} bind:value={form.default_tracking_config_id} placeholder={t('common.none')} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if templateConfigItems.length > 0}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">{t('notificationTracker.defaultTemplateConfig')}<Hint text={t('hints.defaultTemplateConfig')} /></label>
|
||||||
|
<EntitySelect items={[{value: 0, label: t('common.none'), icon: 'mdiMinus'}, ...templateConfigItems]} bind:value={form.default_template_config_id} placeholder={t('common.none')} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||||
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
|
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -475,7 +475,7 @@
|
|||||||
{#if target.type === 'broadcast' && target.child_targets?.length}
|
{#if target.type === 'broadcast' && target.child_targets?.length}
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.child_targets.length} {t('targets.childTargets')}</span>
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.child_targets.length} {t('targets.childTargets')}</span>
|
||||||
{:else if target.type !== 'broadcast' && (target.receivers || []).length > 0}
|
{:else if target.type !== 'broadcast' && (target.receivers || []).length > 0}
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} receiver(s)</span>
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} {t('targets.receivers')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
|
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ class NotificationDispatcher:
|
|||||||
to_email=receiver.email,
|
to_email=receiver.email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body_text=message,
|
body_text=message,
|
||||||
|
body_html=message,
|
||||||
to_name=receiver.name,
|
to_name=receiver.name,
|
||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|||||||
@@ -30,6 +30,33 @@ class EmailClient:
|
|||||||
def __init__(self, smtp_config: SmtpConfig) -> None:
|
def __init__(self, smtp_config: SmtpConfig) -> None:
|
||||||
self._config = smtp_config
|
self._config = smtp_config
|
||||||
|
|
||||||
|
async def verify_connection(self) -> dict[str, Any]:
|
||||||
|
"""Test SMTP connection and authentication without sending an email."""
|
||||||
|
try:
|
||||||
|
import aiosmtplib
|
||||||
|
except ImportError:
|
||||||
|
return {"success": False, "error": "aiosmtplib not installed"}
|
||||||
|
|
||||||
|
cfg = self._config
|
||||||
|
if not cfg.host:
|
||||||
|
return {"success": False, "error": "SMTP host not configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
smtp = aiosmtplib.SMTP(
|
||||||
|
hostname=cfg.host,
|
||||||
|
port=cfg.port,
|
||||||
|
use_tls=cfg.use_tls,
|
||||||
|
start_tls=not cfg.use_tls and cfg.port != 25,
|
||||||
|
)
|
||||||
|
await smtp.connect()
|
||||||
|
if cfg.username and cfg.password:
|
||||||
|
await smtp.login(cfg.username, cfg.password)
|
||||||
|
await smtp.quit()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.warning("SMTP verification failed for %s:%d: %s", cfg.host, cfg.port, e)
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
async def send(
|
async def send(
|
||||||
self,
|
self,
|
||||||
to_email: str,
|
to_email: str,
|
||||||
|
|||||||
@@ -55,6 +55,18 @@ async def create_email_bot(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
# Verify SMTP connection before saving
|
||||||
|
from notify_bridge_core.notifications.email.client import EmailClient, SmtpConfig
|
||||||
|
client = EmailClient(SmtpConfig(
|
||||||
|
host=body.smtp_host, port=body.smtp_port,
|
||||||
|
username=body.smtp_username, password=body.smtp_password,
|
||||||
|
from_address=body.email, from_name=body.name,
|
||||||
|
use_tls=body.smtp_use_tls,
|
||||||
|
))
|
||||||
|
result = await client.verify_connection()
|
||||||
|
if not result.get("success"):
|
||||||
|
raise HTTPException(status_code=400, detail=f"SMTP connection failed: {result.get('error', 'Unknown error')}")
|
||||||
|
|
||||||
bot = EmailBot(user_id=user.id, **body.model_dump())
|
bot = EmailBot(user_id=user.id, **body.model_dump())
|
||||||
session.add(bot)
|
session.add(bot)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class NotificationTrackerCreate(BaseModel):
|
|||||||
collection_ids: list[str] = []
|
collection_ids: list[str] = []
|
||||||
scan_interval: int = 60
|
scan_interval: int = 60
|
||||||
batch_duration: int = 0
|
batch_duration: int = 0
|
||||||
|
default_tracking_config_id: int | None = None
|
||||||
|
default_template_config_id: int | None = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -41,6 +43,8 @@ class NotificationTrackerUpdate(BaseModel):
|
|||||||
collection_ids: list[str] | None = None
|
collection_ids: list[str] | None = None
|
||||||
scan_interval: int | None = None
|
scan_interval: int | None = None
|
||||||
batch_duration: int | None = None
|
batch_duration: int | None = None
|
||||||
|
default_tracking_config_id: int | None = None
|
||||||
|
default_template_config_id: int | None = None
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -190,6 +194,8 @@ async def _tracker_response(session: AsyncSession, t: NotificationTracker) -> di
|
|||||||
"collection_ids": t.collection_ids,
|
"collection_ids": t.collection_ids,
|
||||||
"scan_interval": t.scan_interval,
|
"scan_interval": t.scan_interval,
|
||||||
"batch_duration": t.batch_duration,
|
"batch_duration": t.batch_duration,
|
||||||
|
"default_tracking_config_id": t.default_tracking_config_id,
|
||||||
|
"default_template_config_id": t.default_template_config_id,
|
||||||
"enabled": t.enabled,
|
"enabled": t.enabled,
|
||||||
"tracker_targets": tracker_targets,
|
"tracker_targets": tracker_targets,
|
||||||
"created_at": t.created_at.isoformat(),
|
"created_at": t.created_at.isoformat(),
|
||||||
|
|||||||
@@ -309,6 +309,8 @@ class NotificationTracker(SQLModel, table=True):
|
|||||||
filters: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
filters: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||||
scan_interval: int = Field(default=60)
|
scan_interval: int = Field(default=60)
|
||||||
batch_duration: int = Field(default=0) # seconds to accumulate events before dispatch (0=immediate)
|
batch_duration: int = Field(default=0) # seconds to accumulate events before dispatch (0=immediate)
|
||||||
|
default_tracking_config_id: int | None = Field(default=None, foreign_key="tracking_config.id")
|
||||||
|
default_template_config_id: int | None = Field(default=None, foreign_key="template_config.id")
|
||||||
enabled: bool = Field(default=True)
|
enabled: bool = Field(default=True)
|
||||||
created_at: datetime = Field(default_factory=_utcnow)
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ..database.models import (
|
|||||||
EmailBot,
|
EmailBot,
|
||||||
MatrixBot,
|
MatrixBot,
|
||||||
NotificationTarget,
|
NotificationTarget,
|
||||||
|
NotificationTracker,
|
||||||
NotificationTrackerTarget,
|
NotificationTrackerTarget,
|
||||||
TargetReceiver,
|
TargetReceiver,
|
||||||
TelegramBot,
|
TelegramBot,
|
||||||
@@ -191,6 +192,11 @@ async def load_link_data(
|
|||||||
tracker_id: ID of the tracker whose links to load.
|
tracker_id: ID of the tracker whose links to load.
|
||||||
check_quiet_hours: If True, skip links currently in quiet hours.
|
check_quiet_hours: If True, skip links currently in quiet hours.
|
||||||
"""
|
"""
|
||||||
|
# Load the tracker itself for default config IDs
|
||||||
|
tracker = await session.get(NotificationTracker, tracker_id)
|
||||||
|
default_tc_id = getattr(tracker, "default_tracking_config_id", None) if tracker else None
|
||||||
|
default_tmpl_id = getattr(tracker, "default_template_config_id", None) if tracker else None
|
||||||
|
|
||||||
tt_result = await session.exec(
|
tt_result = await session.exec(
|
||||||
select(NotificationTrackerTarget).where(
|
select(NotificationTrackerTarget).where(
|
||||||
NotificationTrackerTarget.tracker_id == tracker_id
|
NotificationTrackerTarget.tracker_id == tracker_id
|
||||||
@@ -198,35 +204,61 @@ async def load_link_data(
|
|||||||
)
|
)
|
||||||
tracker_targets = tt_result.all()
|
tracker_targets = tt_result.all()
|
||||||
|
|
||||||
link_data: list[dict[str, Any]] = []
|
# Filter enabled links and quiet hours upfront
|
||||||
for tt in tracker_targets:
|
active_links = [
|
||||||
if not tt.enabled:
|
tt for tt in tracker_targets
|
||||||
continue
|
if tt.enabled and not (check_quiet_hours and in_quiet_hours(tt.quiet_hours_start, tt.quiet_hours_end))
|
||||||
if check_quiet_hours and in_quiet_hours(tt.quiet_hours_start, tt.quiet_hours_end):
|
]
|
||||||
continue
|
if not active_links:
|
||||||
|
return []
|
||||||
|
|
||||||
target = await session.get(NotificationTarget, tt.target_id)
|
# Batch-load targets
|
||||||
|
target_ids = list({tt.target_id for tt in active_links})
|
||||||
|
target_result = await session.exec(
|
||||||
|
select(NotificationTarget).where(NotificationTarget.id.in_(target_ids))
|
||||||
|
)
|
||||||
|
target_map = {t.id: t for t in target_result.all()}
|
||||||
|
|
||||||
|
# Batch-load tracking configs (per-link + tracker default)
|
||||||
|
tc_ids = list({tid for tid in
|
||||||
|
[tt.tracking_config_id for tt in active_links] + [default_tc_id]
|
||||||
|
if tid})
|
||||||
|
tc_map: dict[int, TrackingConfig] = {}
|
||||||
|
if tc_ids:
|
||||||
|
tc_result = await session.exec(select(TrackingConfig).where(TrackingConfig.id.in_(tc_ids)))
|
||||||
|
tc_map = {tc.id: tc for tc in tc_result.all()}
|
||||||
|
|
||||||
|
# Batch-load template configs (per-link + tracker default)
|
||||||
|
tmpl_ids = list({tid for tid in
|
||||||
|
[tt.template_config_id for tt in active_links] + [default_tmpl_id]
|
||||||
|
if tid})
|
||||||
|
tmpl_map: dict[int, TemplateConfig] = {}
|
||||||
|
if tmpl_ids:
|
||||||
|
tmpl_result = await session.exec(select(TemplateConfig).where(TemplateConfig.id.in_(tmpl_ids)))
|
||||||
|
tmpl_map = {tc.id: tc for tc in tmpl_result.all()}
|
||||||
|
|
||||||
|
# Batch-load template slots for all template configs
|
||||||
|
slots_by_config: dict[int, dict[str, dict[str, str]]] = {}
|
||||||
|
if tmpl_ids:
|
||||||
|
slot_result = await session.exec(
|
||||||
|
select(TemplateSlot).where(TemplateSlot.config_id.in_(tmpl_ids))
|
||||||
|
)
|
||||||
|
for s in slot_result.all():
|
||||||
|
event_key = s.slot_name.removeprefix("message_") if s.slot_name.startswith("message_") else s.slot_name
|
||||||
|
slots_by_config.setdefault(s.config_id, {}).setdefault(event_key, {})[s.locale] = s.template
|
||||||
|
|
||||||
|
link_data: list[dict[str, Any]] = []
|
||||||
|
for tt in active_links:
|
||||||
|
target = target_map.get(tt.target_id)
|
||||||
if not target:
|
if not target:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Load tracking config and template slots (shared across broadcast children)
|
# Per-link config overrides tracker defaults
|
||||||
tracking_config = None
|
tc_id = tt.tracking_config_id or default_tc_id
|
||||||
if tt.tracking_config_id:
|
tmpl_id = tt.template_config_id or default_tmpl_id
|
||||||
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
tracking_config = tc_map.get(tc_id) if tc_id else None
|
||||||
|
template_config = tmpl_map.get(tmpl_id) if tmpl_id else None
|
||||||
template_config = None
|
template_slots = slots_by_config.get(template_config.id) if template_config else None
|
||||||
template_slots: dict[str, 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: dict[str, dict[str, str]] = {}
|
|
||||||
for s in slot_result.all():
|
|
||||||
event_key = s.slot_name.removeprefix("message_") if s.slot_name.startswith("message_") else s.slot_name
|
|
||||||
raw_slots.setdefault(event_key, {})[s.locale] = s.template
|
|
||||||
template_slots = raw_slots
|
|
||||||
|
|
||||||
# Broadcast target: expand into child targets
|
# Broadcast target: expand into child targets
|
||||||
if target.type == "broadcast":
|
if target.type == "broadcast":
|
||||||
|
|||||||
@@ -104,14 +104,7 @@ async def _send_telegram_broadcast(target: NotificationTarget, message: str, rec
|
|||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
successes = sum(1 for r in results if r.get("success"))
|
return _aggregate(results)
|
||||||
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_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
|
async def _send_webhook_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
|
||||||
@@ -130,14 +123,7 @@ async def _send_webhook_broadcast(target: NotificationTarget, message: str, rece
|
|||||||
client = WebhookClient(session, url, headers)
|
client = WebhookClient(session, url, headers)
|
||||||
results.append(await client.send({"message": message, "event_type": "notification"}))
|
results.append(await client.send({"message": message, "event_type": "notification"}))
|
||||||
|
|
||||||
successes = sum(1 for r in results if r.get("success"))
|
return _aggregate(results)
|
||||||
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:
|
async def _send_email_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
|
||||||
@@ -179,18 +165,12 @@ async def _send_email_broadcast(target: NotificationTarget, message: str, receiv
|
|||||||
to_email=email,
|
to_email=email,
|
||||||
subject="Notification from Notify Bridge",
|
subject="Notification from Notify Bridge",
|
||||||
body_text=message,
|
body_text=message,
|
||||||
|
body_html=message,
|
||||||
to_name=recv.get("name", ""),
|
to_name=recv.get("name", ""),
|
||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
successes = sum(1 for r in results if r.get("success"))
|
return _aggregate(results)
|
||||||
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"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_webhook_like_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
|
async def _send_webhook_like_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
|
||||||
|
|||||||
Reference in New Issue
Block a user