From 0562f78b3587f8db57879f742aa8a69aa44fe1ef Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 22 Mar 2026 15:50:51 +0300 Subject: [PATCH] feat: add Scheduler provider + multi-provider UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduler provider: - Virtual provider (no external service) that emits SCHEDULED_MESSAGE events on user-defined intervals or cron expressions - Custom variables stored in tracker filters, flattened into template context - fire_count persists across triggers via tracker state - APScheduler CronTrigger support for cron-mode schedules - Default templates (EN+RU), seeded on startup Multi-provider UX fixes: - Tracking config hides Immich-specific sections (periodic, scheduled, memory, asset display) for non-Immich providers - Command config driven by provider capabilities — hides commands/settings for providers without bot commands - Template config hides empty "Scheduled Messages" group - Test menu on tracker targets is provider-aware (Immich shows all 4 test types, others show only basic) - Removed redundant Test button from tracker card - System-owned tracking configs (user_id=0) seeded for Gitea + Scheduler - Fixed ownership checks to allow system configs in tracker-target links - Capabilities cache shared across template-configs and command-configs - Command tracker bot selector uses EntitySelect instead of raw select - Sample context includes Gitea + Scheduler variables for template preview --- frontend/src/lib/grid-items.ts | 1 + frontend/src/lib/i18n/en.json | 11 ++ frontend/src/lib/i18n/ru.json | 11 ++ frontend/src/lib/stores/caches.svelte.ts | 17 ++ .../src/routes/command-configs/+page.svelte | 44 +++-- .../src/routes/command-trackers/+page.svelte | 10 +- .../routes/notification-trackers/+page.svelte | 25 ++- .../notification-trackers/TrackerForm.svelte | 87 +++++++++- frontend/src/routes/providers/+page.svelte | 2 + .../src/routes/template-configs/+page.svelte | 12 +- .../src/routes/tracking-configs/+page.svelte | 12 +- .../src/notify_bridge_core/models/events.py | 3 + .../src/notify_bridge_core/providers/base.py | 1 + .../providers/capabilities.py | 20 +++ .../providers/scheduler/__init__.py | 14 ++ .../providers/scheduler/provider.py | 157 ++++++++++++++++++ .../notify_bridge_core/templates/context.py | 10 ++ .../defaults/en/scheduled_message.jinja2 | 7 + .../templates/defaults/loader.py | 3 + .../defaults/ru/scheduled_message.jinja2 | 7 + .../api/notification_tracker_targets.py | 4 +- .../src/notify_bridge_server/api/providers.py | 5 + .../api/tracking_configs.py | 7 +- .../src/notify_bridge_server/api/webhooks.py | 2 + .../database/migrations.py | 1 + .../notify_bridge_server/database/models.py | 3 + .../server/src/notify_bridge_server/main.py | 125 ++++++++++++++ .../services/sample_context.py | 46 +++++ .../services/scheduler.py | 84 ++++++++-- .../notify_bridge_server/services/watcher.py | 13 ++ 30 files changed, 688 insertions(+), 56 deletions(-) create mode 100644 packages/core/src/notify_bridge_core/providers/scheduler/__init__.py create mode 100644 packages/core/src/notify_bridge_core/providers/scheduler/provider.py create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/scheduled_message.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/scheduled_message.jinja2 diff --git a/frontend/src/lib/grid-items.ts b/frontend/src/lib/grid-items.ts index 2ab6404..ffcc677 100644 --- a/frontend/src/lib/grid-items.ts +++ b/frontend/src/lib/grid-items.ts @@ -100,4 +100,5 @@ export const previewTargetTypeItems = (): GridItem[] => [ export const providerTypeItems = (): GridItem[] => [ { value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich') }, { value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea') }, + { value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler') }, ]; diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index e76bae3..eefe3fa 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -107,6 +107,7 @@ "checking": "Checking...", "typeImmich": "Immich", "typeGitea": "Gitea", + "typeScheduler": "Scheduler", "loadError": "Failed to load providers.", "externalDomain": "External Domain", "optional": "optional", @@ -134,6 +135,14 @@ "eventTypes": "Event Types", "notificationTargets": "Notification Targets", "scanInterval": "Scan Interval (seconds)", + "scheduleType": "Schedule", + "intervalMode": "Interval", + "cronMode": "Cron expression", + "cronExpression": "Cron expression", + "cronHint": "Standard 5-field cron: minute hour day month weekday. Example: 0 9 * * 1-5 (weekdays at 9:00)", + "customVariables": "Custom Variables", + "customVariablesHint": "Define key-value pairs available in templates as {{ key }}.", + "addVariable": "Add variable", "createTracker": "Create Tracker", "noTrackers": "No trackers yet. Add a provider first, then create a tracker.", "active": "Active", @@ -310,6 +319,7 @@ "rateSearch": "Search cooldown", "rateFind": "Find cooldown", "rateDefault": "Default cooldown", + "noCommandsForProvider": "This provider type does not support bot commands.", "syncCommands": "Sync with Telegram", "discoverChats": "Discover chats from Telegram", "clickToCopy": "Click to copy chat ID", @@ -365,6 +375,7 @@ "prMerged": "PR merged", "prCommented": "PR commented", "releasePublished": "Release published", + "scheduledMessage": "Scheduled message", "trackImages": "Track images", "trackVideos": "Track videos", "favoritesOnly": "Favorites only", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 97e0b3d..bd35f4c 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -107,6 +107,7 @@ "checking": "Проверка...", "typeImmich": "Immich", "typeGitea": "Gitea", + "typeScheduler": "Планировщик", "loadError": "Не удалось загрузить провайдеры.", "externalDomain": "Внешний домен", "optional": "необязательно", @@ -134,6 +135,14 @@ "eventTypes": "Типы событий", "notificationTargets": "Получатели уведомлений", "scanInterval": "Интервал проверки (секунды)", + "scheduleType": "Расписание", + "intervalMode": "Интервал", + "cronMode": "Cron выражение", + "cronExpression": "Cron выражение", + "cronHint": "Стандартный 5-полевой cron: минута час день месяц день_недели. Пример: 0 9 * * 1-5 (будни в 9:00)", + "customVariables": "Пользовательские переменные", + "customVariablesHint": "Определите пары ключ-значение, доступные в шаблонах как {{ ключ }}.", + "addVariable": "Добавить переменную", "createTracker": "Создать трекер", "noTrackers": "Трекеров пока нет. Сначала добавьте провайдер, затем создайте трекер.", "active": "Активен", @@ -310,6 +319,7 @@ "rateSearch": "Кулдаун поиска", "rateFind": "Кулдаун поиска файлов", "rateDefault": "Кулдаун по умолчанию", + "noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.", "syncCommands": "Синхронизировать с Telegram", "discoverChats": "Обнаружить чаты из Telegram", "clickToCopy": "Нажмите, чтобы скопировать ID чата", @@ -365,6 +375,7 @@ "prMerged": "PR влит", "prCommented": "Комментарий к PR", "releasePublished": "Релиз опубликован", + "scheduledMessage": "Запланированное сообщение", "trackImages": "Фото", "trackVideos": "Видео", "favoritesOnly": "Только избранные", diff --git a/frontend/src/lib/stores/caches.svelte.ts b/frontend/src/lib/stores/caches.svelte.ts index 84ea118..8843905 100644 --- a/frontend/src/lib/stores/caches.svelte.ts +++ b/frontend/src/lib/stores/caches.svelte.ts @@ -53,6 +53,23 @@ export const commandTemplateConfigsCache = createEntityCache('/command-trackers'); +/** Provider capabilities — used by Template Configs, Command Configs. */ +export const capabilitiesCache = (() => { + let data = $state>({}); + let fetchedAt = $state(0); + const TTL = 60_000; // 1 minute + return { + get items() { return data; }, + async fetch(force = false): Promise> { + if (!force && Object.keys(data).length > 0 && Date.now() - fetchedAt < TTL) return data; + const { api } = await import('$lib/api'); + data = await api('/providers/capabilities'); + fetchedAt = Date.now(); + return data; + }, + }; +})(); + /** * All caches keyed by entity type — for search palette and crosslink resolution. */ diff --git a/frontend/src/routes/command-configs/+page.svelte b/frontend/src/routes/command-configs/+page.svelte index 120b900..86a1fde 100644 --- a/frontend/src/routes/command-configs/+page.svelte +++ b/frontend/src/routes/command-configs/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { api } from '$lib/api'; import { t } from '$lib/i18n'; - import { commandConfigsCache, commandTemplateConfigsCache } from '$lib/stores/caches.svelte'; + import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; @@ -37,22 +37,23 @@ let submitting = $state(false); let confirmDelete = $state(null); - const allCommands = [ - { key: 'help', icon: 'mdiHelpCircle' }, - { key: 'status', icon: 'mdiChartBox' }, - { key: 'albums', icon: 'mdiImageMultiple' }, - { key: 'events', icon: 'mdiPulse' }, - { key: 'summary', icon: 'mdiFileDocumentEdit' }, - { key: 'latest', icon: 'mdiImagePlus' }, - { key: 'memory', icon: 'mdiHistory' }, - { key: 'random', icon: 'mdiDice3' }, - { key: 'search', icon: 'mdiMagnify' }, - { key: 'find', icon: 'mdiFileSearch' }, - { key: 'person', icon: 'mdiAccount' }, - { key: 'place', icon: 'mdiMapMarker' }, - { key: 'favorites', icon: 'mdiStar' }, - { key: 'people', icon: 'mdiAccountGroup' }, - ]; + // Immich command icons — used as fallback when capabilities don't specify icons + const commandIcons: Record = { + help: 'mdiHelpCircle', status: 'mdiChartBox', albums: 'mdiImageMultiple', + events: 'mdiPulse', summary: 'mdiFileDocumentEdit', latest: 'mdiImagePlus', + memory: 'mdiHistory', random: 'mdiDice3', search: 'mdiMagnify', + find: 'mdiFileSearch', person: 'mdiAccount', place: 'mdiMapMarker', + favorites: 'mdiStar', people: 'mdiAccountGroup', + }; + + let allCapabilities = $derived(capabilitiesCache.items); + let providerCommands = $derived<{key: string, icon: string}[]>( + (allCapabilities[form.provider_type]?.commands || []).map((c: any) => ({ + key: c.name, + icon: commandIcons[c.name] || 'mdiConsole', + })) + ); + let hasCommands = $derived(providerCommands.length > 0); const defaultForm = () => ({ name: '', @@ -72,6 +73,7 @@ await Promise.all([ commandConfigsCache.fetch(true), commandTemplateConfigsCache.fetch(), + capabilitiesCache.fetch(), ]); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } finally { loaded = true; highlightFromUrl(); } @@ -170,11 +172,12 @@ {/if} + {#if hasCommands}

{t('commandConfig.enabledCommands')}

- {#each allCommands as cmd} + {#each providerCommands as cmd}
+ {:else} +
+ {t('commandConfig.noCommandsForProvider')} +
+ {/if}
edit(tracker)} /> - { try { await api(`/notification-trackers/${tracker.id}/trigger`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err) { snackError((err as any).message); } }} /> toggle(tracker)} disabled={toggling[tracker.id]} /> +
+ {/each} + + + {:else}
@@ -87,6 +171,7 @@
+ {/if}