diff --git a/CLAUDE.md b/CLAUDE.md index 4bebe74..1b7644e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Development Servers -**IMPORTANT**: When the user requests it OR when backend code changes are made (files in `packages/server/`), you MUST restart the standalone server using this one-liner: +**MANDATORY**: You MUST restart the backend server IMMEDIATELY after ANY backend code change (files in `packages/server/` or `packages/core/`). Do NOT wait for the user to ask — restart automatically every time. Failure to restart means the user will test against stale code and encounter bugs that don't exist. Use this one-liner: ```bash PID=$(netstat -ano 2>/dev/null | grep ':8420.*LISTENING' | awk '{print $5}' | head -1) && [ -n "$PID" ] && taskkill //F //PID $PID 2>/dev/null; sleep 1 && cd packages/server && pip install -e . 2>&1 | tail -1 && cd ../.. && NOTIFY_BRIDGE_DATA_DIR=./test-data NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32chars nohup python -m uvicorn notify_bridge_server.main:app --host 0.0.0.0 --port 8420 > /dev/null 2>&1 & sleep 3 && curl -s http://localhost:8420/api/health ``` @@ -71,3 +71,6 @@ TelegramBot → token, update_mode, bot_username (used as notification target ba 3. **`packages/server/.../api/template_configs.py`** — `get_template_variables()` endpoint (`event_vars`, `asset_fields`, `album_fields`, `scheduled_vars`, per-slot variable dicts) 4. **`packages/core/.../templates/defaults/{en,ru}/*.jinja2`** — default template files using the new variables 5. **`packages/core/.../providers/immich/provider.py`** — `IMMICH_VARIABLES` list (provider-specific variable definitions) +6. **`packages/server/.../api/command_template_configs.py`** — `get_command_variables()` endpoint (for command response templates) + +**IMPORTANT**: Variable reference endpoints MUST document child/nested properties, not only top-level variables. When a variable is a list of dicts (e.g. `assets`, `albums`, `events`, `commands`), the endpoint MUST include a corresponding `*_fields` dict describing the child properties (e.g. `asset_fields: {"id": "...", "filename": "..."}`) so the frontend can show them (e.g. `{{ asset.id }}`, `{{ album.name }}`). Never list only `"assets": "List of asset dicts"` — always specify what fields each dict contains. diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index bd94464..c135fa3 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -178,7 +178,7 @@ }, "targets": { "title": "Targets", - "description": "Notification destinations (Telegram, webhooks)", + "description": "Notification destinations (Telegram, Discord, Slack, Email, ntfy, Matrix, Webhooks)", "addTarget": "Add Target", "cancel": "Cancel", "type": "Type", @@ -203,7 +203,16 @@ "disableUrlPreview": "Disable link previews", "sendLargeAsDocuments": "Send large photos as documents", "chatAction": "Chat action", - "chatActionNone": "None (no action)" + "chatActionNone": "None (no action)", + "overrideUsername": "Override bot username", + "ntfyServer": "ntfy Server URL", + "ntfyTopic": "Topic", + "ntfyToken": "Auth Token", + "ntfyTokenPlaceholder": "Optional (for protected topics)", + "selectEmailBot": "Select Email Bot", + "selectMatrixBot": "Select Matrix Bot", + "recipientEmail": "Recipient Email", + "matrixRoomId": "Room ID" }, "users": { "title": "Users", @@ -345,6 +354,7 @@ "templateConfig": { "title": "Template Configs", "description": "Define how notification messages are formatted", + "providerType": "Service Provider Type", "newConfig": "New Config", "name": "Name", "namePlaceholder": "Default EN", @@ -479,6 +489,21 @@ "botLocale": "Language for command descriptions in Telegram's menu and bot response messages.", "rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit." }, + "matrixBot": { + "title": "Matrix Bots", + "description": "Matrix homeserver connections for room notifications", + "addBot": "Add Matrix Bot", + "name": "Name", + "namePlaceholder": "Home Server Bot", + "homeserverUrl": "Homeserver URL", + "accessToken": "Access Token", + "tokenPlaceholder": "syt_...", + "tokenUnchanged": "(unchanged)", + "displayName": "Display Name", + "testConnection": "Test connection", + "noBots": "No Matrix bots yet.", + "confirmDelete": "Delete this Matrix bot?" + }, "emailBot": { "title": "Email Bots", "description": "SMTP email senders for notifications", @@ -527,7 +552,9 @@ "defaultCooldown": "Default cooldown (s)", "noConfigs": "No command configs yet.", "confirmDelete": "Delete this command config?", - "commands": "commands" + "commands": "commands", + "responseTemplate": "Response Template", + "noTemplate": "Default (hardcoded)" }, "commandTracker": { "title": "Command Trackers", @@ -595,7 +622,11 @@ "emailBotCreated": "Email bot created", "emailBotUpdated": "Email bot updated", "emailBotDeleted": "Email bot deleted", - "emailBotTestSent": "Test email sent successfully" + "emailBotTestSent": "Test email sent successfully", + "matrixBotCreated": "Matrix bot created", + "matrixBotUpdated": "Matrix bot updated", + "matrixBotDeleted": "Matrix bot deleted", + "matrixBotTestOk": "Matrix connection verified" }, "common": { "loading": "Loading...", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 923d0ca..b55bbcf 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -178,7 +178,7 @@ }, "targets": { "title": "Получатели", - "description": "Адреса уведомлений (Telegram, вебхуки)", + "description": "Адреса уведомлений (Telegram, Discord, Slack, Email, ntfy, Matrix, вебхуки)", "addTarget": "Добавить получателя", "cancel": "Отмена", "type": "Тип", @@ -203,7 +203,16 @@ "disableUrlPreview": "Отключить превью ссылок", "sendLargeAsDocuments": "Отправлять большие фото как документы", "chatAction": "Действие в чате", - "chatActionNone": "Нет (без действия)" + "chatActionNone": "Нет (без действия)", + "overrideUsername": "Переопределить имя бота", + "ntfyServer": "URL сервера ntfy", + "ntfyTopic": "Тема", + "ntfyToken": "Токен авторизации", + "ntfyTokenPlaceholder": "Необязательно (для защищённых тем)", + "selectEmailBot": "Выберите Email бот", + "selectMatrixBot": "Выберите Matrix бот", + "recipientEmail": "Email получателя", + "matrixRoomId": "ID комнаты" }, "users": { "title": "Пользователи", @@ -345,6 +354,7 @@ "templateConfig": { "title": "Конфигурации шаблонов", "description": "Определите формат уведомлений", + "providerType": "Тип сервис-провайдера", "newConfig": "Новая конфигурация", "name": "Название", "namePlaceholder": "По умолчанию RU", @@ -479,6 +489,21 @@ "botLocale": "Язык описаний команд в меню Telegram и ответов бота.", "rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений." }, + "matrixBot": { + "title": "Matrix боты", + "description": "Подключения к Matrix серверам для уведомлений в комнаты", + "addBot": "Добавить Matrix бот", + "name": "Название", + "namePlaceholder": "Бот для дома", + "homeserverUrl": "URL сервера", + "accessToken": "Токен доступа", + "tokenPlaceholder": "syt_...", + "tokenUnchanged": "(без изменений)", + "displayName": "Отображаемое имя", + "testConnection": "Проверить подключение", + "noBots": "Matrix ботов пока нет.", + "confirmDelete": "Удалить этот Matrix бот?" + }, "emailBot": { "title": "Email боты", "description": "SMTP отправители для уведомлений по email", @@ -527,7 +552,9 @@ "defaultCooldown": "Кулдаун по умолчанию (с)", "noConfigs": "Конфигураций команд пока нет.", "confirmDelete": "Удалить эту конфигурацию команд?", - "commands": "команд" + "commands": "команд", + "responseTemplate": "Шаблон ответов", + "noTemplate": "По умолчанию (встроенный)" }, "commandTracker": { "title": "Трекеры команд", @@ -595,7 +622,11 @@ "emailBotCreated": "Email бот создан", "emailBotUpdated": "Email бот обновлён", "emailBotDeleted": "Email бот удалён", - "emailBotTestSent": "Тестовое письмо отправлено" + "emailBotTestSent": "Тестовое письмо отправлено", + "matrixBotCreated": "Matrix бот создан", + "matrixBotUpdated": "Matrix бот обновлён", + "matrixBotDeleted": "Matrix бот удалён", + "matrixBotTestOk": "Подключение к Matrix проверено" }, "common": { "loading": "Загрузка...", diff --git a/frontend/src/routes/command-configs/+page.svelte b/frontend/src/routes/command-configs/+page.svelte index 11719dc..5858963 100644 --- a/frontend/src/routes/command-configs/+page.svelte +++ b/frontend/src/routes/command-configs/+page.svelte @@ -13,6 +13,7 @@ import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; let configs = $state([]); + let cmdTemplateConfigs = $state([]); let loaded = $state(false); let showForm = $state(false); let editing = $state(null); @@ -46,13 +47,17 @@ response_mode: 'media', default_count: 5, rate_limits: { search: 30, default: 10 }, + command_template_config_id: null as number | null, }); let form = $state(defaultForm()); onMount(load); async function load() { try { - configs = await api('/command-configs'); + [configs, cmdTemplateConfigs] = await Promise.all([ + api('/command-configs'), + api('/command-template-configs'), + ]); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } finally { loaded = true; } } @@ -68,6 +73,7 @@ response_mode: cfg.response_mode || 'media', default_count: cfg.default_count || 5, rate_limits: { search: cfg.rate_limits?.search || 30, default: cfg.rate_limits?.default || 10 }, + command_template_config_id: cfg.command_template_config_id || null, }; editing = cfg.id; showForm = true; @@ -157,6 +163,17 @@ +
+ + +
+
diff --git a/frontend/src/routes/command-template-configs/+page.svelte b/frontend/src/routes/command-template-configs/+page.svelte index 51f5aa6..7e46c20 100644 --- a/frontend/src/routes/command-template-configs/+page.svelte +++ b/frontend/src/routes/command-template-configs/+page.svelte @@ -42,9 +42,15 @@ let slotErrorLines = $state>({}); let slotErrorTypes = $state>({}); let validateTimers: Record> = {}; + let varsRef = $state>({}); + let showVarsFor = $state(null); - // Load command slot definitions from capabilities - let commandSlots = $state([]); + // Provider capabilities + let allCapabilities = $state>({}); + let providerTypes = $derived(Object.keys(allCapabilities)); + let commandSlots = $derived( + 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)]" />
+ {#if !editing} +
+ + +
+ {:else} +
+ {t('templateConfig.providerType')} + {allCapabilities[form.provider_type]?.display_name || form.provider_type} +
+ {/if} +
{t('cmdTemplateConfig.commandResponses')}

{t('cmdTemplateConfig.commandResponsesHint')}

@@ -225,8 +250,11 @@ {#each commandSlots as slot}
- - {slot.description} + + {#if varsRef[slot.name]} + + {/if}

{config.name}

+ {config.provider_type} {#if config.user_id === 0} System {/if} @@ -296,3 +325,37 @@ confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> + + + showVarsFor = null}> + {#if showVarsFor && varsRef[showVarsFor]} +

{varsRef[showVarsFor].description}

+
+

{t('templateConfig.variables')}:

+ {#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]} +
+ {'{{ ' + name + ' }}'} + {desc} +
+ {/each} +
+ {#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]} +
+

{title} (use {prefix}.field):

+ {#each Object.entries(varsRef[showVarsFor][fieldKey]) as [name, desc]} +
+ {'{{ ' + prefix + '.' + name + ' }}'} + {desc} +
+ {/each} +
+ {/if} + {/each} + {/if} +
diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index 2fd5e43..75ee776 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -13,17 +13,35 @@ import Hint from '$lib/components/Hint.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; - import type { NotificationTarget, TelegramBot, TelegramChat } from '$lib/types'; + import type { NotificationTarget, TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types'; + + const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix'] as const; + type TargetType = typeof ALL_TYPES[number]; + const TYPE_ICONS: Record = { + telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline', + discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix', + }; let targets = $state([]); let bots = $state([]); + let emailBots = $state([]); + let matrixBots = $state([]); let botChats = $state>({}); let showForm = $state(false); let editing = $state(null); - let formType = $state<'telegram' | 'webhook'>('telegram'); + let formType = $state('telegram'); const defaultForm = () => ({ name: '', icon: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '', max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50, - disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing' }); + disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing', + // Discord/Slack + webhook_url: '', username: '', + // ntfy + server_url: 'https://ntfy.sh', topic: '', auth_token: '', priority: 3, + // Matrix + matrix_bot_id: 0, room_id: '', + // Email + email_bot_id: 0, email: '', + }); let form = $state(defaultForm()); let error = $state(''); let headersError = $state(''); @@ -36,7 +54,9 @@ onMount(load); async function load() { try { - [targets, bots] = await Promise.all([api('/targets'), api('/telegram-bots')]); + [targets, bots, emailBots, matrixBots] = await Promise.all([ + api('/targets'), api('/telegram-bots'), api('/email-bots'), api('/matrix-bots'), + ]); loadError = ''; } catch (err: any) { loadError = err.message || t('common.loadError'); snackError(loadError); } finally { loaded = true; } } @@ -51,11 +71,24 @@ formType = tgt.type; const c = tgt.config || {}; form = { - name: tgt.name, icon: tgt.icon || '', bot_id: c.bot_id || 0, bot_token: '', chat_id: c.chat_id || '', url: c.url || '', headers: '', + name: tgt.name, icon: tgt.icon || '', + // telegram + bot_id: c.bot_id || 0, bot_token: '', chat_id: c.chat_id || '', max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10, media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50, disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false, ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing', + // webhook + url: c.url || '', headers: '', + // discord/slack + webhook_url: c.webhook_url || '', username: c.username || '', + // ntfy + server_url: c.server_url || 'https://ntfy.sh', topic: c.topic || '', + auth_token: c.auth_token || '', priority: c.priority ?? 3, + // email + email_bot_id: c.email_bot_id || 0, email: c.email || '', + // matrix + matrix_bot_id: c.matrix_bot_id || 0, room_id: c.room_id || '', }; editing = tgt.id; showTelegramSettings = false; showForm = true; if (form.bot_id) await loadBotChats(); @@ -66,24 +99,36 @@ if (submitting) return; submitting = true; try { - let botToken = form.bot_token; - if (formType === 'telegram' && form.bot_id && !botToken) { - const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`); - botToken = tokenRes.token; - } - let parsedHeaders = {}; - if (formType === 'webhook' && form.headers) { - try { parsedHeaders = JSON.parse(form.headers); } - catch { headersError = t('common.headersInvalid'); return; } - } - const config = formType === 'telegram' - ? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id, + let config: Record = {}; + + if (formType === 'telegram') { + let botToken = form.bot_token; + if (form.bot_id && !botToken) { + const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`); + botToken = tokenRes.token; + } + config = { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id, bot_id: form.bot_id || undefined, max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group, media_delay: form.media_delay, max_asset_size: form.max_asset_size, disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents, - ai_captions: form.ai_captions, chat_action: form.chat_action || undefined } - : { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions }; + ai_captions: form.ai_captions, chat_action: form.chat_action || undefined }; + } else if (formType === 'webhook') { + let parsedHeaders = {}; + if (form.headers) { + try { parsedHeaders = JSON.parse(form.headers); } + catch { headersError = t('common.headersInvalid'); return; } + } + config = { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions }; + } else if (formType === 'discord' || formType === 'slack') { + config = { webhook_url: form.webhook_url, username: form.username || undefined }; + } else if (formType === 'ntfy') { + config = { server_url: form.server_url, topic: form.topic, auth_token: form.auth_token || undefined }; + } else if (formType === 'email') { + config = { email_bot_id: form.email_bot_id, email: form.email }; + } else if (formType === 'matrix') { + config = { matrix_bot_id: form.matrix_bot_id, room_id: form.room_id }; + } if (editing) { await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) }); } else { @@ -126,11 +171,13 @@ {#if error}
{error}
{/if}
- {t('targets.type')} -
- - -
+ +
@@ -216,7 +263,7 @@
{/if}
- {:else} + {:else if formType === 'webhook'}
@@ -226,9 +273,72 @@ {#if headersError}

{headersError}

{/if}
+ {:else if formType === 'discord' || formType === 'slack'} +
+ + +
+
+ + +
+ {:else if formType === 'ntfy'} +
+ + +
+
+ + +
+
+ + +
+ {:else if formType === 'email'} +
+ + + {#if emailBots.length === 0} +

{t('emailBot.noBots')}

+ {/if} +
+
+ + +
+ {:else if formType === 'matrix'} +
+ + + {#if matrixBots.length === 0} +

{t('matrixBot.noBots')}

+ {/if} +
+
+ + +
{/if} - + {#if formType === 'telegram'} + + {/if} @@ -247,9 +357,10 @@
- +

{target.name}

{target.type} + {#if target.receiver_count}{target.receiver_count} receiver(s){/if}

{#if target.type === 'telegram'} @@ -257,8 +368,16 @@ {#if target.config?.chat_action} {target.config.chat_action} {/if} - {:else} + {:else if target.type === 'webhook'} {target.config?.url || ''} + {:else if target.type === 'discord' || target.type === 'slack'} + {target.config?.webhook_url ? target.config.webhook_url.substring(0, 50) + '...' : ''} + {:else if target.type === 'ntfy'} + {target.config?.server_url || 'ntfy.sh'} / {target.config?.topic || ''} + {:else if target.type === 'email'} + {target.config?.email || ''} + {:else if target.type === 'matrix'} + {target.config?.room_id || ''} {/if}

diff --git a/frontend/src/routes/telegram-bots/+page.svelte b/frontend/src/routes/telegram-bots/+page.svelte index 8b7fa8a..52cc0a2 100644 --- a/frontend/src/routes/telegram-bots/+page.svelte +++ b/frontend/src/routes/telegram-bots/+page.svelte @@ -12,10 +12,11 @@ import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte'; - import type { TelegramBot, TelegramChat, EmailBot } from '$lib/types'; + import type { TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types'; let bots = $state([]); let emailBots = $state([]); + let matrixBots = $state([]); let loaded = $state(false); let showForm = $state(false); let editing = $state(null); @@ -38,10 +39,11 @@ onMount(load); async function load() { try { - [bots, settings, emailBots] = await Promise.all([ + [bots, settings, emailBots, matrixBots] = await Promise.all([ api('/telegram-bots'), api('/settings'), api('/email-bots'), + api('/matrix-bots'), ]); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } finally { loaded = true; } @@ -278,6 +280,65 @@ } catch (err: any) { snackError(err.message); } emailTesting = { ...emailTesting, [botId]: false }; } + + // --- Matrix Bot state --- + let showMatrixForm = $state(false); + let editingMatrix = $state(null); + let matrixSubmitting = $state(false); + let matrixTesting = $state>({}); + let confirmDeleteMatrix = $state(null); + const defaultMatrixForm = () => ({ + name: '', icon: '', homeserver_url: '', access_token: '', display_name: '', + }); + let matrixForm = $state(defaultMatrixForm()); + + function openNewMatrix() { matrixForm = defaultMatrixForm(); editingMatrix = null; showMatrixForm = true; } + function editMatrixBot(bot: MatrixBot) { + matrixForm = { + name: bot.name, icon: bot.icon || '', + homeserver_url: bot.homeserver_url, access_token: '', + display_name: bot.display_name || '', + }; + editingMatrix = bot.id; showMatrixForm = true; + } + + async function saveMatrixBot(e: SubmitEvent) { + e.preventDefault(); error = ''; matrixSubmitting = true; + try { + const body = { ...matrixForm }; + if (editingMatrix && !body.access_token) delete (body as any).access_token; + if (editingMatrix) { + await api(`/matrix-bots/${editingMatrix}`, { method: 'PUT', body: JSON.stringify(body) }); + snackSuccess(t('snack.matrixBotUpdated')); + } else { + await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) }); + snackSuccess(t('snack.matrixBotCreated')); + } + matrixForm = defaultMatrixForm(); showMatrixForm = false; editingMatrix = null; await load(); + } catch (err: any) { error = err.message; snackError(err.message); } + finally { matrixSubmitting = false; } + } + + function removeMatrix(id: number) { + confirmDeleteMatrix = { + id, + onconfirm: async () => { + try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.matrixBotDeleted')); } + catch (err: any) { error = err.message; snackError(err.message); } + finally { confirmDeleteMatrix = null; } + } + }; + } + + async function testMatrixBot(botId: number) { + matrixTesting = { ...matrixTesting, [botId]: true }; + try { + const res = await api(`/matrix-bots/${botId}/test`, { method: 'POST' }); + if (res.success) snackSuccess(t('snack.matrixBotTestOk')); + else snackError(res.error || 'Failed'); + } catch (err: any) { snackError(err.message); } + matrixTesting = { ...matrixTesting, [botId]: false }; + } @@ -601,3 +662,85 @@ confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} /> + + +
+ + + + +{#if showMatrixForm} + + {#if error}
{error}
{/if} +
+
+ +
+ matrixForm.icon = v} /> + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{/if} + +{#if matrixBots.length === 0 && !showMatrixForm} + + + +{:else} +
+ {#each matrixBots as bot} + +
+
+
+ +

{bot.name}

+
+
+ {bot.homeserver_url} + {#if bot.display_name} + {bot.display_name} + {/if} +
+
+
+ testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} /> + editMatrixBot(bot)} /> + removeMatrix(bot.id)} variant="danger" /> +
+
+
+ {/each} +
+{/if} +
+ + confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} /> diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index 244a0dd..9263197 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -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>({}); + 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)]" />
+ {#if !editing} +
+ + +
+ {:else} +
+ {t('templateConfig.providerType')} + {allCapabilities[form.provider_type]?.display_name || form.provider_type} +
+ {/if} +