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:
@@ -105,31 +105,38 @@
|
||||
let form = $state(defaultForm());
|
||||
let previewTargetType = $state('telegram');
|
||||
|
||||
const templateSlots = [
|
||||
{ group: 'eventMessages', slots: [
|
||||
{ key: 'message_assets_added', label: 'assetsAdded', rows: 10 },
|
||||
{ key: 'message_assets_removed', label: 'assetsRemoved', rows: 3 },
|
||||
{ key: 'message_collection_renamed', label: 'albumRenamed', rows: 2 },
|
||||
{ key: 'message_collection_deleted', label: 'albumDeleted', rows: 2 },
|
||||
{ key: 'message_sharing_changed', label: 'sharingChanged', rows: 2 },
|
||||
]},
|
||||
{ group: 'scheduledMessages', slots: [
|
||||
{ key: 'periodic_summary_message', label: 'periodicSummary', rows: 6 },
|
||||
{ key: 'scheduled_assets_message', label: 'scheduledAssets', rows: 6 },
|
||||
{ key: 'memory_mode_message', label: 'memoryMode', rows: 6 },
|
||||
]},
|
||||
// Provider capabilities: loaded dynamically
|
||||
let allCapabilities = $state<Record<string, any>>({});
|
||||
let providerTypes = $derived(Object.keys(allCapabilities));
|
||||
|
||||
// Dynamic slot definitions based on selected provider_type
|
||||
let notificationSlots = $derived<{name: string, description: string}[]>(
|
||||
allCapabilities[form.provider_type]?.notification_slots || []
|
||||
);
|
||||
|
||||
// Group slots into event messages vs scheduled messages based on slot name prefix
|
||||
let templateSlots = $derived([
|
||||
{ group: 'eventMessages', slots: notificationSlots
|
||||
.filter(s => s.name.startsWith('message_'))
|
||||
.map(s => ({ key: s.name, label: s.name.replace('message_', '').replace(/_/g, ' '), description: s.description, rows: s.name === 'message_assets_added' ? 10 : 3 }))
|
||||
},
|
||||
{ group: 'scheduledMessages', slots: notificationSlots
|
||||
.filter(s => !s.name.startsWith('message_'))
|
||||
.map(s => ({ key: s.name, label: s.name.replace(/_/g, ' '), description: s.description, rows: 6 }))
|
||||
},
|
||||
{ group: 'settings', slots: [
|
||||
{ key: 'date_format', label: 'dateFormat', rows: 1, isDateFormat: true },
|
||||
{ key: 'date_only_format', label: 'dateOnlyFormat', rows: 1, isDateFormat: true },
|
||||
{ key: 'date_format', label: 'dateFormat', description: 'Date+time format', rows: 1, isDateFormat: true },
|
||||
{ key: 'date_only_format', label: 'dateOnlyFormat', description: 'Date-only format', rows: 1, isDateFormat: true },
|
||||
]},
|
||||
];
|
||||
]);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
[configs, varsRef] = await Promise.all([
|
||||
[configs, varsRef, allCapabilities] = await Promise.all([
|
||||
api('/template-configs'),
|
||||
api('/template-configs/variables'),
|
||||
api('/providers/capabilities'),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
@@ -233,6 +240,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="tpc-provider" class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</label>
|
||||
<select id="tpc-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}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="preview-target" class="text-sm font-medium">{t('templateConfig.previewAs')}:</label>
|
||||
<select id="preview-target" bind:value={previewTargetType} onchange={refreshAllPreviews}
|
||||
@@ -249,7 +273,7 @@
|
||||
{#each group.slots as slot}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-xs text-[var(--color-muted-foreground)]">{t(`templateConfig.${slot.label}`)}</label>
|
||||
<label class="text-xs text-[var(--color-muted-foreground)]">{slot.description || t(`templateConfig.${slot.label}`, slot.label)}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if varsRef[slot.key]}
|
||||
<button type="button" onclick={() => showVarsFor = slot.key}
|
||||
@@ -308,6 +332,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} 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}
|
||||
|
||||
Reference in New Issue
Block a user