feat: broadcast notification target + UX improvements
Add broadcast target type that fans out notifications to multiple child targets. Dispatch expands broadcast into children in load_link_data() — dispatcher stays unaware. Children can be toggled on/off via disabled_child_ids in config. Also: dashboard provider card smaller font for names, scroll-to-form on target edit, broadcast nav tab with counter, flag_modified fix for JSON column updates, CLAUDE.md nav tree docs.
This commit is contained in:
@@ -34,4 +34,5 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the
|
|||||||
- Components use `getDescriptor(type)` and render dynamically from the descriptor
|
- Components use `getDescriptor(type)` and render dynamically from the descriptor
|
||||||
- Feature gating: check `capabilities.notification_slots` or `capabilities.commands`, not `provider.type === 'immich'`
|
- Feature gating: check `capabilities.notification_slots` or `capabilities.commands`, not `provider.type === 'immich'`
|
||||||
- Template variable helpers: ALL provider types must have entries in `get_template_variables()`
|
- Template variable helpers: ALL provider types must have entries in `get_template_variables()`
|
||||||
9. **New provider descriptor checklist** — when adding a new service provider, create a descriptor file in `frontend/src/lib/providers/{name}.ts` and register it in `index.ts`. The descriptor must define: `type`, `defaultName`, `icon`, `hasUrl`, `configFields`, `buildConfig()`, `hasConfigChanged()`, `eventFields`, `collectionMeta` (or `null`). Optional: `extraTrackingFields`, `featureSections`, `webhookUrlPattern`, `webhookBased`, `onBeforeSave`. Also add i18n keys: `providers.type{PascalName}` and `gridDesc.provider{PascalName}` in both `en.json` and `ru.json`.
|
9. **Nav tree & entity types** — when adding a new entity type (target type, bot type, etc.), update the sidebar nav in `frontend/src/routes/+layout.svelte`. Target types need: `{ href: '/targets?type={type}', key: 'nav.target{PascalName}', icon: '...', countKey: 'targets_{type}' }` in the `children` array under `nav.targets`, plus i18n keys `nav.target{PascalName}` in both locale files. Also add the counter entry `targets_{type}: targets.filter(t => t.type === '{type}').length` to the `counts` derived block in `+layout.svelte`.
|
||||||
|
10. **New provider descriptor checklist** — when adding a new service provider, create a descriptor file in `frontend/src/lib/providers/{name}.ts` and register it in `index.ts`. The descriptor must define: `type`, `defaultName`, `icon`, `hasUrl`, `configFields`, `buildConfig()`, `hasConfigChanged()`, `eventFields`, `collectionMeta` (or `null`). Optional: `extraTrackingFields`, `featureSections`, `webhookUrlPattern`, `webhookBased`, `onBeforeSave`. Also add i18n keys: `providers.type{PascalName}` and `gridDesc.provider{PascalName}` in both `en.json` and `ru.json`.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"targetSlack": "Slack",
|
"targetSlack": "Slack",
|
||||||
"targetNtfy": "ntfy",
|
"targetNtfy": "ntfy",
|
||||||
"targetMatrix": "Matrix",
|
"targetMatrix": "Matrix",
|
||||||
|
"targetBroadcast": "Broadcast",
|
||||||
"automation": "Automation",
|
"automation": "Automation",
|
||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
},
|
},
|
||||||
@@ -256,6 +257,11 @@
|
|||||||
"descSlack": "Slack channel webhooks for notifications",
|
"descSlack": "Slack channel webhooks for notifications",
|
||||||
"descNtfy": "ntfy push notification topics",
|
"descNtfy": "ntfy push notification topics",
|
||||||
"descMatrix": "Matrix room destinations for notifications",
|
"descMatrix": "Matrix room destinations for notifications",
|
||||||
|
"descBroadcast": "Send to multiple targets at once",
|
||||||
|
"childTargets": "target(s)",
|
||||||
|
"selectChildTargets": "Select child targets",
|
||||||
|
"noChildTargets": "No child targets configured.",
|
||||||
|
"noChildTargetsAvailable": "Create other targets first, then add them here.",
|
||||||
"addTarget": "Add Target",
|
"addTarget": "Add Target",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
@@ -814,6 +820,8 @@
|
|||||||
"syntaxError": "Syntax error",
|
"syntaxError": "Syntax error",
|
||||||
"undefinedVar": "Unknown variable",
|
"undefinedVar": "Unknown variable",
|
||||||
"line": "line",
|
"line": "line",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"filterByName": "Filter by name...",
|
"filterByName": "Filter by name...",
|
||||||
"allTypes": "All types",
|
"allTypes": "All types",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"targetSlack": "Slack",
|
"targetSlack": "Slack",
|
||||||
"targetNtfy": "ntfy",
|
"targetNtfy": "ntfy",
|
||||||
"targetMatrix": "Matrix",
|
"targetMatrix": "Matrix",
|
||||||
|
"targetBroadcast": "Рассылка",
|
||||||
"automation": "Автоматизация",
|
"automation": "Автоматизация",
|
||||||
"actions": "Действия"
|
"actions": "Действия"
|
||||||
},
|
},
|
||||||
@@ -256,6 +257,11 @@
|
|||||||
"descSlack": "Вебхуки каналов Slack для уведомлений",
|
"descSlack": "Вебхуки каналов Slack для уведомлений",
|
||||||
"descNtfy": "Топики ntfy для push-уведомлений",
|
"descNtfy": "Топики ntfy для push-уведомлений",
|
||||||
"descMatrix": "Комнаты Matrix для доставки уведомлений",
|
"descMatrix": "Комнаты Matrix для доставки уведомлений",
|
||||||
|
"descBroadcast": "Отправка сразу в несколько целей",
|
||||||
|
"childTargets": "цель(ей)",
|
||||||
|
"selectChildTargets": "Выберите дочерние цели",
|
||||||
|
"noChildTargets": "Дочерние цели не настроены.",
|
||||||
|
"noChildTargetsAvailable": "Сначала создайте другие цели, затем добавьте их сюда.",
|
||||||
"addTarget": "Добавить получателя",
|
"addTarget": "Добавить получателя",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"type": "Тип",
|
"type": "Тип",
|
||||||
@@ -814,6 +820,8 @@
|
|||||||
"syntaxError": "Ошибка синтаксиса",
|
"syntaxError": "Ошибка синтаксиса",
|
||||||
"undefinedVar": "Неизвестная переменная",
|
"undefinedVar": "Неизвестная переменная",
|
||||||
"line": "строка",
|
"line": "строка",
|
||||||
|
"enable": "Включить",
|
||||||
|
"disable": "Выключить",
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"filterByName": "Фильтр по имени...",
|
"filterByName": "Фильтр по имени...",
|
||||||
"allTypes": "Все типы",
|
"allTypes": "Все типы",
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export interface NotificationTarget {
|
|||||||
chat_name?: string;
|
chat_name?: string;
|
||||||
receiver_count: number;
|
receiver_count: number;
|
||||||
receivers: TargetReceiver[];
|
receivers: TargetReceiver[];
|
||||||
|
/** Broadcast targets only: resolved child target summaries. */
|
||||||
|
child_targets?: { id: number; name: string; type: string; icon: string }[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
targets_slack: targets.filter(t => t.type === 'slack').length,
|
targets_slack: targets.filter(t => t.type === 'slack').length,
|
||||||
targets_ntfy: targets.filter(t => t.type === 'ntfy').length,
|
targets_ntfy: targets.filter(t => t.type === 'ntfy').length,
|
||||||
targets_matrix: targets.filter(t => t.type === 'matrix').length,
|
targets_matrix: targets.filter(t => t.type === 'matrix').length,
|
||||||
|
targets_broadcast: targets.filter(t => t.type === 'broadcast').length,
|
||||||
} as Record<string, number>;
|
} as Record<string, number>;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,6 +170,7 @@
|
|||||||
{ href: '/targets?type=slack', key: 'nav.targetSlack', icon: 'mdiSlack', countKey: 'targets_slack' },
|
{ href: '/targets?type=slack', key: 'nav.targetSlack', icon: 'mdiSlack', countKey: 'targets_slack' },
|
||||||
{ href: '/targets?type=ntfy', key: 'nav.targetNtfy', icon: 'mdiBell', countKey: 'targets_ntfy' },
|
{ href: '/targets?type=ntfy', key: 'nav.targetNtfy', icon: 'mdiBell', countKey: 'targets_ntfy' },
|
||||||
{ href: '/targets?type=matrix', key: 'nav.targetMatrix', icon: 'mdiMatrix', countKey: 'targets_matrix' },
|
{ href: '/targets?type=matrix', key: 'nav.targetMatrix', icon: 'mdiMatrix', countKey: 'targets_matrix' },
|
||||||
|
{ href: '/targets?type=broadcast', key: 'nav.targetBroadcast', icon: 'mdiBullhorn', countKey: 'targets_broadcast' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -250,7 +250,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm" style="color: var(--color-muted-foreground);">{card.literalLabel || t(card.label)}</p>
|
<p class="text-sm" style="color: var(--color-muted-foreground);">{card.literalLabel || t(card.label)}</p>
|
||||||
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
|
<p class="{card.literalValue ? 'stat-value-text' : 'stat-value'} font-mono" style="animation-delay: {i * 80 + 200}ms;">
|
||||||
{#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
|
{#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,6 +362,7 @@
|
|||||||
.stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; }
|
.stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; }
|
||||||
.stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; }
|
.stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; }
|
||||||
.stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; animation: countUp 0.5s ease-out both; }
|
.stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; animation: countUp 0.5s ease-out both; }
|
||||||
|
.stat-value-text { font-size: 1rem; font-weight: 600; line-height: 1.3; animation: countUp 0.5s ease-out both; }
|
||||||
.stat-suffix { font-size: 1rem; font-weight: 400; color: var(--color-muted-foreground); }
|
.stat-suffix { font-size: 1rem; font-weight: 400; color: var(--color-muted-foreground); }
|
||||||
.event-timeline { display: flex; flex-direction: column; }
|
.event-timeline { display: flex; flex-direction: column; }
|
||||||
.event-item { display: flex; align-items: flex-start; gap: 1rem; position: relative; padding-bottom: 0.75rem; }
|
.event-item { display: flex; align-items: flex-start; gap: 1rem; position: relative; padding-bottom: 0.75rem; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
@@ -70,15 +70,17 @@
|
|||||||
|
|
||||||
// ── Constants ──
|
// ── Constants ──
|
||||||
|
|
||||||
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix'] as const;
|
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
|
||||||
type TargetType = typeof ALL_TYPES[number];
|
type TargetType = typeof ALL_TYPES[number];
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, string> = {
|
||||||
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
|
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
|
||||||
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix',
|
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix',
|
||||||
|
broadcast: 'mdiBullhorn',
|
||||||
};
|
};
|
||||||
const TYPE_DESC_KEYS: Record<string, string> = {
|
const TYPE_DESC_KEYS: Record<string, string> = {
|
||||||
telegram: 'targets.descTelegram', webhook: 'targets.descWebhook', email: 'targets.descEmail',
|
telegram: 'targets.descTelegram', webhook: 'targets.descWebhook', email: 'targets.descEmail',
|
||||||
discord: 'targets.descDiscord', slack: 'targets.descSlack', ntfy: 'targets.descNtfy', matrix: 'targets.descMatrix',
|
discord: 'targets.descDiscord', slack: 'targets.descSlack', ntfy: 'targets.descNtfy', matrix: 'targets.descMatrix',
|
||||||
|
broadcast: 'targets.descBroadcast',
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeGridItems = $derived(ALL_TYPES.map(tt => ({
|
const typeGridItems = $derived(ALL_TYPES.map(tt => ({
|
||||||
@@ -120,6 +122,8 @@
|
|||||||
matrix_bot_id: 0,
|
matrix_bot_id: 0,
|
||||||
// Email
|
// Email
|
||||||
email_bot_id: 0,
|
email_bot_id: 0,
|
||||||
|
// Broadcast
|
||||||
|
child_target_ids: [] as number[],
|
||||||
});
|
});
|
||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -128,6 +132,12 @@
|
|||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
let showTelegramSettings = $state(false);
|
let showTelegramSettings = $state(false);
|
||||||
let confirmDelete = $state<NotificationTarget | null>(null);
|
let confirmDelete = $state<NotificationTarget | null>(null);
|
||||||
|
let formEl: HTMLElement;
|
||||||
|
|
||||||
|
async function scrollToForm() {
|
||||||
|
await tick();
|
||||||
|
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Receiver inline form state ──
|
// ── Receiver inline form state ──
|
||||||
|
|
||||||
@@ -185,6 +195,7 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
showTelegramSettings = false;
|
showTelegramSettings = false;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
|
scrollToForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function edit(tgt: NotificationTarget) {
|
async function edit(tgt: NotificationTarget) {
|
||||||
@@ -207,10 +218,13 @@
|
|||||||
email_bot_id: c.email_bot_id || 0,
|
email_bot_id: c.email_bot_id || 0,
|
||||||
// matrix
|
// matrix
|
||||||
matrix_bot_id: c.matrix_bot_id || 0,
|
matrix_bot_id: c.matrix_bot_id || 0,
|
||||||
|
// broadcast
|
||||||
|
child_target_ids: c.child_target_ids || [],
|
||||||
};
|
};
|
||||||
editing = tgt.id;
|
editing = tgt.id;
|
||||||
showTelegramSettings = false;
|
showTelegramSettings = false;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
|
scrollToForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(e: SubmitEvent) {
|
async function save(e: SubmitEvent) {
|
||||||
@@ -245,6 +259,8 @@
|
|||||||
config = { email_bot_id: form.email_bot_id };
|
config = { email_bot_id: form.email_bot_id };
|
||||||
} else if (formType === 'matrix') {
|
} else if (formType === 'matrix') {
|
||||||
config = { matrix_bot_id: form.matrix_bot_id };
|
config = { matrix_bot_id: form.matrix_bot_id };
|
||||||
|
} else if (formType === 'broadcast') {
|
||||||
|
config = { child_target_ids: form.child_target_ids };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@@ -367,6 +383,21 @@
|
|||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: any) { snackError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleBroadcastChild(targetId: number, childId: number) {
|
||||||
|
const tgt = allTargets.find(t => t.id === targetId);
|
||||||
|
if (!tgt) return;
|
||||||
|
const disabled = new Set<number>(tgt.config?.disabled_child_ids || []);
|
||||||
|
if (disabled.has(childId)) disabled.delete(childId);
|
||||||
|
else disabled.add(childId);
|
||||||
|
try {
|
||||||
|
await api(`/targets/${targetId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (err: any) { snackError(err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
async function testReceiver(targetId: number, receiverId: number) {
|
async function testReceiver(targetId: number, receiverId: number) {
|
||||||
receiverTesting = { ...receiverTesting, [receiverId]: true };
|
receiverTesting = { ...receiverTesting, [receiverId]: true };
|
||||||
try {
|
try {
|
||||||
@@ -392,6 +423,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
|
<div bind:this={formEl}></div>
|
||||||
<TargetForm
|
<TargetForm
|
||||||
bind:form
|
bind:form
|
||||||
bind:formType
|
bind:formType
|
||||||
@@ -404,6 +436,7 @@
|
|||||||
telegramBotCount={telegramBots.length}
|
telegramBotCount={telegramBots.length}
|
||||||
emailBotCount={emailBots.length}
|
emailBotCount={emailBots.length}
|
||||||
matrixBotCount={matrixBots.length}
|
matrixBotCount={matrixBots.length}
|
||||||
|
broadcastChildItems={allTargets.filter(t => t.type !== 'broadcast' && t.id !== editing).map(t => ({ value: t.id, label: t.name, icon: t.icon || TYPE_ICONS[t.type] || 'mdiTarget', desc: t.type }))}
|
||||||
{editing}
|
{editing}
|
||||||
{submitting}
|
{submitting}
|
||||||
{error}
|
{error}
|
||||||
@@ -439,7 +472,11 @@
|
|||||||
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
||||||
<p class="font-medium">{target.name}</p>
|
<p class="font-medium">{target.name}</p>
|
||||||
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
|
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
|
||||||
{#if (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>{/if}
|
{#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>
|
||||||
|
{: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>
|
||||||
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,6 +506,7 @@
|
|||||||
ontestReceiver={testReceiver}
|
ontestReceiver={testReceiver}
|
||||||
onloadBotChats={loadReceiverBotChats}
|
onloadBotChats={loadReceiverBotChats}
|
||||||
onchangeReceiverForm={(f) => receiverForm = f}
|
onchangeReceiverForm={(f) => receiverForm = f}
|
||||||
|
ontoggleBroadcastChild={toggleBroadcastChild}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
ontestReceiver: (targetId: number, receiverId: number) => void;
|
ontestReceiver: (targetId: number, receiverId: number) => void;
|
||||||
onloadBotChats: (botId: number) => void;
|
onloadBotChats: (botId: number) => void;
|
||||||
onchangeReceiverForm: (form: Record<string, any>) => void;
|
onchangeReceiverForm: (form: Record<string, any>) => void;
|
||||||
|
ontoggleBroadcastChild?: (targetId: number, childId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -44,10 +45,37 @@
|
|||||||
ontestReceiver,
|
ontestReceiver,
|
||||||
onloadBotChats,
|
onloadBotChats,
|
||||||
onchangeReceiverForm,
|
onchangeReceiverForm,
|
||||||
|
ontoggleBroadcastChild,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
||||||
|
{#if target.type === 'broadcast'}
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.childTargets')}</p>
|
||||||
|
</div>
|
||||||
|
{@const disabledIds = new Set(target.config?.disabled_child_ids || [])}
|
||||||
|
{#if target.child_targets?.length}
|
||||||
|
{#each target.child_targets as child (child.id)}
|
||||||
|
{@const isDisabled = disabledIds.has(child.id)}
|
||||||
|
<div class="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-[var(--color-muted)]" class:opacity-50={isDisabled}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<MdiIcon name={child.icon || typeIcons[child.type] || 'mdiTarget'} size={14} />
|
||||||
|
<span class="text-sm">{child.name}</span>
|
||||||
|
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{child.type}</span>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
icon={isDisabled ? 'mdiToggleSwitchOff' : 'mdiToggleSwitch'}
|
||||||
|
title={isDisabled ? t('common.enable') : t('common.disable')}
|
||||||
|
onclick={() => ontoggleBroadcastChild?.(target.id, child.id)}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-[var(--color-muted-foreground)] italic">{t('targets.noChildTargets')}</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.receivers')}</p>
|
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.receivers')}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,4 +181,5 @@
|
|||||||
{t('targets.addReceiver')}
|
{t('targets.addReceiver')}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import Hint from '$lib/components/Hint.svelte';
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||||
|
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||||
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
|
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
|
||||||
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
|
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
auth_token: string;
|
auth_token: string;
|
||||||
matrix_bot_id: number;
|
matrix_bot_id: number;
|
||||||
email_bot_id: number;
|
email_bot_id: number;
|
||||||
|
child_target_ids: number[];
|
||||||
};
|
};
|
||||||
formType: string;
|
formType: string;
|
||||||
activeType: string | null;
|
activeType: string | null;
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
emailBotItems: EntityItem[];
|
emailBotItems: EntityItem[];
|
||||||
matrixBotItems: EntityItem[];
|
matrixBotItems: EntityItem[];
|
||||||
chatActionItems: GridItem[];
|
chatActionItems: GridItem[];
|
||||||
|
broadcastChildItems?: { value: number; label: string; icon: string; desc: string }[];
|
||||||
telegramBotCount: number;
|
telegramBotCount: number;
|
||||||
emailBotCount: number;
|
emailBotCount: number;
|
||||||
matrixBotCount: number;
|
matrixBotCount: number;
|
||||||
@@ -56,6 +59,7 @@
|
|||||||
emailBotItems,
|
emailBotItems,
|
||||||
matrixBotItems,
|
matrixBotItems,
|
||||||
chatActionItems,
|
chatActionItems,
|
||||||
|
broadcastChildItems = [],
|
||||||
telegramBotCount,
|
telegramBotCount,
|
||||||
emailBotCount,
|
emailBotCount,
|
||||||
matrixBotCount,
|
matrixBotCount,
|
||||||
@@ -160,6 +164,20 @@
|
|||||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline">→</a></p>
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline">→</a></p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if formType === 'broadcast'}
|
||||||
|
{@const childIds = (form.child_target_ids || []).map(String)}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">{t('targets.selectChildTargets')}</label>
|
||||||
|
<MultiEntitySelect
|
||||||
|
items={broadcastChildItems?.map(i => ({ value: String(i.value), label: i.label, icon: i.icon, desc: i.desc })) ?? []}
|
||||||
|
values={childIds}
|
||||||
|
onchange={(vals) => form.child_target_ids = vals.map(Number)}
|
||||||
|
placeholder={t('targets.selectChildTargets')}
|
||||||
|
/>
|
||||||
|
{#if broadcastChildItems?.length === 0}
|
||||||
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('targets.noChildTargetsAvailable')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if formType === 'telegram'}
|
{#if formType === 'telegram'}
|
||||||
|
|||||||
@@ -111,8 +111,11 @@ async def list_targets(
|
|||||||
if lang:
|
if lang:
|
||||||
chat_languages[f"{bot_id}_{chat_id}"] = lang
|
chat_languages[f"{bot_id}_{chat_id}"] = lang
|
||||||
|
|
||||||
|
# Build lookup for broadcast child target resolution
|
||||||
|
target_map = {t.id: t for t in targets}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
_target_response(t, chat_names, target_receivers.get(t.id, []), chat_languages)
|
_target_response(t, chat_names, target_receivers.get(t.id, []), chat_languages, target_map)
|
||||||
for t in targets
|
for t in targets
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -124,15 +127,24 @@ async def create_target(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Create a new notification target."""
|
"""Create a new notification target."""
|
||||||
valid_types = ("telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix")
|
valid_types = ("telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix", "broadcast")
|
||||||
if body.type not in valid_types:
|
if body.type not in valid_types:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Type must be one of: {', '.join(valid_types)}",
|
detail=f"Type must be one of: {', '.join(valid_types)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract delivery fields from config — they go into a TargetReceiver
|
# Broadcast-specific validation
|
||||||
clean_config, receiver_cfg = _extract_delivery_fields(body.type, body.config)
|
if body.type == "broadcast":
|
||||||
|
child_ids = body.config.get("child_target_ids", [])
|
||||||
|
if not isinstance(child_ids, list):
|
||||||
|
raise HTTPException(status_code=400, detail="child_target_ids must be a list")
|
||||||
|
await _validate_broadcast_children(session, child_ids, user.id, exclude_target_id=None)
|
||||||
|
clean_config = {"child_target_ids": child_ids}
|
||||||
|
receiver_cfg: dict[str, Any] = {}
|
||||||
|
else:
|
||||||
|
# Extract delivery fields from config — they go into a TargetReceiver
|
||||||
|
clean_config, receiver_cfg = _extract_delivery_fields(body.type, body.config)
|
||||||
|
|
||||||
target = NotificationTarget(
|
target = NotificationTarget(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -190,34 +202,46 @@ async def update_target(
|
|||||||
|
|
||||||
# If config is being updated, extract any delivery fields
|
# If config is being updated, extract any delivery fields
|
||||||
if "config" in updates and updates["config"] is not None:
|
if "config" in updates and updates["config"] is not None:
|
||||||
clean_config, receiver_cfg = _extract_delivery_fields(target.type, updates["config"])
|
if target.type == "broadcast":
|
||||||
updates["config"] = clean_config
|
child_ids = updates["config"].get("child_target_ids", [])
|
||||||
|
if not isinstance(child_ids, list):
|
||||||
|
raise HTTPException(status_code=400, detail="child_target_ids must be a list")
|
||||||
|
await _validate_broadcast_children(session, child_ids, user.id, exclude_target_id=target.id)
|
||||||
|
disabled_ids = updates["config"].get("disabled_child_ids", [])
|
||||||
|
updates["config"] = {"child_target_ids": child_ids, "disabled_child_ids": disabled_ids}
|
||||||
|
else:
|
||||||
|
clean_config, receiver_cfg = _extract_delivery_fields(target.type, updates["config"])
|
||||||
|
updates["config"] = clean_config
|
||||||
|
|
||||||
# Update or create receiver if delivery fields were present
|
# Update or create receiver if delivery fields were present
|
||||||
if receiver_cfg:
|
if receiver_cfg:
|
||||||
key = _receiver_key(target.type, receiver_cfg)
|
key = _receiver_key(target.type, receiver_cfg)
|
||||||
existing_result = await session.exec(
|
existing_result = await session.exec(
|
||||||
select(TargetReceiver).where(
|
select(TargetReceiver).where(
|
||||||
TargetReceiver.target_id == target.id,
|
TargetReceiver.target_id == target.id,
|
||||||
TargetReceiver.receiver_key == key,
|
TargetReceiver.receiver_key == key,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
existing_recv = existing_result.first()
|
||||||
existing_recv = existing_result.first()
|
if existing_recv:
|
||||||
if existing_recv:
|
existing_recv.config = receiver_cfg
|
||||||
existing_recv.config = receiver_cfg
|
session.add(existing_recv)
|
||||||
session.add(existing_recv)
|
else:
|
||||||
else:
|
receiver = TargetReceiver(
|
||||||
receiver = TargetReceiver(
|
target_id=target.id,
|
||||||
target_id=target.id,
|
name=target.name,
|
||||||
name=target.name,
|
config=receiver_cfg,
|
||||||
config=receiver_cfg,
|
receiver_key=key,
|
||||||
receiver_key=key,
|
enabled=True,
|
||||||
enabled=True,
|
)
|
||||||
)
|
session.add(receiver)
|
||||||
session.add(receiver)
|
|
||||||
|
|
||||||
for field_name, value in updates.items():
|
for field_name, value in updates.items():
|
||||||
setattr(target, field_name, value)
|
setattr(target, field_name, value)
|
||||||
|
# Force SQLAlchemy to detect JSON column change
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
if "config" in updates:
|
||||||
|
flag_modified(target, "config")
|
||||||
session.add(target)
|
session.add(target)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(target)
|
await session.refresh(target)
|
||||||
@@ -257,11 +281,37 @@ async def test_target(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_broadcast_children(
|
||||||
|
session: AsyncSession,
|
||||||
|
child_ids: list[int],
|
||||||
|
user_id: int,
|
||||||
|
*,
|
||||||
|
exclude_target_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Validate broadcast child target IDs.
|
||||||
|
|
||||||
|
- All IDs must exist and belong to the user
|
||||||
|
- No broadcast targets allowed as children (prevents circular refs)
|
||||||
|
- Cannot reference itself
|
||||||
|
"""
|
||||||
|
if not child_ids:
|
||||||
|
return
|
||||||
|
if exclude_target_id and exclude_target_id in child_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="A broadcast target cannot include itself")
|
||||||
|
for child_id in child_ids:
|
||||||
|
child = await session.get(NotificationTarget, child_id)
|
||||||
|
if not child or child.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Child target {child_id} not found")
|
||||||
|
if child.type == "broadcast":
|
||||||
|
raise HTTPException(status_code=400, detail="A broadcast target cannot include another broadcast")
|
||||||
|
|
||||||
|
|
||||||
def _target_response(
|
def _target_response(
|
||||||
target: NotificationTarget,
|
target: NotificationTarget,
|
||||||
chat_names: dict[str, str] | None = None,
|
chat_names: dict[str, str] | None = None,
|
||||||
receivers: list[TargetReceiver] | None = None,
|
receivers: list[TargetReceiver] | None = None,
|
||||||
chat_languages: dict[str, str] | None = None,
|
chat_languages: dict[str, str] | None = None,
|
||||||
|
target_map: dict[int, NotificationTarget] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
recv_list = receivers or []
|
recv_list = receivers or []
|
||||||
resp = {
|
resp = {
|
||||||
@@ -295,6 +345,14 @@ def _target_response(
|
|||||||
recv_resp["chat_name"] = chat_names[key]
|
recv_resp["chat_name"] = chat_names[key]
|
||||||
if chat_languages and key in chat_languages:
|
if chat_languages and key in chat_languages:
|
||||||
recv_resp["language_code"] = chat_languages[key]
|
recv_resp["language_code"] = chat_languages[key]
|
||||||
|
# Attach child target summaries for broadcast targets
|
||||||
|
if target.type == "broadcast" and target_map:
|
||||||
|
child_ids = target.config.get("child_target_ids", [])
|
||||||
|
resp["child_targets"] = [
|
||||||
|
{"id": cid, "name": target_map[cid].name, "type": target_map[cid].type, "icon": target_map[cid].icon}
|
||||||
|
for cid in child_ids if cid in target_map
|
||||||
|
]
|
||||||
|
resp["receiver_count"] = len(resp["child_targets"])
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,88 @@ def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
|||||||
return flag_map.get(event_type, True)
|
return flag_map.get(event_type, True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_target(
|
||||||
|
session: AsyncSession,
|
||||||
|
target: NotificationTarget,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Resolve a single target into dispatch-ready data (config + receivers + credentials).
|
||||||
|
|
||||||
|
Returns a dict with target_type, target_config, and receivers.
|
||||||
|
Does NOT include tracking_config or template_slots — those come from the tracker link.
|
||||||
|
"""
|
||||||
|
# Load receivers as typed Receiver objects
|
||||||
|
recv_result = await session.exec(
|
||||||
|
select(TargetReceiver).where(
|
||||||
|
TargetReceiver.target_id == target.id,
|
||||||
|
TargetReceiver.enabled == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recv_rows = recv_result.all()
|
||||||
|
|
||||||
|
# For Telegram targets, resolve locale from TelegramChat
|
||||||
|
chat_locale_map: dict[str, str] = {}
|
||||||
|
if target.type == "telegram":
|
||||||
|
bot_id = target.config.get("bot_id")
|
||||||
|
if bot_id:
|
||||||
|
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
|
||||||
|
if chat_ids:
|
||||||
|
chat_result = await session.exec(
|
||||||
|
select(TelegramChat).where(
|
||||||
|
TelegramChat.bot_id == bot_id,
|
||||||
|
TelegramChat.chat_id.in_(chat_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for chat in chat_result.all():
|
||||||
|
resolved = (
|
||||||
|
getattr(chat, 'language_override', '') or
|
||||||
|
getattr(chat, 'language_code', '') or ''
|
||||||
|
)
|
||||||
|
if resolved:
|
||||||
|
chat_locale_map[chat.chat_id] = resolved[:2].lower()
|
||||||
|
|
||||||
|
receivers: list[Receiver] = []
|
||||||
|
for r in recv_rows:
|
||||||
|
explicit_locale = getattr(r, 'locale', '') or ''
|
||||||
|
locale = explicit_locale
|
||||||
|
if not locale and target.type == "telegram":
|
||||||
|
chat_id = str(r.config.get("chat_id", ""))
|
||||||
|
locale = chat_locale_map.get(chat_id, "")
|
||||||
|
receivers.append(build_receiver(target.type, dict(r.config), locale))
|
||||||
|
|
||||||
|
target_config = dict(target.config)
|
||||||
|
# Inject chat_action for Telegram targets
|
||||||
|
if hasattr(target, 'chat_action') and target.chat_action:
|
||||||
|
target_config["chat_action"] = target.chat_action
|
||||||
|
# Inject bot credentials for bot-backed target types
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
elif target.type == "matrix":
|
||||||
|
matrix_bot_id = target.config.get("matrix_bot_id")
|
||||||
|
if matrix_bot_id:
|
||||||
|
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
|
||||||
|
if matrix_bot:
|
||||||
|
target_config["homeserver_url"] = matrix_bot.homeserver_url
|
||||||
|
target_config["access_token"] = matrix_bot.access_token
|
||||||
|
|
||||||
|
return {
|
||||||
|
"target_type": target.type,
|
||||||
|
"target_config": target_config,
|
||||||
|
"receivers": receivers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def load_link_data(
|
async def load_link_data(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
tracker_id: int,
|
tracker_id: int,
|
||||||
@@ -127,45 +209,7 @@ async def load_link_data(
|
|||||||
if not target:
|
if not target:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Load receivers as typed Receiver objects
|
# Load tracking config and template slots (shared across broadcast children)
|
||||||
recv_result = await session.exec(
|
|
||||||
select(TargetReceiver).where(
|
|
||||||
TargetReceiver.target_id == target.id,
|
|
||||||
TargetReceiver.enabled == True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
recv_rows = recv_result.all()
|
|
||||||
|
|
||||||
# For Telegram targets, resolve locale from TelegramChat
|
|
||||||
chat_locale_map: dict[str, str] = {}
|
|
||||||
if target.type == "telegram":
|
|
||||||
bot_id = target.config.get("bot_id")
|
|
||||||
if bot_id:
|
|
||||||
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
|
|
||||||
if chat_ids:
|
|
||||||
chat_result = await session.exec(
|
|
||||||
select(TelegramChat).where(
|
|
||||||
TelegramChat.bot_id == bot_id,
|
|
||||||
TelegramChat.chat_id.in_(chat_ids),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for chat in chat_result.all():
|
|
||||||
resolved = (
|
|
||||||
getattr(chat, 'language_override', '') or
|
|
||||||
getattr(chat, 'language_code', '') or ''
|
|
||||||
)
|
|
||||||
if resolved:
|
|
||||||
chat_locale_map[chat.chat_id] = resolved[:2].lower()
|
|
||||||
|
|
||||||
receivers: list[Receiver] = []
|
|
||||||
for r in recv_rows:
|
|
||||||
explicit_locale = getattr(r, 'locale', '') or ''
|
|
||||||
locale = explicit_locale
|
|
||||||
if not locale and target.type == "telegram":
|
|
||||||
chat_id = str(r.config.get("chat_id", ""))
|
|
||||||
locale = chat_locale_map.get(chat_id, "")
|
|
||||||
receivers.append(build_receiver(target.type, dict(r.config), locale))
|
|
||||||
|
|
||||||
tracking_config = None
|
tracking_config = None
|
||||||
if tt.tracking_config_id:
|
if tt.tracking_config_id:
|
||||||
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||||
@@ -184,37 +228,29 @@ async def load_link_data(
|
|||||||
raw_slots.setdefault(event_key, {})[s.locale] = s.template
|
raw_slots.setdefault(event_key, {})[s.locale] = s.template
|
||||||
template_slots = raw_slots
|
template_slots = raw_slots
|
||||||
|
|
||||||
target_config = dict(target.config)
|
# Broadcast target: expand into child targets
|
||||||
# Inject chat_action for Telegram targets
|
if target.type == "broadcast":
|
||||||
if hasattr(target, 'chat_action') and target.chat_action:
|
child_ids = target.config.get("child_target_ids", [])
|
||||||
target_config["chat_action"] = target.chat_action
|
disabled_ids = set(target.config.get("disabled_child_ids", []))
|
||||||
# Inject bot credentials for bot-backed target types
|
for child_id in child_ids:
|
||||||
if target.type == "email":
|
if child_id in disabled_ids:
|
||||||
email_bot_id = target.config.get("email_bot_id")
|
continue
|
||||||
if email_bot_id:
|
child_target = await session.get(NotificationTarget, child_id)
|
||||||
email_bot = await session.get(EmailBot, email_bot_id)
|
if not child_target or child_target.type == "broadcast":
|
||||||
if email_bot:
|
continue
|
||||||
target_config["smtp"] = {
|
resolved = await _resolve_target(session, child_target)
|
||||||
"host": email_bot.smtp_host,
|
link_data.append({
|
||||||
"port": email_bot.smtp_port,
|
**resolved,
|
||||||
"username": email_bot.smtp_username,
|
"tracking_config": tracking_config,
|
||||||
"password": email_bot.smtp_password,
|
"template_config": template_config,
|
||||||
"from_address": email_bot.email,
|
"template_slots": template_slots,
|
||||||
"from_name": email_bot.name,
|
})
|
||||||
"use_tls": email_bot.smtp_use_tls,
|
continue
|
||||||
}
|
|
||||||
elif target.type == "matrix":
|
|
||||||
matrix_bot_id = target.config.get("matrix_bot_id")
|
|
||||||
if matrix_bot_id:
|
|
||||||
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
|
|
||||||
if matrix_bot:
|
|
||||||
target_config["homeserver_url"] = matrix_bot.homeserver_url
|
|
||||||
target_config["access_token"] = matrix_bot.access_token
|
|
||||||
|
|
||||||
|
# Regular target
|
||||||
|
resolved = await _resolve_target(session, target)
|
||||||
link_data.append({
|
link_data.append({
|
||||||
"target_type": target.type,
|
**resolved,
|
||||||
"target_config": target_config,
|
|
||||||
"receivers": receivers,
|
|
||||||
"tracking_config": tracking_config,
|
"tracking_config": tracking_config,
|
||||||
"template_config": template_config,
|
"template_config": template_config,
|
||||||
"template_slots": template_slots,
|
"template_slots": template_slots,
|
||||||
|
|||||||
@@ -309,11 +309,35 @@ async def send_to_receiver(target: NotificationTarget, receiver_config: dict, me
|
|||||||
|
|
||||||
|
|
||||||
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
|
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
|
||||||
"""Send a simple test message."""
|
"""Send a simple test message. For broadcast targets, fans out to all children."""
|
||||||
|
if target.type == "broadcast":
|
||||||
|
return await _send_broadcast_test(target, locale)
|
||||||
message = _get_test_message(locale, target.type)
|
message = _get_test_message(locale, target.type)
|
||||||
return await send_to_target(target, message)
|
return await send_to_target(target, message)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_broadcast_test(target: NotificationTarget, locale: str) -> dict:
|
||||||
|
"""Send test notifications to all child targets of a broadcast target."""
|
||||||
|
child_ids = target.config.get("child_target_ids", [])
|
||||||
|
if not child_ids:
|
||||||
|
return {"success": False, "error": "No child targets configured"}
|
||||||
|
|
||||||
|
disabled_ids = set(target.config.get("disabled_child_ids", []))
|
||||||
|
engine = get_engine()
|
||||||
|
results: list[dict] = []
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
for child_id in child_ids:
|
||||||
|
if child_id in disabled_ids:
|
||||||
|
continue
|
||||||
|
child = await session.get(NotificationTarget, child_id)
|
||||||
|
if not child or child.type == "broadcast":
|
||||||
|
continue
|
||||||
|
message = _get_test_message(locale, child.type)
|
||||||
|
results.append(await send_to_target(child, message))
|
||||||
|
|
||||||
|
return _aggregate(results)
|
||||||
|
|
||||||
|
|
||||||
async def send_test_template_notification(
|
async def send_test_template_notification(
|
||||||
target: NotificationTarget, slot: str, template_str: str
|
target: NotificationTarget, slot: str, template_str: str
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ fi
|
|||||||
# Start backend
|
# Start backend
|
||||||
NOTIFY_BRIDGE_DATA_DIR=./test-data \
|
NOTIFY_BRIDGE_DATA_DIR=./test-data \
|
||||||
NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32chars \
|
NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32chars \
|
||||||
PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || command -v py 2>/dev/null)
|
PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || echo "py -3.13")
|
||||||
nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \
|
nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \
|
||||||
--host 0.0.0.0 --port 8420 > /dev/null 2>&1 &
|
--host 0.0.0.0 --port 8420 > /dev/null 2>&1 &
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user