feat: Discord/Slack/ntfy/Matrix targets, command templates, delete protection, email/matrix bots

- Discord, Slack, ntfy, Matrix notification target types with clients and dispatch
- MatrixBot model + API + frontend in Bots tab
- Command template system fully wired into all handler commands
- Default command templates seeded (EN/RU, 14 slots each)
- Command template editor with variables reference including child fields
- Delete protection on all 10 entity types (409 with consumer details)
- Provider type selector on template config forms
- Target type selector as dropdown with all 7 types
- Response template selector on command config form
- CLAUDE.md: mandatory server restart rule, child properties rule
This commit is contained in:
2026-03-21 20:36:12 +03:00
parent 846d480d38
commit 3e3a6f0777
64 changed files with 1861 additions and 180 deletions
@@ -42,9 +42,15 @@
let slotErrorLines = $state<Record<string, number | null>>({});
let slotErrorTypes = $state<Record<string, string>>({});
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
// Load command slot definitions from capabilities
let commandSlots = $state<SlotDef[]>([]);
// Provider capabilities
let allCapabilities = $state<Record<string, any>>({});
let providerTypes = $derived(Object.keys(allCapabilities));
let commandSlots = $derived<SlotDef[]>(
allCapabilities[form.provider_type]?.command_slots || []
);
const defaultForm = () => ({
provider_type: 'immich',
@@ -59,12 +65,14 @@
async function load() {
try {
const [cfgs, caps] = await Promise.all([
const [cfgs, caps, vars] = await Promise.all([
api('/command-template-configs'),
api('/providers/capabilities/immich'),
api('/providers/capabilities'),
api('/command-template-configs/variables'),
]);
configs = cfgs;
commandSlots = caps.command_slots || [];
allCapabilities = caps;
varsRef = vars;
} catch (err: any) {
error = err.message || t('common.loadError');
snackError(error);
@@ -218,6 +226,23 @@
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if !editing}
<div>
<label for="ct-provider" class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</label>
<select id="ct-provider" bind:value={form.provider_type}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
{#each providerTypes as pt}
<option value={pt}>{allCapabilities[pt]?.display_name || pt} ({pt})</option>
{/each}
</select>
</div>
{:else}
<div>
<span class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</span>
<span class="text-sm text-[var(--color-muted-foreground)]">{allCapabilities[form.provider_type]?.display_name || form.provider_type}</span>
</div>
{/if}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
<p class="text-xs text-[var(--color-muted-foreground)] mb-3">{t('cmdTemplateConfig.commandResponsesHint')}</p>
@@ -225,8 +250,11 @@
{#each commandSlots as slot}
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs text-[var(--color-muted-foreground)]">/{slot.name}</label>
<span class="text-xs text-[var(--color-muted-foreground)]">{slot.description}</span>
<label class="text-xs text-[var(--color-muted-foreground)]">/{slot.name}{slot.description}</label>
{#if varsRef[slot.name]}
<button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
</div>
<JinjaEditor
value={form.slots[slot.name] || ''}
@@ -272,6 +300,7 @@
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">System</span>
{/if}
@@ -296,3 +325,37 @@
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{varsRef[showVarsFor].description}</p>
<div class="space-y-1">
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
</div>
{/each}
</div>
{#each [
['asset_fields', 'asset', 'Asset fields'],
['album_fields', 'album', 'Album fields'],
['command_fields', 'cmd', 'Command fields'],
['event_fields', 'event', 'Event fields'],
] as [fieldKey, prefix, title]}
{#if varsRef[showVarsFor][fieldKey]}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p>
{#each Object.entries(varsRef[showVarsFor][fieldKey]) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
</div>
{/each}
</div>
{/if}
{/each}
{/if}
</Modal>