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:
2026-03-24 15:15:41 +03:00
parent 8cb836e16c
commit d8ecb60073
13 changed files with 327 additions and 102 deletions
+2 -1
View File
@@ -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
- 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()`
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`.
+8
View File
@@ -34,6 +34,7 @@
"targetSlack": "Slack",
"targetNtfy": "ntfy",
"targetMatrix": "Matrix",
"targetBroadcast": "Broadcast",
"automation": "Automation",
"actions": "Actions"
},
@@ -256,6 +257,11 @@
"descSlack": "Slack channel webhooks for notifications",
"descNtfy": "ntfy push notification topics",
"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",
"cancel": "Cancel",
"type": "Type",
@@ -814,6 +820,8 @@
"syntaxError": "Syntax error",
"undefinedVar": "Unknown variable",
"line": "line",
"enable": "Enable",
"disable": "Disable",
"add": "Add",
"filterByName": "Filter by name...",
"allTypes": "All types",
+8
View File
@@ -34,6 +34,7 @@
"targetSlack": "Slack",
"targetNtfy": "ntfy",
"targetMatrix": "Matrix",
"targetBroadcast": "Рассылка",
"automation": "Автоматизация",
"actions": "Действия"
},
@@ -256,6 +257,11 @@
"descSlack": "Вебхуки каналов Slack для уведомлений",
"descNtfy": "Топики ntfy для push-уведомлений",
"descMatrix": "Комнаты Matrix для доставки уведомлений",
"descBroadcast": "Отправка сразу в несколько целей",
"childTargets": "цель(ей)",
"selectChildTargets": "Выберите дочерние цели",
"noChildTargets": "Дочерние цели не настроены.",
"noChildTargetsAvailable": "Сначала создайте другие цели, затем добавьте их сюда.",
"addTarget": "Добавить получателя",
"cancel": "Отмена",
"type": "Тип",
@@ -814,6 +820,8 @@
"syntaxError": "Ошибка синтаксиса",
"undefinedVar": "Неизвестная переменная",
"line": "строка",
"enable": "Включить",
"disable": "Выключить",
"add": "Добавить",
"filterByName": "Фильтр по имени...",
"allTypes": "Все типы",
+2
View File
@@ -106,6 +106,8 @@ export interface NotificationTarget {
chat_name?: string;
receiver_count: number;
receivers: TargetReceiver[];
/** Broadcast targets only: resolved child target summaries. */
child_targets?: { id: number; name: string; type: string; icon: string }[];
created_at: string;
}
+2
View File
@@ -104,6 +104,7 @@
targets_slack: targets.filter(t => t.type === 'slack').length,
targets_ntfy: targets.filter(t => t.type === 'ntfy').length,
targets_matrix: targets.filter(t => t.type === 'matrix').length,
targets_broadcast: targets.filter(t => t.type === 'broadcast').length,
} as Record<string, number>;
});
@@ -169,6 +170,7 @@
{ 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=matrix', key: 'nav.targetMatrix', icon: 'mdiMatrix', countKey: 'targets_matrix' },
{ href: '/targets?type=broadcast', key: 'nav.targetBroadcast', icon: 'mdiBullhorn', countKey: 'targets_broadcast' },
],
},
];
+2 -1
View File
@@ -250,7 +250,7 @@
</div>
<div>
<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}
</p>
</div>
@@ -362,6 +362,7 @@
.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-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); }
.event-timeline { display: flex; flex-direction: column; }
.event-item { display: flex; align-items: flex-start; gap: 1rem; position: relative; padding-bottom: 0.75rem; }
+41 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { page } from '$app/state';
import { api } from '$lib/api';
import { t, getLocale } from '$lib/i18n';
@@ -70,15 +70,17 @@
// ── 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];
const TYPE_ICONS: Record<string, string> = {
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix',
broadcast: 'mdiBullhorn',
};
const TYPE_DESC_KEYS: Record<string, string> = {
telegram: 'targets.descTelegram', webhook: 'targets.descWebhook', email: 'targets.descEmail',
discord: 'targets.descDiscord', slack: 'targets.descSlack', ntfy: 'targets.descNtfy', matrix: 'targets.descMatrix',
broadcast: 'targets.descBroadcast',
};
const typeGridItems = $derived(ALL_TYPES.map(tt => ({
@@ -120,6 +122,8 @@
matrix_bot_id: 0,
// Email
email_bot_id: 0,
// Broadcast
child_target_ids: [] as number[],
});
let form = $state(defaultForm());
let error = $state('');
@@ -128,6 +132,12 @@
let loadError = $state('');
let showTelegramSettings = $state(false);
let confirmDelete = $state<NotificationTarget | null>(null);
let formEl: HTMLElement;
async function scrollToForm() {
await tick();
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── Receiver inline form state ──
@@ -185,6 +195,7 @@
editing = null;
showTelegramSettings = false;
showForm = true;
scrollToForm();
}
async function edit(tgt: NotificationTarget) {
@@ -207,10 +218,13 @@
email_bot_id: c.email_bot_id || 0,
// matrix
matrix_bot_id: c.matrix_bot_id || 0,
// broadcast
child_target_ids: c.child_target_ids || [],
};
editing = tgt.id;
showTelegramSettings = false;
showForm = true;
scrollToForm();
}
async function save(e: SubmitEvent) {
@@ -245,6 +259,8 @@
config = { email_bot_id: form.email_bot_id };
} else if (formType === 'matrix') {
config = { matrix_bot_id: form.matrix_bot_id };
} else if (formType === 'broadcast') {
config = { child_target_ids: form.child_target_ids };
}
if (editing) {
@@ -367,6 +383,21 @@
} 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) {
receiverTesting = { ...receiverTesting, [receiverId]: true };
try {
@@ -392,6 +423,7 @@
{/if}
{#if showForm}
<div bind:this={formEl}></div>
<TargetForm
bind:form
bind:formType
@@ -404,6 +436,7 @@
telegramBotCount={telegramBots.length}
emailBotCount={emailBots.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}
{submitting}
{error}
@@ -439,7 +472,11 @@
<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>
{#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}
</div>
</div>
@@ -469,6 +506,7 @@
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
ontoggleBroadcastChild={toggleBroadcastChild}
/>
</Card>
{/each}
@@ -24,6 +24,7 @@
ontestReceiver: (targetId: number, receiverId: number) => void;
onloadBotChats: (botId: number) => void;
onchangeReceiverForm: (form: Record<string, any>) => void;
ontoggleBroadcastChild?: (targetId: number, childId: number) => void;
}
let {
@@ -44,10 +45,37 @@
ontestReceiver,
onloadBotChats,
onchangeReceiverForm,
ontoggleBroadcastChild,
}: Props = $props();
</script>
<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">
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.receivers')}</p>
</div>
@@ -153,4 +181,5 @@
{t('targets.addReceiver')}
</button>
{/if}
{/if}
</div>
@@ -6,6 +6,7 @@
import Hint from '$lib/components/Hint.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.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 { GridItem } from '$lib/components/IconGridSelect.svelte';
@@ -28,6 +29,7 @@
auth_token: string;
matrix_bot_id: number;
email_bot_id: number;
child_target_ids: number[];
};
formType: string;
activeType: string | null;
@@ -36,6 +38,7 @@
emailBotItems: EntityItem[];
matrixBotItems: EntityItem[];
chatActionItems: GridItem[];
broadcastChildItems?: { value: number; label: string; icon: string; desc: string }[];
telegramBotCount: number;
emailBotCount: number;
matrixBotCount: number;
@@ -56,6 +59,7 @@
emailBotItems,
matrixBotItems,
chatActionItems,
broadcastChildItems = [],
telegramBotCount,
emailBotCount,
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>
{/if}
</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 formType === 'telegram'}
@@ -111,8 +111,11 @@ async def list_targets(
if 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 [
_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
]
@@ -124,15 +127,24 @@ async def create_target(
session: AsyncSession = Depends(get_session),
):
"""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:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Type must be one of: {', '.join(valid_types)}",
)
# Extract delivery fields from config — they go into a TargetReceiver
clean_config, receiver_cfg = _extract_delivery_fields(body.type, body.config)
# Broadcast-specific validation
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(
user_id=user.id,
@@ -190,34 +202,46 @@ async def update_target(
# If config is being updated, extract any delivery fields
if "config" in updates and updates["config"] is not None:
clean_config, receiver_cfg = _extract_delivery_fields(target.type, updates["config"])
updates["config"] = clean_config
if target.type == "broadcast":
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
if receiver_cfg:
key = _receiver_key(target.type, receiver_cfg)
existing_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.receiver_key == key,
# Update or create receiver if delivery fields were present
if receiver_cfg:
key = _receiver_key(target.type, receiver_cfg)
existing_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.receiver_key == key,
)
)
)
existing_recv = existing_result.first()
if existing_recv:
existing_recv.config = receiver_cfg
session.add(existing_recv)
else:
receiver = TargetReceiver(
target_id=target.id,
name=target.name,
config=receiver_cfg,
receiver_key=key,
enabled=True,
)
session.add(receiver)
existing_recv = existing_result.first()
if existing_recv:
existing_recv.config = receiver_cfg
session.add(existing_recv)
else:
receiver = TargetReceiver(
target_id=target.id,
name=target.name,
config=receiver_cfg,
receiver_key=key,
enabled=True,
)
session.add(receiver)
for field_name, value in updates.items():
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)
await session.commit()
await session.refresh(target)
@@ -257,11 +281,37 @@ async def test_target(
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(
target: NotificationTarget,
chat_names: dict[str, str] | None = None,
receivers: list[TargetReceiver] | None = None,
chat_languages: dict[str, str] | None = None,
target_map: dict[int, NotificationTarget] | None = None,
) -> dict:
recv_list = receivers or []
resp = {
@@ -295,6 +345,14 @@ def _target_response(
recv_resp["chat_name"] = chat_names[key]
if chat_languages and key in chat_languages:
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
@@ -96,6 +96,88 @@ def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
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(
session: AsyncSession,
tracker_id: int,
@@ -127,45 +209,7 @@ async def load_link_data(
if not target:
continue
# 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))
# Load tracking config and template slots (shared across broadcast children)
tracking_config = None
if 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
template_slots = raw_slots
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
# Broadcast target: expand into child targets
if target.type == "broadcast":
child_ids = target.config.get("child_target_ids", [])
disabled_ids = set(target.config.get("disabled_child_ids", []))
for child_id in child_ids:
if child_id in disabled_ids:
continue
child_target = await session.get(NotificationTarget, child_id)
if not child_target or child_target.type == "broadcast":
continue
resolved = await _resolve_target(session, child_target)
link_data.append({
**resolved,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
})
continue
# Regular target
resolved = await _resolve_target(session, target)
link_data.append({
"target_type": target.type,
"target_config": target_config,
"receivers": receivers,
**resolved,
"tracking_config": tracking_config,
"template_config": template_config,
"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:
"""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)
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(
target: NotificationTarget, slot: str, template_str: str
) -> dict:
+1 -1
View File
@@ -15,7 +15,7 @@ fi
# Start backend
NOTIFY_BRIDGE_DATA_DIR=./test-data \
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 \
--host 0.0.0.0 --port 8420 > /dev/null 2>&1 &