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

Major architectural improvements:
- Provider-type enforcement: configs validated against provider type at assignment
- TemplateConfig migrated to slot-based pattern (TemplateSlot child table)
- Broadcast targets: TargetReceiver child table for multi-receiver dispatch
- EmailBot: first-class email sender entity with SMTP config, test connection
- CommandTemplateConfig: generic slot-based command response templates
- Provider capability registry: dynamic slot/event/command definitions per provider
- CommandTracker play/pause button matches NotificationTracker style
This commit is contained in:
2026-03-21 16:33:24 +03:00
parent 371ea70756
commit 846d480d38
27 changed files with 2355 additions and 205 deletions
@@ -39,8 +39,8 @@
const res = await api('/template-configs/preview-date-format', {
method: 'POST',
body: JSON.stringify({
date_format: (form as any).date_format,
date_only_format: (form as any).date_only_format,
date_format: form.date_format,
date_only_format: form.date_only_format,
}),
});
dateFormatPreview = res;
@@ -63,7 +63,7 @@
const doValidate = async () => {
try {
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType, date_format: (form as any).date_format, date_only_format: (form as any).date_only_format }) });
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType, date_format: form.date_format, date_only_format: form.date_only_format }) });
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
@@ -86,8 +86,9 @@
function refreshAllPreviews() {
for (const group of templateSlots) {
for (const slot of group.slots) {
const template = (form as any)[slot.key];
if (template && slot.key !== 'date_format' && slot.key !== 'date_only_format') {
if (slot.isDateFormat) continue;
const template = form.slots[slot.key] || '';
if (template) {
validateSlot(slot.key, template, true);
}
}
@@ -97,14 +98,7 @@
const defaultForm = () => ({
provider_type: 'immich', name: '', description: '', icon: '',
message_assets_added: '',
message_assets_removed: '',
message_collection_renamed: '',
message_collection_deleted: '',
message_sharing_changed: '',
periodic_summary_message: '',
scheduled_assets_message: '',
memory_mode_message: '',
slots: {} as Record<string, string>,
date_format: '%d.%m.%Y, %H:%M UTC',
date_only_format: '%d.%m.%Y',
});
@@ -125,8 +119,8 @@
{ key: 'memory_mode_message', label: 'memoryMode', rows: 6 },
]},
{ group: 'settings', slots: [
{ key: 'date_format', label: 'dateFormat', rows: 1 },
{ key: 'date_only_format', label: 'dateOnlyFormat', rows: 1 },
{ key: 'date_format', label: 'dateFormat', rows: 1, isDateFormat: true },
{ key: 'date_only_format', label: 'dateOnlyFormat', rows: 1, isDateFormat: true },
]},
];
@@ -142,8 +136,17 @@
}
function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); }
function edit(c: any) {
form = { ...defaultForm(), ...c }; editing = c.id; showForm = true;
function edit(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
name: c.name,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = c.id; showForm = true;
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
setTimeout(() => refreshAllPreviews(), 100);
}
@@ -158,11 +161,16 @@
} catch (err: any) { error = err.message; snackError(err.message); }
}
function clone(c: any) {
form = { ...defaultForm(), ...c, name: `${c.name} (Copy)`, description: c.description || '' };
delete (form as any).id;
delete (form as any).user_id;
delete (form as any).created_at;
function clone(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
name: `${c.name} (Copy)`,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = null;
showForm = true;
slotPreview = {};
@@ -249,9 +257,9 @@
{/if}
</div>
</div>
{#if slot.key === 'date_format' || slot.key === 'date_only_format'}
<input bind:value={(form as any)[slot.key]}
oninput={() => { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }}
{#if slot.isDateFormat}
<input value={(form as any)[slot.key]}
oninput={(e: Event) => { (form as any)[slot.key] = (e.target as HTMLInputElement).value; clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{#if dateFormatPreview[slot.key]}
<p class="mt-1 text-xs font-mono" style="color: var(--color-muted-foreground);">{t('templateConfig.preview')}: <span style="color: var(--color-foreground);">{dateFormatPreview[slot.key]}</span></p>
@@ -259,7 +267,7 @@
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
{/if}
{:else}
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v: string) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
<JinjaEditor value={form.slots[slot.key] || ''} onchange={(v: string) => { form.slots[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
{#if slotErrors[slot.key]}
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>