feat: entity relationship refactor — notification trackers, command system, chat actions
Rework entity schema: rename Tracker→NotificationTracker, add CommandConfig/ CommandTracker/CommandTrackerListener entities for decoupled command handling. Commands now resolve through CommandTracker→CommandConfig instead of TelegramBot.commands_config. Smart ref-counted bot polling based on active listeners. Add chat_action to telegram targets. Full frontend CRUD pages for command configs and command trackers. Idempotent SQLite migrations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,19 +42,25 @@ Default test account: username `admin`, password `admin1`.
|
||||
- **Environment vars**: `NOTIFY_BRIDGE_DATA_DIR`, `NOTIFY_BRIDGE_SECRET_KEY`, `NOTIFY_BRIDGE_DATABASE_URL`
|
||||
- Core package includes `jinja2` dependency (template rendering lives in core, not server).
|
||||
|
||||
## Entity Relationships (Phase 6)
|
||||
## Entity Relationships
|
||||
|
||||
```
|
||||
ServiceProvider → type: "immich", config: JSON (url, api_key, external_domain)
|
||||
Tracker → provider_id, tracking_config_id, target_ids: JSON list, collection_ids: JSON list
|
||||
TrackingConfig → provider_type (must match provider), event flags, scheduling
|
||||
TemplateConfig → provider_type (must match provider), Jinja2 slots per event type
|
||||
NotificationTarget → template_config_id, type: "telegram"/"webhook", config: JSON
|
||||
ServiceProvider → type: "immich" (inferred capabilities: notifications, commands)
|
||||
NotificationTracker → provider_id, collection_ids, scan_interval, batch_duration, enabled
|
||||
NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled
|
||||
TrackingConfig → provider_type, event flags, scheduling rules
|
||||
TemplateConfig → provider_type, Jinja2 template slots per event type
|
||||
NotificationTarget → type: "telegram"/"webhook", config JSON, chat_action (telegram only)
|
||||
CommandConfig → provider_type, enabled_commands, locale, response_mode, default_count, rate_limits
|
||||
CommandTracker → provider_id, command_config_id, enabled
|
||||
CommandTrackerListener → command_tracker_id, listener_type ("telegram_bot"), listener_id
|
||||
TelegramBot → token, update_mode, bot_username (used as notification target backend + commands listener)
|
||||
```
|
||||
|
||||
- TrackingConfig owned by Tracker (what to watch), TemplateConfig owned by Target (how to format)
|
||||
- NotificationTrackerTarget links a tracker to a target with per-link tracking/template config and quiet hours
|
||||
- CommandTrackerListener links a command tracker to a listener (e.g. TelegramBot) for slash-command handling
|
||||
- `user_id=0` on TemplateConfig = system default (EN/RU seeded on first startup)
|
||||
- DB: SQLite + async SQLAlchemy via sqlmodel, auto-created on startup
|
||||
- DB: SQLite + async SQLAlchemy via sqlmodel, auto-created on startup with migrations
|
||||
- API: All CRUD routes under `/api/`, auth via JWT Bearer, `NOTIFY_BRIDGE_` env prefix
|
||||
|
||||
## Template System Sync Rules
|
||||
|
||||
Generated
-17
@@ -19,7 +19,6 @@
|
||||
"codemirror": "^6.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
@@ -1082,15 +1081,6 @@
|
||||
"acorn": "^8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/adapter-auto": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz",
|
||||
"integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"@sveltejs/kit": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/adapter-static": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
|
||||
@@ -3186,13 +3176,6 @@
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@sveltejs/adapter-auto": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz",
|
||||
"integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@sveltejs/adapter-static": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Providers",
|
||||
"trackers": "Trackers",
|
||||
"notificationTrackers": "Notif. Trackers",
|
||||
"trackingConfigs": "Tracking",
|
||||
"templateConfigs": "Templates",
|
||||
"telegramBots": "Bots",
|
||||
"targets": "Targets",
|
||||
"commandConfigs": "Cmd Configs",
|
||||
"commandTrackers": "Cmd Trackers",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout"
|
||||
@@ -93,8 +95,8 @@
|
||||
"testAndSave": "Test & Save",
|
||||
"saveWithoutTest": "Save without testing"
|
||||
},
|
||||
"trackers": {
|
||||
"title": "Trackers",
|
||||
"notificationTracker": {
|
||||
"title": "Notification Trackers",
|
||||
"description": "Monitor albums for changes",
|
||||
"newTracker": "New Tracker",
|
||||
"cancel": "Cancel",
|
||||
@@ -198,7 +200,9 @@
|
||||
"maxAssetSize": "Max asset size (MB)",
|
||||
"videoWarning": "Video size warning",
|
||||
"disableUrlPreview": "Disable link previews",
|
||||
"sendLargeAsDocuments": "Send large photos as documents"
|
||||
"sendLargeAsDocuments": "Send large photos as documents",
|
||||
"chatAction": "Chat action",
|
||||
"chatActionNone": "None (no action)"
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
@@ -474,6 +478,47 @@
|
||||
"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."
|
||||
},
|
||||
"commandConfig": {
|
||||
"title": "Command Configs",
|
||||
"description": "Define command settings for Telegram bot interactions",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Default commands",
|
||||
"providerType": "Provider Type",
|
||||
"enabledCommands": "Enabled Commands",
|
||||
"locale": "Locale",
|
||||
"responseMode": "Response Mode",
|
||||
"modeMedia": "Media (photos)",
|
||||
"modeText": "Text only",
|
||||
"defaultCount": "Default Count",
|
||||
"rateLimits": "Rate Limits",
|
||||
"searchCooldown": "Search cooldown (s)",
|
||||
"defaultCooldown": "Default cooldown (s)",
|
||||
"noConfigs": "No command configs yet.",
|
||||
"confirmDelete": "Delete this command config?",
|
||||
"commands": "commands"
|
||||
},
|
||||
"commandTracker": {
|
||||
"title": "Command Trackers",
|
||||
"description": "Manage command trackers and their listeners",
|
||||
"newTracker": "New Tracker",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Family commands",
|
||||
"provider": "Provider",
|
||||
"selectProvider": "Select provider...",
|
||||
"commandConfig": "Command Config",
|
||||
"selectCommandConfig": "Select command config...",
|
||||
"listeners": "Listeners",
|
||||
"addListener": "Add Listener",
|
||||
"removeListener": "Remove",
|
||||
"noTrackers": "No command trackers yet.",
|
||||
"confirmDelete": "Delete this command tracker?",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"noListeners": "No listeners attached.",
|
||||
"selectBot": "Select bot...",
|
||||
"listenerType": "telegram_bot"
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details"
|
||||
@@ -504,7 +549,16 @@
|
||||
"commandsSynced": "Commands synced to Telegram",
|
||||
"targetLinked": "Target linked",
|
||||
"targetUnlinked": "Target unlinked",
|
||||
"botUpdated": "Bot updated"
|
||||
"botUpdated": "Bot updated",
|
||||
"commandConfigSaved": "Command config saved",
|
||||
"commandConfigDeleted": "Command config deleted",
|
||||
"commandTrackerCreated": "Command tracker created",
|
||||
"commandTrackerUpdated": "Command tracker updated",
|
||||
"commandTrackerDeleted": "Command tracker deleted",
|
||||
"commandTrackerEnabled": "Command tracker enabled",
|
||||
"commandTrackerDisabled": "Command tracker disabled",
|
||||
"listenerAdded": "Listener added",
|
||||
"listenerRemoved": "Listener removed"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
"nav": {
|
||||
"dashboard": "Главная",
|
||||
"providers": "Провайдеры",
|
||||
"trackers": "Трекеры",
|
||||
"notificationTrackers": "Трекеры увед.",
|
||||
"trackingConfigs": "Отслеживание",
|
||||
"templateConfigs": "Шаблоны",
|
||||
"telegramBots": "Боты",
|
||||
"targets": "Получатели",
|
||||
"commandConfigs": "Конф. команд",
|
||||
"commandTrackers": "Трекеры команд",
|
||||
"users": "Пользователи",
|
||||
"settings": "Настройки",
|
||||
"logout": "Выход"
|
||||
@@ -93,8 +95,8 @@
|
||||
"testAndSave": "Проверить и сохранить",
|
||||
"saveWithoutTest": "Сохранить без проверки"
|
||||
},
|
||||
"trackers": {
|
||||
"title": "Трекеры",
|
||||
"notificationTracker": {
|
||||
"title": "Трекеры уведомлений",
|
||||
"description": "Отслеживание изменений в альбомах",
|
||||
"newTracker": "Новый трекер",
|
||||
"cancel": "Отмена",
|
||||
@@ -198,7 +200,9 @@
|
||||
"maxAssetSize": "Макс. размер файла (МБ)",
|
||||
"videoWarning": "Предупреждение о размере видео",
|
||||
"disableUrlPreview": "Отключить превью ссылок",
|
||||
"sendLargeAsDocuments": "Отправлять большие фото как документы"
|
||||
"sendLargeAsDocuments": "Отправлять большие фото как документы",
|
||||
"chatAction": "Действие в чате",
|
||||
"chatActionNone": "Нет (без действия)"
|
||||
},
|
||||
"users": {
|
||||
"title": "Пользователи",
|
||||
@@ -474,6 +478,47 @@
|
||||
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
||||
},
|
||||
"commandConfig": {
|
||||
"title": "Конфигурации команд",
|
||||
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Команды по умолчанию",
|
||||
"providerType": "Тип провайдера",
|
||||
"enabledCommands": "Включённые команды",
|
||||
"locale": "Язык",
|
||||
"responseMode": "Режим ответа",
|
||||
"modeMedia": "Медиа (фото)",
|
||||
"modeText": "Только текст",
|
||||
"defaultCount": "Кол-во по умолчанию",
|
||||
"rateLimits": "Ограничения частоты",
|
||||
"searchCooldown": "Кулдаун поиска (с)",
|
||||
"defaultCooldown": "Кулдаун по умолчанию (с)",
|
||||
"noConfigs": "Конфигураций команд пока нет.",
|
||||
"confirmDelete": "Удалить эту конфигурацию команд?",
|
||||
"commands": "команд"
|
||||
},
|
||||
"commandTracker": {
|
||||
"title": "Трекеры команд",
|
||||
"description": "Управление трекерами команд и их слушателями",
|
||||
"newTracker": "Новый трекер",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Семейные команды",
|
||||
"provider": "Провайдер",
|
||||
"selectProvider": "Выберите провайдер...",
|
||||
"commandConfig": "Конфигурация команд",
|
||||
"selectCommandConfig": "Выберите конфигурацию...",
|
||||
"listeners": "Слушатели",
|
||||
"addListener": "Добавить слушателя",
|
||||
"removeListener": "Удалить",
|
||||
"noTrackers": "Трекеров команд пока нет.",
|
||||
"confirmDelete": "Удалить этот трекер команд?",
|
||||
"enabled": "Включён",
|
||||
"disabled": "Отключён",
|
||||
"noListeners": "Нет подключённых слушателей.",
|
||||
"selectBot": "Выберите бота...",
|
||||
"listenerType": "telegram_bot"
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали"
|
||||
@@ -504,7 +549,16 @@
|
||||
"commandsSynced": "Команды синхронизированы с Telegram",
|
||||
"targetLinked": "Получатель привязан",
|
||||
"targetUnlinked": "Получатель отвязан",
|
||||
"botUpdated": "Бот обновлён"
|
||||
"botUpdated": "Бот обновлён",
|
||||
"commandConfigSaved": "Конфигурация команд сохранена",
|
||||
"commandConfigDeleted": "Конфигурация команд удалена",
|
||||
"commandTrackerCreated": "Трекер команд создан",
|
||||
"commandTrackerUpdated": "Трекер команд обновлён",
|
||||
"commandTrackerDeleted": "Трекер команд удалён",
|
||||
"commandTrackerEnabled": "Трекер команд включён",
|
||||
"commandTrackerDisabled": "Трекер команд отключён",
|
||||
"listenerAdded": "Слушатель добавлен",
|
||||
"listenerRemoved": "Слушатель удалён"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
|
||||
@@ -39,11 +39,13 @@
|
||||
const baseNavItems = [
|
||||
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
|
||||
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
|
||||
{ href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
|
||||
{ href: '/notification-trackers', key: 'nav.notificationTrackers', icon: 'mdiRadar' },
|
||||
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
|
||||
{ href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
|
||||
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
{ href: '/command-trackers', key: 'nav.commandTrackers', icon: 'mdiConsoleLine' },
|
||||
{ href: '/command-configs', key: 'nav.commandConfigs', icon: 'mdiCog' },
|
||||
];
|
||||
const navItems = $derived(auth.isAdmin
|
||||
? [...baseNavItems, { href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' }, { href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' }]
|
||||
|
||||
@@ -127,6 +127,9 @@
|
||||
animateCount(0, status.trackers.active, (v) => displayActive = v);
|
||||
animateCount(0, status.trackers.total, (v) => displayTotal = v);
|
||||
animateCount(0, status.targets, (v) => displayTargets = v);
|
||||
if (status.command_trackers !== undefined) {
|
||||
animateCount(0, status.command_trackers, (v) => displayCommandTrackers = v);
|
||||
}
|
||||
}, 200);
|
||||
} catch (err: any) {
|
||||
error = err.message || t('common.error');
|
||||
@@ -135,10 +138,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
let displayCommandTrackers = $state(0);
|
||||
|
||||
const statCards = $derived(status ? [
|
||||
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
|
||||
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
|
||||
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
|
||||
...(status.command_trackers !== undefined ? [{ icon: 'mdiConsoleLine', label: 'nav.commandTrackers', value: displayCommandTrackers, color: '#8b5cf6' }] : []),
|
||||
] : []);
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(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' },
|
||||
];
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
icon: '',
|
||||
provider_type: 'immich',
|
||||
enabled_commands: ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'] as string[],
|
||||
locale: 'en',
|
||||
response_mode: 'media',
|
||||
default_count: 5,
|
||||
rate_limits: { search: 30, default: 10 },
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
configs = await api('/command-configs');
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function editConfig(cfg: any) {
|
||||
form = {
|
||||
name: cfg.name,
|
||||
icon: cfg.icon || '',
|
||||
provider_type: cfg.provider_type || 'immich',
|
||||
enabled_commands: [...(cfg.enabled_commands || [])],
|
||||
locale: cfg.locale || 'en',
|
||||
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 },
|
||||
};
|
||||
editing = cfg.id;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function toggleCmd(cmd: string) {
|
||||
const enabled = [...form.enabled_commands];
|
||||
const idx = enabled.indexOf(cmd);
|
||||
if (idx >= 0) enabled.splice(idx, 1);
|
||||
else enabled.push(cmd);
|
||||
form.enabled_commands = enabled;
|
||||
}
|
||||
|
||||
async function saveConfig(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
const body = JSON.stringify(form);
|
||||
if (editing) {
|
||||
await api(`/command-configs/${editing}`, { method: 'PUT', body });
|
||||
snackSuccess(t('snack.commandConfigSaved'));
|
||||
} else {
|
||||
await api('/command-configs', { method: 'POST', body });
|
||||
snackSuccess(t('snack.commandConfigSaved'));
|
||||
}
|
||||
form = defaultForm(); showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
function remove(cfg: any) {
|
||||
confirmDelete = {
|
||||
id: cfg.id,
|
||||
onconfirm: async () => {
|
||||
try {
|
||||
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.commandConfigDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('commandConfig.title')} description={t('commandConfig.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('commandConfig.newConfig')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={saveConfig} class="space-y-4">
|
||||
<div>
|
||||
<label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="cfg-name" bind:value={form.name} required placeholder={t('commandConfig.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="cfg-provider-type" class="block text-sm font-medium mb-1">{t('commandConfig.providerType')}</label>
|
||||
<select id="cfg-provider-type" 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)]">
|
||||
<option value="immich">Immich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Enabled commands -->
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-2">{t('commandConfig.enabledCommands')}</p>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1">
|
||||
{#each allCommands as cmd}
|
||||
<label class="flex items-center gap-1.5 text-xs cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
|
||||
<input type="checkbox" checked={form.enabled_commands.includes(cmd.key)}
|
||||
onchange={() => toggleCmd(cmd.key)} />
|
||||
<MdiIcon name={cmd.icon} size={14} />
|
||||
/{cmd.key}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('commandConfig.locale')}</label>
|
||||
<select bind:value={form.locale}
|
||||
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('commandConfig.responseMode')}</label>
|
||||
<select bind:value={form.response_mode}
|
||||
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value="media">{t('commandConfig.modeMedia')}</option>
|
||||
<option value="text">{t('commandConfig.modeText')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('commandConfig.defaultCount')}</label>
|
||||
<input type="number" bind:value={form.default_count} min="1" max="20"
|
||||
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('commandConfig.searchCooldown')}</label>
|
||||
<input type="number" bind:value={form.rate_limits.search} min="0" max="300"
|
||||
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-1/2 sm:w-1/4">
|
||||
<label class="block text-xs mb-1">{t('commandConfig.defaultCooldown')}</label>
|
||||
<input type="number" bind:value={form.rate_limits.default} min="0" max="300"
|
||||
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiConsoleLine" message={t('commandConfig.noConfigs')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each configs as cfg}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium">{cfg.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{cfg.provider_type}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500 font-mono">
|
||||
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
|
||||
</span>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{cfg.locale?.toUpperCase()}</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
|
||||
{t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
|
||||
· {t('commandConfig.defaultCount')}: {cfg.default_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editConfig(cfg)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(cfg)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
@@ -0,0 +1,314 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import type { ServiceProvider, TelegramBot } from '$lib/types';
|
||||
|
||||
let trackers = $state<any[]>([]);
|
||||
let providers = $state<ServiceProvider[]>([]);
|
||||
let commandConfigs = $state<any[]>([]);
|
||||
let telegramBots = $state<TelegramBot[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
let toggling = $state<Record<number, boolean>>({});
|
||||
|
||||
// Listeners per tracker
|
||||
let listeners = $state<Record<number, any[]>>({});
|
||||
let listenersLoading = $state<Record<number, boolean>>({});
|
||||
let expandedTracker = $state<number | null>(null);
|
||||
let addingListener = $state<Record<number, boolean>>({});
|
||||
let newListenerBotId = $state<Record<number, number>>({});
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
icon: '',
|
||||
provider_id: 0,
|
||||
command_config_id: 0,
|
||||
enabled: true,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
// Filter command configs by selected provider's type
|
||||
let filteredConfigs = $derived(() => {
|
||||
if (!form.provider_id) return commandConfigs;
|
||||
const provider = providers.find(p => p.id === form.provider_id);
|
||||
if (!provider) return commandConfigs;
|
||||
return commandConfigs.filter(c => c.provider_type === provider.type);
|
||||
});
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
[trackers, providers, commandConfigs, telegramBots] = await Promise.all([
|
||||
api('/command-trackers'),
|
||||
api('/providers'),
|
||||
api('/command-configs'),
|
||||
api('/telegram-bots'),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function editTracker(trk: any) {
|
||||
form = {
|
||||
name: trk.name,
|
||||
icon: trk.icon || '',
|
||||
provider_id: trk.provider_id,
|
||||
command_config_id: trk.command_config_id,
|
||||
enabled: trk.enabled,
|
||||
};
|
||||
editing = trk.id;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
async function saveTracker(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
const body = JSON.stringify(form);
|
||||
if (editing) {
|
||||
await api(`/command-trackers/${editing}`, { method: 'PUT', body });
|
||||
snackSuccess(t('snack.commandTrackerUpdated'));
|
||||
} else {
|
||||
await api('/command-trackers', { method: 'POST', body });
|
||||
snackSuccess(t('snack.commandTrackerCreated'));
|
||||
}
|
||||
form = defaultForm(); showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
function remove(trk: any) {
|
||||
confirmDelete = {
|
||||
id: trk.id,
|
||||
onconfirm: async () => {
|
||||
try {
|
||||
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.commandTrackerDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function toggleEnabled(trk: any) {
|
||||
toggling = { ...toggling, [trk.id]: true };
|
||||
try {
|
||||
const endpoint = trk.enabled ? 'disable' : 'enable';
|
||||
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
|
||||
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
|
||||
await load();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
toggling = { ...toggling, [trk.id]: false };
|
||||
}
|
||||
|
||||
function toggleListeners(trkId: number) {
|
||||
if (expandedTracker === trkId) {
|
||||
expandedTracker = null;
|
||||
return;
|
||||
}
|
||||
expandedTracker = trkId;
|
||||
loadListeners(trkId);
|
||||
}
|
||||
|
||||
async function loadListeners(trkId: number) {
|
||||
listenersLoading = { ...listenersLoading, [trkId]: true };
|
||||
try {
|
||||
listeners = { ...listeners, [trkId]: await api(`/command-trackers/${trkId}/listeners`) };
|
||||
} catch { listeners = { ...listeners, [trkId]: [] }; }
|
||||
listenersLoading = { ...listenersLoading, [trkId]: false };
|
||||
}
|
||||
|
||||
async function addListener(trkId: number) {
|
||||
const botId = newListenerBotId[trkId];
|
||||
if (!botId) return;
|
||||
addingListener = { ...addingListener, [trkId]: true };
|
||||
try {
|
||||
await api(`/command-trackers/${trkId}/listeners`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ listener_type: 'telegram_bot', listener_id: botId }),
|
||||
});
|
||||
snackSuccess(t('snack.listenerAdded'));
|
||||
await loadListeners(trkId);
|
||||
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
addingListener = { ...addingListener, [trkId]: false };
|
||||
}
|
||||
|
||||
async function removeListener(trkId: number, listenerId: number) {
|
||||
try {
|
||||
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
|
||||
snackSuccess(t('snack.listenerRemoved'));
|
||||
await loadListeners(trkId);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
function providerName(id: number): string {
|
||||
return providers.find(p => p.id === id)?.name || '?';
|
||||
}
|
||||
function configName(id: number): string {
|
||||
return commandConfigs.find(c => c.id === id)?.name || '?';
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('commandTracker.title')} description={t('commandTracker.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('commandTracker.newTracker')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={saveTracker} class="space-y-3">
|
||||
<div>
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('commandTracker.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('commandTracker.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="trk-provider" class="block text-sm font-medium mb-1">{t('commandTracker.provider')}</label>
|
||||
<select id="trk-provider" bind:value={form.provider_id} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0} disabled>{t('commandTracker.selectProvider')}</option>
|
||||
{#each providers as p}
|
||||
<option value={p.id}>{p.name} ({p.type})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="trk-config" class="block text-sm font-medium mb-1">{t('commandTracker.commandConfig')}</label>
|
||||
<select id="trk-config" bind:value={form.command_config_id} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0} disabled>{t('commandTracker.selectCommandConfig')}</option>
|
||||
{#each filteredConfigs() as c}
|
||||
<option value={c.id}>{c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" bind:checked={form.enabled} />
|
||||
{t('commandTracker.enabled')}
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if trackers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiConsoleLine" message={t('commandTracker.noTrackers')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each trackers as trk}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium">{trk.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{providerName(trk.provider_id)}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{configName(trk.command_config_id)}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-500'
|
||||
: 'bg-red-500/10 text-red-500'}">
|
||||
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
{#if trk.listener_count !== undefined}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
|
||||
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
|
||||
<button onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]}
|
||||
class="text-xs px-2 py-1 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
{trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
</button>
|
||||
<button onclick={() => toggleListeners(trk.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('commandTracker.listeners')} {expandedTracker === trk.id ? '▲' : '▼'}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listeners section -->
|
||||
{#if expandedTracker === trk.id}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3" transition:slide>
|
||||
{#if listenersLoading[trk.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (listeners[trk.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('commandTracker.noListeners')}</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each listeners[trk.id] as listener}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiIcon name="mdiRobot" size={14} />
|
||||
<span class="font-medium">{listener.name || listener.listener_type}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-500 font-mono">{listener.listener_type}</span>
|
||||
</div>
|
||||
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
|
||||
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add listener -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<select bind:value={newListenerBotId[trk.id]}
|
||||
class="flex-1 px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0} disabled selected>{t('commandTracker.selectBot')}</option>
|
||||
{#each telegramBots as bot}
|
||||
<option value={bot.id}>{bot.name} {bot.bot_username ? `(@${bot.bot_username})` : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button onclick={() => addListener(trk.id)} disabled={!newListenerBotId[trk.id] || addingListener[trk.id]}
|
||||
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
|
||||
{addingListener[trk.id] ? t('common.loading') : t('commandTracker.addListener')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
+46
-46
@@ -18,7 +18,7 @@
|
||||
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
let trackers = $state<Tracker[]>([]);
|
||||
let notificationTrackers = $state<Tracker[]>([]);
|
||||
let providers = $state<ServiceProvider[]>([]);
|
||||
let targets = $state<NotificationTarget[]>([]);
|
||||
let trackingConfigs = $state<TrackingConfig[]>([]);
|
||||
@@ -59,8 +59,8 @@
|
||||
async function load() {
|
||||
loadError = '';
|
||||
try {
|
||||
[trackers, providers, targets, trackingConfigs, templateConfigs] = await Promise.all([
|
||||
api('/trackers'), api('/providers'), api('/targets'),
|
||||
[notificationTrackers, providers, targets, trackingConfigs, templateConfigs] = await Promise.all([
|
||||
api('/notification-trackers'), api('/providers'), api('/targets'),
|
||||
api('/tracking-configs'), api('/template-configs'),
|
||||
]);
|
||||
} catch (err: any) {
|
||||
@@ -126,10 +126,10 @@
|
||||
submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.trackerUpdated'));
|
||||
} else {
|
||||
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||
await api('/notification-trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.trackerCreated'));
|
||||
}
|
||||
showForm = false; editing = null; linkWarning = null; await load();
|
||||
@@ -164,7 +164,7 @@
|
||||
if (toggling[tracker.id]) return;
|
||||
toggling = { ...toggling, [tracker.id]: true };
|
||||
try {
|
||||
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||
await api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||
await load();
|
||||
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
|
||||
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
|
||||
@@ -173,7 +173,7 @@
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
await api(`/trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.trackerDeleted'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
@@ -183,10 +183,10 @@
|
||||
let testMenuStyle = $state('');
|
||||
|
||||
const testTypes = [
|
||||
{ key: 'basic', icon: 'mdiSend', labelKey: 'trackers.testBasic' },
|
||||
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'trackers.testPeriodic' },
|
||||
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'trackers.testScheduled' },
|
||||
{ key: 'memory', icon: 'mdiHistory', labelKey: 'trackers.testMemory' },
|
||||
{ key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
||||
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic' },
|
||||
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'notificationTracker.testScheduled' },
|
||||
{ key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory' },
|
||||
];
|
||||
|
||||
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
||||
@@ -195,7 +195,7 @@
|
||||
if (ttTesting[key]) return;
|
||||
ttTesting = { ...ttTesting, [key]: testType };
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
|
||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
@@ -237,7 +237,7 @@
|
||||
if (!targetId) return;
|
||||
addingTarget = { ...addingTarget, [trackerId]: true };
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets`, {
|
||||
await api(`/notification-trackers/${trackerId}/targets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
target_id: targetId,
|
||||
@@ -256,7 +256,7 @@
|
||||
|
||||
async function removeTargetLink(trackerId: number, ttId: number) {
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.targetUnlinked'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
@@ -264,7 +264,7 @@
|
||||
|
||||
async function updateTargetLink(trackerId: number, tt: any, field: string, value: any) {
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets/${tt.id}`, {
|
||||
await api(`/notification-trackers/${trackerId}/targets/${tt.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ [field]: value }),
|
||||
});
|
||||
@@ -273,10 +273,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('trackers.title')} description={t('trackers.description')}>
|
||||
<PageHeader title={t('notificationTracker.title')} description={t('notificationTracker.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('trackers.cancel') : t('trackers.newTracker')}
|
||||
{showForm ? t('notificationTracker.cancel') : t('notificationTracker.newTracker')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
@@ -292,22 +292,22 @@
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-4">
|
||||
<div>
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('trackers.name')}</label>
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('trackers.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="trk-provider" class="block text-sm font-medium mb-1">{t('trackers.server')}</label>
|
||||
<label for="trk-provider" class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label>
|
||||
<select id="trk-provider" bind:value={form.provider_id} onchange={loadCollections} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0} disabled>{t('trackers.selectServer')}</option>
|
||||
<option value={0} disabled>{t('notificationTracker.selectServer')}</option>
|
||||
{#each providers as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#if collections.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('trackers.albums')} ({collections.length})</label>
|
||||
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')} ({collections.length})</label>
|
||||
<input type="text" bind:value={collectionFilter} placeholder="Filter..."
|
||||
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
|
||||
@@ -327,17 +327,17 @@
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('trackers.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('trackers.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
|
||||
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('notificationTracker.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
|
||||
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{#if linkCheckLoading}{t('trackers.checkingLinks')}{:else}{editing ? t('common.save') : t('trackers.createTracker')}{/if}
|
||||
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
@@ -345,13 +345,13 @@
|
||||
{/if}
|
||||
|
||||
{#if loaded && !loadError}
|
||||
{#if trackers.length === 0 && !showForm}
|
||||
{#if notificationTrackers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiRadar" message={t('trackers.noTrackers')} />
|
||||
<EmptyState icon="mdiRadar" message={t('notificationTracker.noTrackers')} />
|
||||
</Card>
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each trackers as tracker}
|
||||
{#each notificationTrackers as tracker}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -359,22 +359,22 @@
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
|
||||
<p class="font-medium">{tracker.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('trackers.linkedTargets')}
|
||||
{(tracker.collection_ids || []).length} {t('notificationTracker.albums_count')} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
|
||||
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { try { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err) { snackError((err as any).message); } }} />
|
||||
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('trackers.pause') : t('trackers.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
||||
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { try { await api(`/notification-trackers/${tracker.id}/trigger`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err) { snackError((err as any).message); } }} />
|
||||
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
||||
<button onclick={() => toggleExpand(tracker.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('trackers.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
|
||||
{t('notificationTracker.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('trackers.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
||||
<IconButton icon="mdiDelete" title={t('notificationTracker.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -382,7 +382,7 @@
|
||||
{#if expandedTracker === tracker.id}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
|
||||
{#if (tracker.tracker_targets || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('trackers.noLinkedTargets')}</p>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('notificationTracker.noLinkedTargets')}</p>
|
||||
{:else}
|
||||
{#each tracker.tracker_targets as tt}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
@@ -391,7 +391,7 @@
|
||||
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
|
||||
{#if !tt.enabled}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('trackers.paused')}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
@@ -413,7 +413,7 @@
|
||||
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
|
||||
</div>
|
||||
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
||||
title={tt.enabled ? t('trackers.pause') : t('trackers.resume')}
|
||||
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
|
||||
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
|
||||
onclick={() => removeTargetLink(tracker.id, tt.id)} variant="danger" />
|
||||
@@ -427,7 +427,7 @@
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<select bind:value={newLinkTargetId[tracker.id]}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1">
|
||||
<option value={0}>— {t('trackers.addTarget')} —</option>
|
||||
<option value={0}>— {t('notificationTracker.addTarget')} —</option>
|
||||
{#each getUnlinkedTargets(tracker) as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each}
|
||||
</select>
|
||||
<select bind:value={newLinkTrackingConfigId[tracker.id]}
|
||||
@@ -463,7 +463,7 @@
|
||||
</div>
|
||||
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
|
||||
{#each testTypes as tt}
|
||||
{@const trackerId = trackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id}
|
||||
{@const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id}
|
||||
<button
|
||||
onclick={() => trackerId && testTrackerTarget(trackerId, Number(testMenuOpen), tt.key)}
|
||||
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
@@ -478,33 +478,33 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal open={linkWarning !== null} title={t('trackers.missingLinksTitle')} onclose={() => { linkWarning = null; }}>
|
||||
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={() => { linkWarning = null; }}>
|
||||
{#if linkWarning}
|
||||
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
|
||||
{t('trackers.missingLinksDesc')}
|
||||
{t('notificationTracker.missingLinksDesc')}
|
||||
</p>
|
||||
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
|
||||
{#each linkWarning.albums as album}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<span class="font-medium">{album.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{album.issue === 'expired' ? t('trackers.expired') : album.issue === 'password-protected' ? t('trackers.passwordProtected') : t('trackers.noLink')}
|
||||
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiInformation" size={14} /> {t('trackers.linksNote')}
|
||||
<MdiIcon name="mdiInformation" size={14} /> {t('notificationTracker.linksNote')}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<button onclick={dismissLinkWarning}
|
||||
class="px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
|
||||
{t('trackers.saveWithoutLinks')}
|
||||
{t('notificationTracker.saveWithoutLinks')}
|
||||
</button>
|
||||
{#if linkWarning.albums.some(a => a.issue === 'missing')}
|
||||
<button onclick={autoCreateLinks} disabled={linkCreating}
|
||||
class="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
|
||||
{linkCreating ? t('common.loading') : t('trackers.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
|
||||
{linkCreating ? t('common.loading') : t('notificationTracker.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -513,7 +513,7 @@
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
message={t('trackers.confirmDelete')}
|
||||
message={t('notificationTracker.confirmDelete')}
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => confirmDelete = null}
|
||||
/>
|
||||
@@ -23,7 +23,7 @@
|
||||
let formType = $state<'telegram' | 'webhook'>('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 });
|
||||
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: '' });
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
let headersError = $state('');
|
||||
@@ -55,7 +55,7 @@
|
||||
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,
|
||||
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? '',
|
||||
};
|
||||
editing = tgt.id; showTelegramSettings = false; showForm = true;
|
||||
if (form.bot_id) await loadBotChats();
|
||||
@@ -82,7 +82,7 @@
|
||||
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 }
|
||||
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined }
|
||||
: { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
|
||||
if (editing) {
|
||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
||||
@@ -198,6 +198,19 @@
|
||||
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
|
||||
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="tgt-chataction" class="block text-xs mb-1">{t('targets.chatAction')}</label>
|
||||
<select id="tgt-chataction" bind:value={form.chat_action}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="">{t('targets.chatActionNone')}</option>
|
||||
<option value="typing">typing</option>
|
||||
<option value="upload_photo">upload_photo</option>
|
||||
<option value="upload_video">upload_video</option>
|
||||
<option value="upload_document">upload_document</option>
|
||||
<option value="record_video">record_video</option>
|
||||
<option value="record_voice">record_voice</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
|
||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
|
||||
</div>
|
||||
@@ -241,6 +254,9 @@
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{#if target.type === 'telegram'}
|
||||
Chat: {#if target.chat_name}{target.chat_name} <span class="font-mono text-xs">({target.config?.chat_id})</span>{:else}{target.config?.chat_id || '***'}{/if}
|
||||
{#if target.config?.chat_action}
|
||||
<span class="text-xs px-1.5 py-0.5 ml-1 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.config.chat_action}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
{target.config?.url || ''}
|
||||
{/if}
|
||||
|
||||
@@ -108,70 +108,38 @@
|
||||
|
||||
let chatTesting = $state<Record<string, boolean>>({});
|
||||
|
||||
// Commands config editing
|
||||
let cmdConfig = $state<Record<number, any>>({});
|
||||
let cmdSaving = $state<Record<number, boolean>>({});
|
||||
let cmdSyncing = $state<Record<number, boolean>>({});
|
||||
let modeChanging = $state<Record<number, boolean>>({});
|
||||
|
||||
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' },
|
||||
];
|
||||
// Listener status: command trackers using this bot
|
||||
let botListenerStatus = $state<Record<number, any[]>>({});
|
||||
let botListenerLoading = $state<Record<number, boolean>>({});
|
||||
|
||||
function initCmdConfig(bot: any) {
|
||||
if (!cmdConfig[bot.id]) {
|
||||
const cfg = bot.commands_config || {};
|
||||
cmdConfig = { ...cmdConfig, [bot.id]: {
|
||||
enabled: cfg.enabled || ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'],
|
||||
locale: cfg.locale || 'en',
|
||||
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 },
|
||||
}};
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCmd(botId: number, cmd: string) {
|
||||
const cfg = cmdConfig[botId];
|
||||
if (!cfg) return;
|
||||
const enabled = [...cfg.enabled];
|
||||
const idx = enabled.indexOf(cmd);
|
||||
if (idx >= 0) enabled.splice(idx, 1);
|
||||
else enabled.push(cmd);
|
||||
cmdConfig = { ...cmdConfig, [botId]: { ...cfg, enabled } };
|
||||
}
|
||||
|
||||
async function saveCmdConfig(botId: number) {
|
||||
cmdSaving = { ...cmdSaving, [botId]: true };
|
||||
async function loadListenerStatus(botId: number) {
|
||||
botListenerLoading = { ...botListenerLoading, [botId]: true };
|
||||
try {
|
||||
await api(`/telegram-bots/${botId}`, { method: 'PUT', body: JSON.stringify({ commands_config: cmdConfig[botId] }) });
|
||||
await load();
|
||||
snackSuccess(t('snack.botUpdated'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
cmdSaving = { ...cmdSaving, [botId]: false };
|
||||
// Load all command trackers and filter for ones that have this bot as a listener
|
||||
const trackers = await api('/command-trackers');
|
||||
const matched: any[] = [];
|
||||
for (const trk of trackers) {
|
||||
try {
|
||||
const listeners = await api(`/command-trackers/${trk.id}/listeners`);
|
||||
const hasBot = listeners.some((l: any) => l.listener_type === 'telegram_bot' && l.listener_id === botId);
|
||||
if (hasBot) matched.push(trk);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
botListenerStatus = { ...botListenerStatus, [botId]: matched };
|
||||
} catch { botListenerStatus = { ...botListenerStatus, [botId]: [] }; }
|
||||
botListenerLoading = { ...botListenerLoading, [botId]: false };
|
||||
}
|
||||
|
||||
async function syncCommands(botId: number) {
|
||||
cmdSyncing = { ...cmdSyncing, [botId]: true };
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
cmdSyncing = { ...cmdSyncing, [botId]: false };
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
async function switchMode(botId: number, mode: string) {
|
||||
@@ -315,9 +283,14 @@
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => { initCmdConfig(bot); toggleSection(bot.id, 'commands'); }}
|
||||
<button onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('telegramBot.commands')} {expandedSection[bot.id] === 'commands' ? '▲' : '▼'}
|
||||
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline px-2 py-1 flex items-center gap-1">
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
{t('telegramBot.syncCommands')}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
||||
</div>
|
||||
@@ -360,67 +333,35 @@
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Commands section -->
|
||||
{#if expandedSection[bot.id] === 'commands' && cmdConfig[bot.id]}
|
||||
<!-- Listener Status section -->
|
||||
{#if expandedSection[bot.id] === 'listeners'}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-3" in:slide>
|
||||
<!-- Command toggles -->
|
||||
<div>
|
||||
<p class="text-xs font-medium mb-2">{t('telegramBot.enabledCommands')}</p>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1">
|
||||
{#each allCommands as cmd}
|
||||
<label class="flex items-center gap-1.5 text-xs cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
|
||||
<input type="checkbox" checked={cmdConfig[bot.id].enabled.includes(cmd.key)}
|
||||
onchange={() => toggleCmd(bot.id, cmd.key)} />
|
||||
<MdiIcon name={cmd.icon} size={14} />
|
||||
/{cmd.key}
|
||||
</label>
|
||||
{#if botListenerLoading[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (botListenerStatus[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('commandTracker.noListeners')}</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each botListenerStatus[bot.id] as trk}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiIcon name={trk.icon || 'mdiConsoleLine'} size={14} />
|
||||
<span class="font-medium">{trk.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-500'
|
||||
: 'bg-red-500/10 text-red-500'}">
|
||||
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
<a href="/command-trackers" class="text-xs text-[var(--color-primary)] hover:underline">
|
||||
{t('common.edit')}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('telegramBot.responseMode')}</label>
|
||||
<select bind:value={cmdConfig[bot.id].response_mode}
|
||||
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value="media">{t('telegramBot.modeMedia')}</option>
|
||||
<option value="text">{t('telegramBot.modeText')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('telegramBot.cmdLocale')}</label>
|
||||
<select bind:value={cmdConfig[bot.id].locale}
|
||||
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('telegramBot.defaultCount')}</label>
|
||||
<input type="number" bind:value={cmdConfig[bot.id].default_count} min="1" max="20"
|
||||
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('telegramBot.searchCooldown')}</label>
|
||||
<input type="number" bind:value={cmdConfig[bot.id].rate_limits.search} min="0" max="300"
|
||||
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 1: Config actions -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button onclick={() => saveCmdConfig(bot.id)} disabled={cmdSaving[bot.id]}
|
||||
class="px-3 py-1 text-xs bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
|
||||
{cmdSaving[bot.id] ? t('common.loading') : t('telegramBot.saveConfig')}
|
||||
</button>
|
||||
<button onclick={() => syncCommands(bot.id)} disabled={cmdSyncing[bot.id]}
|
||||
class="px-3 py-1 text-xs border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
{cmdSyncing[bot.id] ? t('common.loading') : t('telegramBot.syncCommands')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Update mode -->
|
||||
<!-- Update mode -->
|
||||
<div class="border-t border-[var(--color-border)] pt-3">
|
||||
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
@@ -459,7 +400,6 @@
|
||||
class="px-2 py-1 text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
|
||||
{t('telegramBot.unregisterWebhook')}
|
||||
</button>
|
||||
<!-- Webhook status -->
|
||||
{#if webhookStatus[bot.id]}
|
||||
{@const ws = webhookStatus[bot.id]}
|
||||
<span class="text-xs font-mono {ws.url ? 'text-blue-500' : 'text-[var(--color-muted-foreground)]'}">
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Command config management API routes."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import CommandConfig, CommandTracker, User
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/command-configs", tags=["command-configs"])
|
||||
|
||||
|
||||
class CommandConfigCreate(BaseModel):
|
||||
provider_type: str
|
||||
name: str
|
||||
icon: str = ""
|
||||
enabled_commands: list[str] = []
|
||||
locale: str = "en"
|
||||
response_mode: str = "media"
|
||||
default_count: int = 5
|
||||
rate_limits: dict[str, Any] = {}
|
||||
|
||||
|
||||
class CommandConfigUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
enabled_commands: list[str] | None = None
|
||||
locale: str | None = None
|
||||
response_mode: str | None = None
|
||||
default_count: int | None = None
|
||||
rate_limits: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_command_configs(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all command configs for the current user."""
|
||||
result = await session.exec(
|
||||
select(CommandConfig).where(CommandConfig.user_id == user.id)
|
||||
)
|
||||
return [_config_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_command_config(
|
||||
body: CommandConfigCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Create a new command config."""
|
||||
# Validate provider_type
|
||||
valid_types = ("immich",)
|
||||
if body.provider_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid provider_type. Must be one of: {', '.join(valid_types)}",
|
||||
)
|
||||
|
||||
config = CommandConfig(user_id=user.id, **body.model_dump())
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
@router.get("/{config_id}")
|
||||
async def get_command_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get a single command config."""
|
||||
config = await _get_user_config(session, config_id, user.id)
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
@router.put("/{config_id}")
|
||||
async def update_command_config(
|
||||
config_id: int,
|
||||
body: CommandConfigUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a command config."""
|
||||
config = await _get_user_config(session, config_id, user.id)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(config, field, value)
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_command_config(
|
||||
config_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a command config. Fails if in use by any command tracker."""
|
||||
config = await _get_user_config(session, config_id, user.id)
|
||||
|
||||
# Check if any command tracker references this config
|
||||
result = await session.exec(
|
||||
select(CommandTracker).where(CommandTracker.command_config_id == config_id)
|
||||
)
|
||||
if result.first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot delete: command config is in use by a command tracker",
|
||||
)
|
||||
|
||||
await session.delete(config)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
def _config_response(c: CommandConfig) -> dict:
|
||||
return {
|
||||
"id": c.id,
|
||||
"user_id": c.user_id,
|
||||
"provider_type": c.provider_type,
|
||||
"name": c.name,
|
||||
"icon": c.icon,
|
||||
"enabled_commands": c.enabled_commands or [],
|
||||
"locale": c.locale,
|
||||
"response_mode": c.response_mode,
|
||||
"default_count": c.default_count,
|
||||
"rate_limits": c.rate_limits or {},
|
||||
"created_at": c.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_config(
|
||||
session: AsyncSession, config_id: int, user_id: int
|
||||
) -> CommandConfig:
|
||||
config = await session.get(CommandConfig, config_id)
|
||||
if not config or config.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Command config not found")
|
||||
return config
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Command tracker and listener management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import func, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import (
|
||||
CommandConfig,
|
||||
CommandTracker,
|
||||
CommandTrackerListener,
|
||||
ServiceProvider,
|
||||
TelegramBot,
|
||||
User,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/command-trackers", tags=["command-trackers"])
|
||||
|
||||
|
||||
class CommandTrackerCreate(BaseModel):
|
||||
provider_id: int
|
||||
command_config_id: int
|
||||
name: str
|
||||
icon: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class CommandTrackerUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
enabled: bool | None = None
|
||||
command_config_id: int | None = None
|
||||
|
||||
|
||||
class ListenerCreate(BaseModel):
|
||||
listener_type: str
|
||||
listener_id: int
|
||||
|
||||
|
||||
# --- Command Tracker CRUD ---
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_command_trackers(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all command trackers for the current user, with listener counts."""
|
||||
result = await session.exec(
|
||||
select(CommandTracker).where(CommandTracker.user_id == user.id)
|
||||
)
|
||||
trackers = result.all()
|
||||
return [await _tracker_response(session, t) for t in trackers]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_command_tracker(
|
||||
body: CommandTrackerCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Create a new command tracker."""
|
||||
# Validate provider exists and user owns it
|
||||
provider = await session.get(ServiceProvider, body.provider_id)
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
# Validate command config exists and user owns it
|
||||
config = await session.get(CommandConfig, body.command_config_id)
|
||||
if not config or config.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Command config not found")
|
||||
|
||||
# Validate provider_type matches
|
||||
if config.provider_type != provider.type:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider type mismatch: provider is '{provider.type}' but command config is for '{config.provider_type}'",
|
||||
)
|
||||
|
||||
tracker = CommandTracker(user_id=user.id, **body.model_dump())
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
|
||||
@router.get("/{tracker_id}")
|
||||
async def get_command_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get a single command tracker with its listeners."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
return await _tracker_response(session, tracker, include_listeners=True)
|
||||
|
||||
|
||||
@router.put("/{tracker_id}")
|
||||
async def update_command_tracker(
|
||||
tracker_id: int,
|
||||
body: CommandTrackerUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a command tracker."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
|
||||
# If changing command_config_id, validate ownership and provider_type match
|
||||
if "command_config_id" in updates and updates["command_config_id"] is not None:
|
||||
config = await session.get(CommandConfig, updates["command_config_id"])
|
||||
if not config or config.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Command config not found")
|
||||
provider = await session.get(ServiceProvider, tracker.provider_id)
|
||||
if provider and config.provider_type != provider.type:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider type mismatch: provider is '{provider.type}' but command config is for '{config.provider_type}'",
|
||||
)
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(tracker, field, value)
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
|
||||
@router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_command_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Delete a command tracker and cascade delete its listeners."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
|
||||
# Delete associated listeners, collecting bot IDs for polling cleanup
|
||||
result = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == tracker_id
|
||||
)
|
||||
)
|
||||
bot_ids_to_check: set[int] = set()
|
||||
for listener in result.all():
|
||||
if listener.listener_type == "telegram_bot":
|
||||
bot_ids_to_check.add(listener.listener_id)
|
||||
await session.delete(listener)
|
||||
|
||||
await session.delete(tracker)
|
||||
await session.commit()
|
||||
|
||||
# Stop polling for bots that may no longer be needed
|
||||
if bot_ids_to_check:
|
||||
from ..services.telegram_poller import stop_bot_if_unused
|
||||
for bot_id in bot_ids_to_check:
|
||||
await stop_bot_if_unused(bot_id)
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/enable")
|
||||
async def enable_command_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Enable a command tracker."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
tracker.enabled = True
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
|
||||
# Start polling for any telegram bot listeners
|
||||
lr = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == tracker_id,
|
||||
CommandTrackerListener.listener_type == "telegram_bot",
|
||||
)
|
||||
)
|
||||
from ..services.telegram_poller import start_bot_if_needed
|
||||
for listener in lr.all():
|
||||
await start_bot_if_needed(listener.listener_id)
|
||||
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/disable")
|
||||
async def disable_command_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Disable a command tracker."""
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
tracker.enabled = False
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
|
||||
# Stop polling for any telegram bot listeners that are no longer needed
|
||||
lr = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == tracker_id,
|
||||
CommandTrackerListener.listener_type == "telegram_bot",
|
||||
)
|
||||
)
|
||||
from ..services.telegram_poller import stop_bot_if_unused
|
||||
for listener in lr.all():
|
||||
await stop_bot_if_unused(listener.listener_id)
|
||||
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
|
||||
# --- Listener Management ---
|
||||
|
||||
|
||||
@router.get("/{tracker_id}/listeners")
|
||||
async def list_listeners(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all listeners for a command tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
result = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == tracker_id
|
||||
)
|
||||
)
|
||||
return [_listener_response(l) for l in result.all()]
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/listeners", status_code=status.HTTP_201_CREATED)
|
||||
async def add_listener(
|
||||
tracker_id: int,
|
||||
body: ListenerCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Add a listener to a command tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
|
||||
# Validate listener exists and user owns it
|
||||
if body.listener_type == "telegram_bot":
|
||||
bot = await session.get(TelegramBot, body.listener_id)
|
||||
if not bot or bot.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Telegram bot not found")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported listener type: {body.listener_type}",
|
||||
)
|
||||
|
||||
# Check for duplicate
|
||||
result = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == tracker_id,
|
||||
CommandTrackerListener.listener_type == body.listener_type,
|
||||
CommandTrackerListener.listener_id == body.listener_id,
|
||||
)
|
||||
)
|
||||
if result.first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Listener is already linked to this command tracker",
|
||||
)
|
||||
|
||||
listener = CommandTrackerListener(
|
||||
command_tracker_id=tracker_id,
|
||||
listener_type=body.listener_type,
|
||||
listener_id=body.listener_id,
|
||||
)
|
||||
session.add(listener)
|
||||
await session.commit()
|
||||
await session.refresh(listener)
|
||||
|
||||
# Start polling for this bot if needed
|
||||
if body.listener_type == "telegram_bot":
|
||||
from ..services.telegram_poller import start_bot_if_needed
|
||||
await start_bot_if_needed(body.listener_id)
|
||||
|
||||
return _listener_response(listener)
|
||||
|
||||
|
||||
@router.delete("/{tracker_id}/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_listener(
|
||||
tracker_id: int,
|
||||
listener_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Remove a listener from a command tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
listener = await session.get(CommandTrackerListener, listener_id)
|
||||
if not listener or listener.command_tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Listener not found")
|
||||
|
||||
removed_type = listener.listener_type
|
||||
removed_id = listener.listener_id
|
||||
|
||||
await session.delete(listener)
|
||||
await session.commit()
|
||||
|
||||
# Stop polling for this bot if no longer needed
|
||||
if removed_type == "telegram_bot":
|
||||
from ..services.telegram_poller import stop_bot_if_unused
|
||||
await stop_bot_if_unused(removed_id)
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
async def _tracker_response(
|
||||
session: AsyncSession, t: CommandTracker, include_listeners: bool = False
|
||||
) -> dict:
|
||||
"""Build command tracker response."""
|
||||
# Get listener count
|
||||
result = await session.exec(
|
||||
select(func.count()).select_from(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == t.id
|
||||
)
|
||||
)
|
||||
listeners_count = result.one()
|
||||
|
||||
resp = {
|
||||
"id": t.id,
|
||||
"user_id": t.user_id,
|
||||
"provider_id": t.provider_id,
|
||||
"command_config_id": t.command_config_id,
|
||||
"name": t.name,
|
||||
"icon": t.icon,
|
||||
"enabled": t.enabled,
|
||||
"listeners_count": listeners_count,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
|
||||
if include_listeners:
|
||||
lr = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.command_tracker_id == t.id
|
||||
)
|
||||
)
|
||||
resp["listeners"] = [_listener_response(l) for l in lr.all()]
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
def _listener_response(l: CommandTrackerListener) -> dict:
|
||||
return {
|
||||
"id": l.id,
|
||||
"command_tracker_id": l.command_tracker_id,
|
||||
"listener_type": l.listener_type,
|
||||
"listener_id": l.listener_id,
|
||||
"created_at": l.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_tracker(
|
||||
session: AsyncSession, tracker_id: int, user_id: int
|
||||
) -> CommandTracker:
|
||||
tracker = await session.get(CommandTracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Command tracker not found")
|
||||
return tracker
|
||||
+28
-31
@@ -1,4 +1,4 @@
|
||||
"""Tracker-Target link management API routes."""
|
||||
"""Notification tracker-target link management API routes."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -12,10 +12,10 @@ from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import (
|
||||
NotificationTarget,
|
||||
NotificationTracker,
|
||||
NotificationTrackerTarget,
|
||||
ServiceProvider,
|
||||
TemplateConfig,
|
||||
Tracker,
|
||||
TrackerTarget,
|
||||
TrackingConfig,
|
||||
User,
|
||||
)
|
||||
@@ -23,50 +23,48 @@ from ..services.notifier import send_real_data_notification, send_test_notificat
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/trackers/{tracker_id}/targets", tags=["tracker-targets"])
|
||||
router = APIRouter(prefix="/api/notification-trackers/{tracker_id}/targets", tags=["notification-tracker-targets"])
|
||||
|
||||
|
||||
class TrackerTargetCreate(BaseModel):
|
||||
class NotificationTrackerTargetCreate(BaseModel):
|
||||
target_id: int
|
||||
tracking_config_id: int | None = None
|
||||
template_config_id: int | None = None
|
||||
enabled: bool = True
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
commands_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TrackerTargetUpdate(BaseModel):
|
||||
class NotificationTrackerTargetUpdate(BaseModel):
|
||||
tracking_config_id: int | None = None
|
||||
template_config_id: int | None = None
|
||||
enabled: bool | None = None
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
commands_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tracker_targets(
|
||||
async def list_notification_tracker_targets(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all target links for a tracker."""
|
||||
"""List all target links for a notification tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == tracker_id)
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == tracker_id)
|
||||
)
|
||||
return [await _tt_response(session, tt) for tt in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_tracker_target(
|
||||
async def create_notification_tracker_target(
|
||||
tracker_id: int,
|
||||
body: TrackerTargetCreate,
|
||||
body: NotificationTrackerTargetCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Link a target to a tracker with per-link configuration."""
|
||||
"""Link a target to a notification tracker with per-link configuration."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
|
||||
# Validate target exists and belongs to user
|
||||
@@ -76,9 +74,9 @@ async def create_tracker_target(
|
||||
|
||||
# Check for duplicate link
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(
|
||||
TrackerTarget.tracker_id == tracker_id,
|
||||
TrackerTarget.target_id == body.target_id,
|
||||
select(NotificationTrackerTarget).where(
|
||||
NotificationTrackerTarget.tracker_id == tracker_id,
|
||||
NotificationTrackerTarget.target_id == body.target_id,
|
||||
)
|
||||
)
|
||||
if result.first():
|
||||
@@ -97,7 +95,7 @@ async def create_tracker_target(
|
||||
if not tpc or (tpc.user_id != user.id and tpc.user_id != 0):
|
||||
raise HTTPException(status_code=404, detail="Template config not found")
|
||||
|
||||
tt = TrackerTarget(tracker_id=tracker_id, **body.model_dump())
|
||||
tt = NotificationTrackerTarget(tracker_id=tracker_id, **body.model_dump())
|
||||
session.add(tt)
|
||||
await session.commit()
|
||||
await session.refresh(tt)
|
||||
@@ -105,16 +103,16 @@ async def create_tracker_target(
|
||||
|
||||
|
||||
@router.put("/{tracker_target_id}")
|
||||
async def update_tracker_target(
|
||||
async def update_notification_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
body: TrackerTargetUpdate,
|
||||
body: NotificationTrackerTargetUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a tracker-target link's configuration."""
|
||||
"""Update a notification tracker-target link's configuration."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
tt = await session.get(TrackerTarget, tracker_target_id)
|
||||
tt = await session.get(NotificationTrackerTarget, tracker_target_id)
|
||||
if not tt or tt.tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||
|
||||
@@ -138,15 +136,15 @@ async def update_tracker_target(
|
||||
|
||||
|
||||
@router.delete("/{tracker_target_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tracker_target(
|
||||
async def delete_notification_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Remove a target link from a tracker."""
|
||||
"""Remove a target link from a notification tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
tt = await session.get(TrackerTarget, tracker_target_id)
|
||||
tt = await session.get(NotificationTrackerTarget, tracker_target_id)
|
||||
if not tt or tt.tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||
await session.delete(tt)
|
||||
@@ -154,7 +152,7 @@ async def delete_tracker_target(
|
||||
|
||||
|
||||
@router.post("/{tracker_target_id}/test/{test_type}")
|
||||
async def test_tracker_target(
|
||||
async def test_notification_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
test_type: str,
|
||||
@@ -171,7 +169,7 @@ async def test_tracker_target(
|
||||
raise HTTPException(status_code=400, detail=f"Invalid test type. Must be one of: {', '.join(valid_types)}")
|
||||
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
tt = await session.get(TrackerTarget, tracker_target_id)
|
||||
tt = await session.get(NotificationTrackerTarget, tracker_target_id)
|
||||
if not tt or tt.tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||
|
||||
@@ -224,7 +222,7 @@ async def test_tracker_target(
|
||||
return {"target": target.name, **r}
|
||||
|
||||
|
||||
async def _tt_response(session: AsyncSession, tt: TrackerTarget) -> dict:
|
||||
async def _tt_response(session: AsyncSession, tt: NotificationTrackerTarget) -> dict:
|
||||
"""Build tracker-target response with target details."""
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
return {
|
||||
@@ -239,15 +237,14 @@ async def _tt_response(session: AsyncSession, tt: TrackerTarget) -> dict:
|
||||
"enabled": tt.enabled,
|
||||
"quiet_hours_start": tt.quiet_hours_start,
|
||||
"quiet_hours_end": tt.quiet_hours_end,
|
||||
"commands_config": tt.commands_config,
|
||||
"created_at": tt.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_tracker(
|
||||
session: AsyncSession, tracker_id: int, user_id: int
|
||||
) -> Tracker:
|
||||
tracker = await session.get(Tracker, tracker_id)
|
||||
) -> NotificationTracker:
|
||||
tracker = await session.get(NotificationTracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
return tracker
|
||||
+26
-26
@@ -1,4 +1,4 @@
|
||||
"""Tracker management API routes."""
|
||||
"""Notification tracker management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -11,22 +11,21 @@ from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import (
|
||||
EventLog,
|
||||
NotificationTracker,
|
||||
NotificationTrackerState,
|
||||
NotificationTrackerTarget,
|
||||
ServiceProvider,
|
||||
Tracker,
|
||||
TrackerState,
|
||||
TrackerTarget,
|
||||
User,
|
||||
)
|
||||
from ..services.scheduler import schedule_tracker, unschedule_tracker
|
||||
from ..services.watcher import check_tracker
|
||||
from .tracker_targets import _tt_response
|
||||
from .notification_tracker_targets import _tt_response
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
|
||||
router = APIRouter(prefix="/api/notification-trackers", tags=["notification-trackers"])
|
||||
|
||||
|
||||
class TrackerCreate(BaseModel):
|
||||
class NotificationTrackerCreate(BaseModel):
|
||||
provider_id: int
|
||||
name: str
|
||||
icon: str = ""
|
||||
@@ -36,7 +35,7 @@ class TrackerCreate(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class TrackerUpdate(BaseModel):
|
||||
class NotificationTrackerUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
collection_ids: list[str] | None = None
|
||||
@@ -46,20 +45,20 @@ class TrackerUpdate(BaseModel):
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_trackers(
|
||||
async def list_notification_trackers(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.exec(
|
||||
select(Tracker).where(Tracker.user_id == user.id)
|
||||
select(NotificationTracker).where(NotificationTracker.user_id == user.id)
|
||||
)
|
||||
trackers = result.all()
|
||||
return [await _tracker_response(session, t) for t in trackers]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_tracker(
|
||||
body: TrackerCreate,
|
||||
async def create_notification_tracker(
|
||||
body: NotificationTrackerCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
@@ -67,7 +66,7 @@ async def create_tracker(
|
||||
if not provider or provider.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
tracker = Tracker(user_id=user.id, **body.model_dump())
|
||||
tracker = NotificationTracker(user_id=user.id, **body.model_dump())
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
@@ -77,7 +76,7 @@ async def create_tracker(
|
||||
|
||||
|
||||
@router.get("/{tracker_id}")
|
||||
async def get_tracker(
|
||||
async def get_notification_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -87,9 +86,9 @@ async def get_tracker(
|
||||
|
||||
|
||||
@router.put("/{tracker_id}")
|
||||
async def update_tracker(
|
||||
async def update_notification_tracker(
|
||||
tracker_id: int,
|
||||
body: TrackerUpdate,
|
||||
body: NotificationTrackerUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
@@ -107,7 +106,7 @@ async def update_tracker(
|
||||
|
||||
|
||||
@router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tracker(
|
||||
async def delete_notification_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -115,13 +114,13 @@ async def delete_tracker(
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
# Delete associated tracker-target links
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == tracker_id)
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == tracker_id)
|
||||
)
|
||||
for tt in result.all():
|
||||
await session.delete(tt)
|
||||
# Delete associated tracker state
|
||||
state_result = await session.exec(
|
||||
select(TrackerState).where(TrackerState.tracker_id == tracker_id)
|
||||
select(NotificationTrackerState).where(NotificationTrackerState.tracker_id == tracker_id)
|
||||
)
|
||||
for ts in state_result.all():
|
||||
await session.delete(ts)
|
||||
@@ -138,18 +137,19 @@ async def delete_tracker(
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/trigger")
|
||||
async def trigger_tracker(
|
||||
async def trigger_notification_tracker(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
from ..services.watcher import check_tracker
|
||||
result = await check_tracker(tracker.id)
|
||||
return {"triggered": True, "result": result}
|
||||
|
||||
|
||||
@router.get("/{tracker_id}/history")
|
||||
async def tracker_history(
|
||||
async def notification_tracker_history(
|
||||
tracker_id: int,
|
||||
limit: int = Query(default=20, ge=1, le=500),
|
||||
user: User = Depends(get_current_user),
|
||||
@@ -175,10 +175,10 @@ async def tracker_history(
|
||||
]
|
||||
|
||||
|
||||
async def _tracker_response(session: AsyncSession, t: Tracker) -> dict:
|
||||
async def _tracker_response(session: AsyncSession, t: NotificationTracker) -> dict:
|
||||
"""Build tracker response with nested tracker_targets."""
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == t.id)
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == t.id)
|
||||
)
|
||||
tracker_targets = [await _tt_response(session, tt) for tt in result.all()]
|
||||
|
||||
@@ -198,8 +198,8 @@ async def _tracker_response(session: AsyncSession, t: Tracker) -> dict:
|
||||
|
||||
async def _get_user_tracker(
|
||||
session: AsyncSession, tracker_id: int, user_id: int
|
||||
) -> Tracker:
|
||||
tracker = await session.get(Tracker, tracker_id)
|
||||
) -> NotificationTracker:
|
||||
tracker = await session.get(NotificationTracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
return tracker
|
||||
@@ -8,7 +8,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import NotificationTarget, ServiceProvider, Tracker, EventLog, User
|
||||
from ..database.models import NotificationTarget, NotificationTracker, ServiceProvider, EventLog, User
|
||||
|
||||
router = APIRouter(prefix="/api/status", tags=["status"])
|
||||
|
||||
@@ -31,7 +31,7 @@ async def get_status(
|
||||
)).one()
|
||||
|
||||
trackers_result = await session.exec(
|
||||
select(Tracker).where(Tracker.user_id == user.id)
|
||||
select(NotificationTracker).where(NotificationTracker.user_id == user.id)
|
||||
)
|
||||
trackers = trackers_result.all()
|
||||
active_count = sum(1 for t in trackers if t.enabled)
|
||||
@@ -43,8 +43,8 @@ async def get_status(
|
||||
# Build events query with filters
|
||||
events_query = (
|
||||
select(EventLog)
|
||||
.join(Tracker, EventLog.tracker_id == Tracker.id)
|
||||
.where(Tracker.user_id == user.id)
|
||||
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
|
||||
.where(NotificationTracker.user_id == user.id)
|
||||
)
|
||||
|
||||
if event_type:
|
||||
@@ -110,8 +110,8 @@ async def get_event_chart(
|
||||
EventLog.event_type,
|
||||
func.count().label("total"),
|
||||
)
|
||||
.join(Tracker, EventLog.tracker_id == Tracker.id)
|
||||
.where(Tracker.user_id == user.id, EventLog.created_at >= cutoff)
|
||||
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
|
||||
.where(NotificationTracker.user_id == user.id, EventLog.created_at >= cutoff)
|
||||
.group_by(day_col, EventLog.event_type)
|
||||
.order_by(day_col)
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Any
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import NotificationTarget, TelegramBot, TelegramChat, TrackerTarget, User
|
||||
from ..database.models import NotificationTarget, NotificationTrackerTarget, TelegramBot, TelegramChat, User
|
||||
from ..services.notifier import send_test_notification
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -23,12 +23,14 @@ class TargetCreate(BaseModel):
|
||||
name: str
|
||||
icon: str = ""
|
||||
config: dict[str, Any] = {}
|
||||
chat_action: str | None = None
|
||||
|
||||
|
||||
class TargetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
config: dict[str, Any] | None = None
|
||||
chat_action: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -80,6 +82,7 @@ async def create_target(
|
||||
name=body.name,
|
||||
icon=body.icon,
|
||||
config=body.config,
|
||||
chat_action=body.chat_action,
|
||||
)
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
@@ -125,7 +128,7 @@ async def delete_target(
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
# Delete associated tracker-target links
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.target_id == target_id)
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.target_id == target_id)
|
||||
)
|
||||
for tt in result.all():
|
||||
await session.delete(tt)
|
||||
@@ -153,6 +156,7 @@ def _target_response(target: NotificationTarget, chat_names: dict[str, str] | No
|
||||
"name": target.name,
|
||||
"icon": target.icon,
|
||||
"config": _safe_config(target),
|
||||
"chat_action": target.chat_action,
|
||||
"created_at": target.created_at.isoformat(),
|
||||
}
|
||||
# Attach resolved chat name for telegram targets
|
||||
|
||||
@@ -34,7 +34,6 @@ class BotUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
update_mode: str | None = None
|
||||
commands_config: dict | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -86,9 +85,6 @@ async def update_bot(
|
||||
bot.name = body.name
|
||||
if body.icon is not None:
|
||||
bot.icon = body.icon
|
||||
if body.commands_config is not None:
|
||||
bot.commands_config = body.commands_config
|
||||
|
||||
# Handle mode switching
|
||||
if body.update_mode is not None and body.update_mode != bot.update_mode:
|
||||
if body.update_mode == "webhook":
|
||||
@@ -403,7 +399,6 @@ def _bot_response(b: TelegramBot) -> dict:
|
||||
"bot_id": b.bot_id,
|
||||
"webhook_path_id": b.webhook_path_id,
|
||||
"update_mode": b.update_mode or "polling",
|
||||
"commands_config": b.commands_config or {},
|
||||
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
|
||||
"created_at": b.created_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -16,12 +16,15 @@ from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_UR
|
||||
from ..database.engine import get_engine
|
||||
from ..services import make_immich_provider
|
||||
from ..database.models import (
|
||||
CommandConfig,
|
||||
CommandTracker,
|
||||
CommandTrackerListener,
|
||||
EventLog,
|
||||
NotificationTarget,
|
||||
NotificationTracker,
|
||||
NotificationTrackerTarget,
|
||||
ServiceProvider,
|
||||
TelegramBot,
|
||||
Tracker,
|
||||
TrackerTarget,
|
||||
TrackingConfig,
|
||||
)
|
||||
from .parser import parse_command
|
||||
@@ -48,6 +51,70 @@ def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_command_context(
|
||||
bot: TelegramBot,
|
||||
) -> list[tuple[CommandTracker, CommandConfig, ServiceProvider]]:
|
||||
"""Resolve all enabled command trackers, configs, and providers for a bot.
|
||||
|
||||
Finds CommandTrackerListener rows where listener_type="telegram_bot"
|
||||
and listener_id=bot.id, then loads the full chain:
|
||||
CommandTrackerListener -> CommandTracker (enabled) -> CommandConfig + ServiceProvider.
|
||||
"""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
# Find all listeners for this bot
|
||||
result = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.listener_type == "telegram_bot",
|
||||
CommandTrackerListener.listener_id == bot.id,
|
||||
)
|
||||
)
|
||||
listeners = result.all()
|
||||
|
||||
if not listeners:
|
||||
return []
|
||||
|
||||
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]] = []
|
||||
for listener in listeners:
|
||||
tracker = await session.get(CommandTracker, listener.command_tracker_id)
|
||||
if not tracker or not tracker.enabled:
|
||||
continue
|
||||
config = await session.get(CommandConfig, tracker.command_config_id)
|
||||
if not config:
|
||||
continue
|
||||
provider = await session.get(ServiceProvider, tracker.provider_id)
|
||||
if not provider:
|
||||
continue
|
||||
tuples.append((tracker, config, provider))
|
||||
|
||||
return tuples
|
||||
|
||||
|
||||
def _merge_command_context(
|
||||
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
|
||||
) -> tuple[list[str], str, str, int, dict[str, Any]]:
|
||||
"""Merge enabled_commands from all configs and pick defaults from first config.
|
||||
|
||||
Returns (enabled_commands, locale, response_mode, default_count, rate_limits).
|
||||
"""
|
||||
if not ctx:
|
||||
return [], "en", "media", 5, {}
|
||||
|
||||
# Union of all enabled commands across configs
|
||||
enabled: set[str] = set()
|
||||
for _, config, _ in ctx:
|
||||
enabled.update(config.enabled_commands or [])
|
||||
|
||||
# Use first config's settings as defaults
|
||||
first_config = ctx[0][1]
|
||||
locale = first_config.locale or "en"
|
||||
response_mode = first_config.response_mode or "media"
|
||||
default_count = first_config.default_count or 5
|
||||
rate_limits = first_config.rate_limits or {}
|
||||
|
||||
return sorted(enabled), locale, response_mode, default_count, rate_limits
|
||||
|
||||
|
||||
async def handle_command(
|
||||
bot: TelegramBot,
|
||||
chat_id: str,
|
||||
@@ -58,11 +125,8 @@ async def handle_command(
|
||||
if not cmd:
|
||||
return None
|
||||
|
||||
config = bot.commands_config or {}
|
||||
enabled = config.get("enabled", [])
|
||||
default_count = min(config.get("default_count", 5), 20)
|
||||
locale = config.get("locale", "en")
|
||||
rate_limits = config.get("rate_limits", {})
|
||||
ctx = await _resolve_command_context(bot)
|
||||
enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx)
|
||||
|
||||
if cmd == "start":
|
||||
msgs = {
|
||||
@@ -85,20 +149,25 @@ async def handle_command(
|
||||
|
||||
count = min(count_override or default_count, 20)
|
||||
|
||||
# Build providers map from command context
|
||||
providers_map: dict[int, ServiceProvider] = {}
|
||||
for _, _, provider in ctx:
|
||||
providers_map[provider.id] = provider
|
||||
|
||||
# Dispatch
|
||||
if cmd == "help":
|
||||
return _cmd_help(enabled, locale)
|
||||
if cmd == "status":
|
||||
return await _cmd_status(bot, locale)
|
||||
return await _cmd_status(bot, providers_map, locale)
|
||||
if cmd == "albums":
|
||||
return await _cmd_albums(bot, locale)
|
||||
return await _cmd_albums(bot, providers_map, locale)
|
||||
if cmd == "events":
|
||||
return await _cmd_events(bot, count, locale)
|
||||
return await _cmd_events(bot, providers_map, count, locale)
|
||||
if cmd == "people":
|
||||
return await _cmd_people(bot, locale)
|
||||
return await _cmd_people(providers_map, locale)
|
||||
if cmd in ("search", "find", "person", "place", "latest", "random",
|
||||
"favorites", "summary", "memory"):
|
||||
return await _cmd_immich(bot, cmd, args, count, locale)
|
||||
return await _cmd_immich(bot, cmd, args, count, locale, response_mode, providers_map)
|
||||
|
||||
return None
|
||||
|
||||
@@ -112,50 +181,24 @@ def _cmd_help(enabled: list[str], locale: str) -> str:
|
||||
return header.get(locale, header["en"]) + "\n" + "\n".join(lines)
|
||||
|
||||
|
||||
async def _get_bot_context(bot: TelegramBot) -> tuple[
|
||||
list[Tracker], dict[int, ServiceProvider]
|
||||
]:
|
||||
"""Get trackers and providers associated with a bot via its targets."""
|
||||
async def _get_notification_trackers_for_providers(
|
||||
provider_ids: set[int],
|
||||
) -> list[NotificationTracker]:
|
||||
"""Get notification trackers for the given provider IDs.
|
||||
|
||||
Used by commands like albums, events, status that need notification
|
||||
tracker data (collection_ids, event logs).
|
||||
"""
|
||||
if not provider_ids:
|
||||
return []
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
# Find targets that use this bot's token
|
||||
result = await session.exec(
|
||||
select(NotificationTarget).where(
|
||||
NotificationTarget.type == "telegram",
|
||||
NotificationTarget.user_id == bot.user_id,
|
||||
select(NotificationTracker).where(
|
||||
NotificationTracker.provider_id.in_(provider_ids)
|
||||
)
|
||||
)
|
||||
targets = result.all()
|
||||
bot_target_ids = {t.id for t in targets if t.config.get("bot_token") == bot.token}
|
||||
|
||||
if not bot_target_ids:
|
||||
return [], {}
|
||||
|
||||
# Find trackers linked to these targets via TrackerTarget
|
||||
tt_result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.target_id.in_(bot_target_ids))
|
||||
)
|
||||
all_links = tt_result.all()
|
||||
tracker_ids = {tt.tracker_id for tt in all_links}
|
||||
|
||||
if not tracker_ids:
|
||||
return [], {}
|
||||
|
||||
trackers = []
|
||||
provider_ids = set()
|
||||
for tid in tracker_ids:
|
||||
tracker = await session.get(Tracker, tid)
|
||||
if tracker:
|
||||
trackers.append(tracker)
|
||||
provider_ids.add(tracker.provider_id)
|
||||
|
||||
providers_map: dict[int, ServiceProvider] = {}
|
||||
for pid in provider_ids:
|
||||
provider = await session.get(ServiceProvider, pid)
|
||||
if provider:
|
||||
providers_map[pid] = provider
|
||||
|
||||
return trackers, providers_map
|
||||
return list(result.all())
|
||||
|
||||
|
||||
async def _check_native_memory(bot: TelegramBot) -> bool:
|
||||
@@ -173,7 +216,7 @@ async def _check_native_memory(bot: TelegramBot) -> bool:
|
||||
if not bot_target_ids:
|
||||
return False
|
||||
tt_result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.target_id.in_(bot_target_ids))
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.target_id.in_(bot_target_ids))
|
||||
)
|
||||
for tt in tt_result.all():
|
||||
if tt.tracking_config_id:
|
||||
@@ -183,8 +226,9 @@ async def _check_native_memory(bot: TelegramBot) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def _cmd_status(bot: TelegramBot, locale: str) -> str:
|
||||
trackers, _ = await _get_bot_context(bot)
|
||||
async def _cmd_status(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> str:
|
||||
provider_ids = set(providers_map.keys())
|
||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
active = sum(1 for t in trackers if t.enabled)
|
||||
total = len(trackers)
|
||||
total_albums = sum(len(t.collection_ids or []) for t in trackers)
|
||||
@@ -212,8 +256,9 @@ async def _cmd_status(bot: TelegramBot, locale: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
async def _cmd_albums(bot: TelegramBot, locale: str) -> str:
|
||||
trackers, providers_map = await _get_bot_context(bot)
|
||||
async def _cmd_albums(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> str:
|
||||
provider_ids = set(providers_map.keys())
|
||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
if not trackers:
|
||||
return "No tracked albums." if locale == "en" else "Нет отслеживаемых альбомов."
|
||||
|
||||
@@ -236,8 +281,9 @@ async def _cmd_albums(bot: TelegramBot, locale: str) -> str:
|
||||
return header + "\n" + "\n".join(lines) if lines else header + "\n (none)"
|
||||
|
||||
|
||||
async def _cmd_events(bot: TelegramBot, count: int, locale: str) -> str:
|
||||
trackers, _ = await _get_bot_context(bot)
|
||||
async def _cmd_events(bot: TelegramBot, providers_map: dict[int, ServiceProvider], count: int, locale: str) -> str:
|
||||
provider_ids = set(providers_map.keys())
|
||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
tracker_ids = [t.id for t in trackers]
|
||||
if not tracker_ids:
|
||||
return "No events." if locale == "en" else "Нет событий."
|
||||
@@ -263,8 +309,7 @@ async def _cmd_events(bot: TelegramBot, count: int, locale: str) -> str:
|
||||
return header + "\n" + "\n".join(lines)
|
||||
|
||||
|
||||
async def _cmd_people(bot: TelegramBot, locale: str) -> str:
|
||||
_, providers_map = await _get_bot_context(bot)
|
||||
async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) -> str:
|
||||
all_people: dict[str, str] = {}
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
@@ -285,23 +330,28 @@ async def _cmd_people(bot: TelegramBot, locale: str) -> str:
|
||||
|
||||
async def _cmd_immich(
|
||||
bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
|
||||
response_mode: str, providers_map: dict[int, ServiceProvider],
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Handle commands that need Immich API access and may return media."""
|
||||
trackers, providers_map = await _get_bot_context(bot)
|
||||
if not trackers:
|
||||
if not providers_map:
|
||||
return "No trackers configured." if locale == "en" else "Трекеры не настроены."
|
||||
|
||||
# Get notification trackers for album data
|
||||
provider_ids = set(providers_map.keys())
|
||||
notification_trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
|
||||
all_album_ids: list[str] = []
|
||||
for t in trackers:
|
||||
for t in notification_trackers:
|
||||
all_album_ids.extend(t.collection_ids or [])
|
||||
|
||||
first_tracker = trackers[0]
|
||||
provider = providers_map.get(first_tracker.provider_id)
|
||||
if not provider or provider.type != "immich":
|
||||
# Pick the first immich provider
|
||||
provider: ServiceProvider | None = None
|
||||
for p in providers_map.values():
|
||||
if p.type == "immich":
|
||||
provider = p
|
||||
break
|
||||
if not provider:
|
||||
return "Server not found." if locale == "en" else "Сервер не найден."
|
||||
|
||||
config = bot.commands_config or {}
|
||||
response_mode = config.get("response_mode", "media")
|
||||
async with aiohttp.ClientSession() as http:
|
||||
immich = make_immich_provider(http, provider)
|
||||
client = immich.client
|
||||
@@ -578,10 +628,13 @@ async def send_media_group(
|
||||
|
||||
|
||||
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
||||
"""Register enabled commands with Telegram BotFather API."""
|
||||
config = bot.commands_config or {}
|
||||
enabled = config.get("enabled", [])
|
||||
locale = config.get("locale", "en")
|
||||
"""Register enabled commands with Telegram BotFather API.
|
||||
|
||||
Resolves all command trackers and configs for this bot, merges
|
||||
enabled commands (union), and calls setMyCommands.
|
||||
"""
|
||||
ctx = await _resolve_command_context(bot)
|
||||
enabled, locale, _, _, _ = _merge_command_context(ctx)
|
||||
|
||||
commands = []
|
||||
for cmd in enabled:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Data migrations for schema changes.
|
||||
|
||||
Handles converting legacy JSON-array relationships to proper junction tables.
|
||||
Handles converting legacy JSON-array relationships to proper junction tables,
|
||||
and the Phase 1 entity refactor (tracker → notification_tracker, etc.).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
@@ -11,97 +13,133 @@ from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _has_column(conn, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a SQLite table."""
|
||||
cols = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text(f"PRAGMA table_info('{table}')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
return column in cols
|
||||
|
||||
|
||||
async def _has_table(conn, table: str) -> bool:
|
||||
"""Check if a table exists in the SQLite database."""
|
||||
result = await conn.run_sync(
|
||||
lambda sync_conn: sync_conn.execute(
|
||||
text(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name=:name"
|
||||
),
|
||||
{"name": table},
|
||||
).fetchone()
|
||||
)
|
||||
return result is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy schema migrations (pre-Phase 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
"""Add missing columns to existing tables (SQLite ALTER TABLE ADD COLUMN)."""
|
||||
async with engine.begin() as conn:
|
||||
# Helper to check if column exists
|
||||
async def _has_column(table: str, column: str) -> bool:
|
||||
cols = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text(f"PRAGMA table_info('{table}')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
return column in cols
|
||||
# --- Tracker table (may still be named "tracker" or already renamed) ---
|
||||
tracker_table = "notification_tracker" if await _has_table(conn, "notification_tracker") else "tracker"
|
||||
|
||||
# Add batch_duration to tracker if missing
|
||||
if not await _has_column("tracker", "batch_duration"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracker ADD COLUMN batch_duration INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added batch_duration column to tracker table")
|
||||
if await _has_table(conn, tracker_table):
|
||||
if not await _has_column(conn, tracker_table, "batch_duration"):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE {tracker_table} ADD COLUMN batch_duration INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added batch_duration column to %s table", tracker_table)
|
||||
|
||||
# Add enriched fields to event_log if missing
|
||||
for col, sql in [
|
||||
("tracker_name", "ALTER TABLE event_log ADD COLUMN tracker_name TEXT DEFAULT ''"),
|
||||
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
|
||||
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
|
||||
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
|
||||
]:
|
||||
if not await _has_column("event_log", col):
|
||||
await conn.execute(text(sql))
|
||||
logger.info("Added %s column to event_log table", col)
|
||||
if await _has_table(conn, "event_log"):
|
||||
for col, sql in [
|
||||
("tracker_name", "ALTER TABLE event_log ADD COLUMN tracker_name TEXT DEFAULT ''"),
|
||||
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
|
||||
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
|
||||
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
|
||||
]:
|
||||
if not await _has_column(conn, "event_log", col):
|
||||
await conn.execute(text(sql))
|
||||
logger.info("Added %s column to event_log table", col)
|
||||
|
||||
# Add commands_config to telegram_bot if missing
|
||||
if not await _has_column("telegram_bot", "commands_config"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN commands_config TEXT DEFAULT '{}'")
|
||||
)
|
||||
logger.info("Added commands_config column to telegram_bot table")
|
||||
|
||||
# Add webhook_path_id to telegram_bot if missing
|
||||
if not await _has_column("telegram_bot", "webhook_path_id"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN webhook_path_id TEXT DEFAULT ''")
|
||||
)
|
||||
logger.info("Added webhook_path_id column to telegram_bot table")
|
||||
# Backfill existing bots with unique IDs
|
||||
import uuid
|
||||
bots = (await conn.execute(text("SELECT id FROM telegram_bot"))).fetchall()
|
||||
for bot in bots:
|
||||
if await _has_table(conn, "telegram_bot"):
|
||||
if not await _has_column(conn, "telegram_bot", "commands_config"):
|
||||
await conn.execute(
|
||||
text("UPDATE telegram_bot SET webhook_path_id = :wid WHERE id = :bid"),
|
||||
{"wid": uuid.uuid4().hex, "bid": bot[0]},
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN commands_config TEXT DEFAULT '{}'")
|
||||
)
|
||||
if bots:
|
||||
logger.info("Backfilled webhook_path_id for %d existing bots", len(bots))
|
||||
logger.info("Added commands_config column to telegram_bot table")
|
||||
|
||||
# Add webhook_path_id to telegram_bot if missing
|
||||
if not await _has_column(conn, "telegram_bot", "webhook_path_id"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN webhook_path_id TEXT DEFAULT ''")
|
||||
)
|
||||
logger.info("Added webhook_path_id column to telegram_bot table")
|
||||
# Backfill existing bots with unique IDs
|
||||
import uuid
|
||||
bots = (await conn.execute(text("SELECT id FROM telegram_bot"))).fetchall()
|
||||
for bot in bots:
|
||||
await conn.execute(
|
||||
text("UPDATE telegram_bot SET webhook_path_id = :wid WHERE id = :bid"),
|
||||
{"wid": uuid.uuid4().hex, "bid": bot[0]},
|
||||
)
|
||||
if bots:
|
||||
logger.info("Backfilled webhook_path_id for %d existing bots", len(bots))
|
||||
|
||||
# Add update_mode to telegram_bot if missing
|
||||
if not await _has_column(conn, "telegram_bot", "update_mode"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN update_mode TEXT DEFAULT 'polling'")
|
||||
)
|
||||
logger.info("Added update_mode column to telegram_bot table")
|
||||
|
||||
# Add date_only_format to template_config if missing
|
||||
if not await _has_column("template_config", "date_only_format"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE template_config ADD COLUMN date_only_format TEXT DEFAULT '%d.%m.%Y'")
|
||||
)
|
||||
logger.info("Added date_only_format column to template_config table")
|
||||
|
||||
# Add update_mode to telegram_bot if missing
|
||||
if not await _has_column("telegram_bot", "update_mode"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE telegram_bot ADD COLUMN update_mode TEXT DEFAULT 'polling'")
|
||||
)
|
||||
logger.info("Added update_mode column to telegram_bot table")
|
||||
if await _has_table(conn, "template_config"):
|
||||
if not await _has_column(conn, "template_config", "date_only_format"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE template_config ADD COLUMN date_only_format TEXT DEFAULT '%d.%m.%Y'")
|
||||
)
|
||||
logger.info("Added date_only_format column to template_config table")
|
||||
|
||||
# Add memory_source to tracking_config if missing
|
||||
if not await _has_column("tracking_config", "memory_source"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracking_config ADD COLUMN memory_source TEXT DEFAULT 'albums'")
|
||||
)
|
||||
logger.info("Added memory_source column to tracking_config table")
|
||||
if await _has_table(conn, "tracking_config"):
|
||||
if not await _has_column(conn, "tracking_config", "memory_source"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracking_config ADD COLUMN memory_source TEXT DEFAULT 'albums'")
|
||||
)
|
||||
logger.info("Added memory_source column to tracking_config table")
|
||||
|
||||
# Add collection_name and shared to tracker_state if missing
|
||||
if not await _has_column("tracker_state", "collection_name"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracker_state ADD COLUMN collection_name TEXT DEFAULT ''")
|
||||
)
|
||||
logger.info("Added collection_name column to tracker_state table")
|
||||
if not await _has_column("tracker_state", "shared"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracker_state ADD COLUMN shared INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added shared column to tracker_state table")
|
||||
state_table = "notification_tracker_state" if await _has_table(conn, "notification_tracker_state") else "tracker_state"
|
||||
if await _has_table(conn, state_table):
|
||||
if not await _has_column(conn, state_table, "collection_name"):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE {state_table} ADD COLUMN collection_name TEXT DEFAULT ''")
|
||||
)
|
||||
logger.info("Added collection_name column to %s table", state_table)
|
||||
if not await _has_column(conn, state_table, "shared"):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE {state_table} ADD COLUMN shared INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added shared column to %s table", state_table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy tracker_target migration (pre-Phase 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
"""Migrate legacy Tracker.target_ids JSON arrays to TrackerTarget rows.
|
||||
|
||||
@@ -114,36 +152,42 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
Idempotent: skips if legacy columns don't exist or data already migrated.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
# Check if legacy target_ids column exists on tracker table
|
||||
columns = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text("PRAGMA table_info('tracker')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
if "target_ids" not in columns:
|
||||
# Determine which table name exists (pre- or post-rename)
|
||||
if await _has_table(conn, "tracker"):
|
||||
tracker_table = "tracker"
|
||||
tt_table = "tracker_target"
|
||||
tracker_id_col = "tracker_id"
|
||||
elif await _has_table(conn, "notification_tracker"):
|
||||
tracker_table = "notification_tracker"
|
||||
tt_table = "notification_tracker_target"
|
||||
tracker_id_col = "notification_tracker_id"
|
||||
else:
|
||||
logger.debug("No tracker table found — skipping migration")
|
||||
return
|
||||
|
||||
# Check if legacy target_ids column exists
|
||||
if not await _has_column(conn, tracker_table, "target_ids"):
|
||||
logger.debug("No legacy target_ids column found — skipping migration")
|
||||
return
|
||||
|
||||
# Check if tracker_target table already has data (previous migration ran)
|
||||
tt_count = (
|
||||
await conn.execute(text("SELECT COUNT(*) FROM tracker_target"))
|
||||
).scalar()
|
||||
if tt_count and tt_count > 0:
|
||||
logger.debug(
|
||||
"tracker_target table already has %d rows — skipping migration",
|
||||
tt_count,
|
||||
)
|
||||
return
|
||||
# Check if junction table already has data
|
||||
if await _has_table(conn, tt_table):
|
||||
tt_count = (
|
||||
await conn.execute(text(f"SELECT COUNT(*) FROM {tt_table}"))
|
||||
).scalar()
|
||||
if tt_count and tt_count > 0:
|
||||
logger.debug(
|
||||
"%s table already has %d rows — skipping migration",
|
||||
tt_table, tt_count,
|
||||
)
|
||||
return
|
||||
|
||||
# Load legacy data
|
||||
trackers = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT id, target_ids, tracking_config_id, "
|
||||
"quiet_hours_start, quiet_hours_end FROM tracker"
|
||||
f"SELECT id, target_ids, tracking_config_id, "
|
||||
f"quiet_hours_start, quiet_hours_end FROM {tracker_table}"
|
||||
)
|
||||
)
|
||||
).fetchall()
|
||||
@@ -154,20 +198,10 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
|
||||
# Load template_config_id from targets (legacy field)
|
||||
target_template_map: dict[int, int | None] = {}
|
||||
target_cols = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text("PRAGMA table_info('notification_target')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
if "template_config_id" in target_cols:
|
||||
if await _has_column(conn, "notification_target", "template_config_id"):
|
||||
targets = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT id, template_config_id FROM notification_target"
|
||||
)
|
||||
text("SELECT id, template_config_id FROM notification_target")
|
||||
)
|
||||
).fetchall()
|
||||
for t in targets:
|
||||
@@ -175,15 +209,7 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
|
||||
# Load commands_config from telegram_bots (legacy field)
|
||||
bot_commands_map: dict[int, str | None] = {}
|
||||
bot_cols = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
row[1]
|
||||
for row in sync_conn.execute(
|
||||
text("PRAGMA table_info('telegram_bot')")
|
||||
).fetchall()
|
||||
]
|
||||
)
|
||||
if "commands_config" in bot_cols:
|
||||
if await _has_column(conn, "telegram_bot", "commands_config"):
|
||||
bots = (
|
||||
await conn.execute(
|
||||
text("SELECT id, commands_config FROM telegram_bot")
|
||||
@@ -195,8 +221,6 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
# Build target → bot mapping for commands_config migration
|
||||
target_bot_map: dict[int, int] = {}
|
||||
if bot_commands_map:
|
||||
import json
|
||||
|
||||
tgt_rows = (
|
||||
await conn.execute(
|
||||
text("SELECT id, config FROM notification_target WHERE type='telegram'")
|
||||
@@ -207,35 +231,21 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
cfg = json.loads(tgt[1]) if isinstance(tgt[1], str) else tgt[1]
|
||||
if cfg and "bot_token" in cfg:
|
||||
for bot_id, _ in bot_commands_map.items():
|
||||
bot_row = (
|
||||
bot_token_row = (
|
||||
await conn.execute(
|
||||
text("SELECT id FROM telegram_bot WHERE id=:bid"),
|
||||
text("SELECT token FROM telegram_bot WHERE id=:bid"),
|
||||
{"bid": bot_id},
|
||||
)
|
||||
).fetchone()
|
||||
if bot_row:
|
||||
# Match by checking if this target uses this bot's token
|
||||
bot_token_row = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT token FROM telegram_bot WHERE id=:bid"
|
||||
),
|
||||
{"bid": bot_id},
|
||||
)
|
||||
).fetchone()
|
||||
if bot_token_row and bot_token_row[0] == cfg.get(
|
||||
"bot_token"
|
||||
):
|
||||
target_bot_map[tgt[0]] = bot_id
|
||||
if bot_token_row and bot_token_row[0] == cfg.get("bot_token"):
|
||||
target_bot_map[tgt[0]] = bot_id
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to match bot token for target %s", tgt[0],
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Create TrackerTarget rows
|
||||
import json
|
||||
|
||||
# Create junction rows
|
||||
migrated = 0
|
||||
for tracker in trackers:
|
||||
tracker_id = tracker[0]
|
||||
@@ -244,7 +254,6 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
quiet_hours_start = tracker[3]
|
||||
quiet_hours_end = tracker[4]
|
||||
|
||||
# Parse target_ids JSON
|
||||
if isinstance(raw_target_ids, str):
|
||||
try:
|
||||
target_ids = json.loads(raw_target_ids)
|
||||
@@ -258,25 +267,22 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
for target_id in target_ids:
|
||||
template_config_id = target_template_map.get(target_id)
|
||||
|
||||
# Get commands_config if this is a telegram target with a known bot
|
||||
commands_config = None
|
||||
if target_id in target_bot_map:
|
||||
bot_id = target_bot_map[target_id]
|
||||
raw_cmd = bot_commands_map.get(bot_id)
|
||||
if raw_cmd:
|
||||
commands_config = (
|
||||
raw_cmd
|
||||
if isinstance(raw_cmd, str)
|
||||
else json.dumps(raw_cmd)
|
||||
raw_cmd if isinstance(raw_cmd, str) else json.dumps(raw_cmd)
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO tracker_target "
|
||||
"(tracker_id, target_id, tracking_config_id, "
|
||||
"template_config_id, enabled, quiet_hours_start, "
|
||||
"quiet_hours_end, commands_config) "
|
||||
"VALUES (:tid, :tgtid, :tcid, :tmplid, 1, :qhs, :qhe, :cmd)"
|
||||
f"INSERT INTO {tt_table} "
|
||||
f"({tracker_id_col}, target_id, tracking_config_id, "
|
||||
f"template_config_id, enabled, quiet_hours_start, "
|
||||
f"quiet_hours_end, commands_config) "
|
||||
f"VALUES (:tid, :tgtid, :tcid, :tmplid, 1, :qhs, :qhe, :cmd)"
|
||||
),
|
||||
{
|
||||
"tid": tracker_id,
|
||||
@@ -291,3 +297,243 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
migrated += 1
|
||||
|
||||
logger.info("Migrated %d tracker-target links", migrated)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1: Entity refactor migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def migrate_entity_refactor(engine: AsyncEngine) -> None:
|
||||
"""Phase 1 entity refactor — rename tables, add columns, create new tables.
|
||||
|
||||
Fully idempotent: every operation checks preconditions before acting.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Rename table: tracker → notification_tracker
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "tracker") and not await _has_table(conn, "notification_tracker"):
|
||||
await conn.execute(text("ALTER TABLE tracker RENAME TO notification_tracker"))
|
||||
logger.info("Renamed table tracker → notification_tracker")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Rename table: tracker_target → notification_tracker_target
|
||||
# and rename column tracker_id → notification_tracker_id
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "tracker_target") and not await _has_table(conn, "notification_tracker_target"):
|
||||
# SQLite doesn't support RENAME COLUMN in older versions, so we
|
||||
# recreate the table with the new column name.
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE notification_tracker_target ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
|
||||
" target_id INTEGER REFERENCES notification_target(id),"
|
||||
" tracking_config_id INTEGER REFERENCES tracking_config(id),"
|
||||
" template_config_id INTEGER REFERENCES template_config(id),"
|
||||
" enabled INTEGER DEFAULT 1,"
|
||||
" quiet_hours_start TEXT,"
|
||||
" quiet_hours_end TEXT,"
|
||||
" commands_config TEXT,"
|
||||
" created_at TIMESTAMP"
|
||||
")"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"INSERT INTO notification_tracker_target "
|
||||
"(id, notification_tracker_id, target_id, tracking_config_id, "
|
||||
"template_config_id, enabled, quiet_hours_start, quiet_hours_end, "
|
||||
"commands_config, created_at) "
|
||||
"SELECT id, tracker_id, target_id, tracking_config_id, "
|
||||
"template_config_id, enabled, quiet_hours_start, quiet_hours_end, "
|
||||
"commands_config, created_at "
|
||||
"FROM tracker_target"
|
||||
))
|
||||
await conn.execute(text("DROP TABLE tracker_target"))
|
||||
logger.info("Renamed table tracker_target → notification_tracker_target (with column rename tracker_id → notification_tracker_id)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Rename table: tracker_state → notification_tracker_state
|
||||
# and rename column tracker_id → notification_tracker_id
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "tracker_state") and not await _has_table(conn, "notification_tracker_state"):
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE notification_tracker_state ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
|
||||
" collection_id TEXT,"
|
||||
" collection_name TEXT DEFAULT '',"
|
||||
" shared INTEGER DEFAULT 0,"
|
||||
" asset_ids TEXT,"
|
||||
" pending_asset_ids TEXT,"
|
||||
" last_updated TIMESTAMP"
|
||||
")"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"INSERT INTO notification_tracker_state "
|
||||
"(id, notification_tracker_id, collection_id, collection_name, "
|
||||
"shared, asset_ids, pending_asset_ids, last_updated) "
|
||||
"SELECT id, tracker_id, collection_id, collection_name, "
|
||||
"shared, asset_ids, pending_asset_ids, last_updated "
|
||||
"FROM tracker_state"
|
||||
))
|
||||
await conn.execute(text("DROP TABLE tracker_state"))
|
||||
logger.info("Renamed table tracker_state → notification_tracker_state (with column rename tracker_id → notification_tracker_id)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Add chat_action column to notification_target
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "notification_target"):
|
||||
if not await _has_column(conn, "notification_target", "chat_action"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE notification_target ADD COLUMN chat_action TEXT")
|
||||
)
|
||||
logger.info("Added chat_action column to notification_target table")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Rename tracker_id → notification_tracker_id in event_log
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "event_log"):
|
||||
if await _has_column(conn, "event_log", "tracker_id") and not await _has_column(conn, "event_log", "notification_tracker_id"):
|
||||
# Recreate event_log with renamed column
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE event_log_new ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
|
||||
" tracker_name TEXT DEFAULT '',"
|
||||
" provider_id INTEGER,"
|
||||
" provider_name TEXT DEFAULT '',"
|
||||
" event_type TEXT,"
|
||||
" collection_id TEXT,"
|
||||
" collection_name TEXT,"
|
||||
" assets_count INTEGER DEFAULT 0,"
|
||||
" details TEXT,"
|
||||
" created_at TIMESTAMP"
|
||||
")"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"INSERT INTO event_log_new "
|
||||
"(id, notification_tracker_id, tracker_name, provider_id, "
|
||||
"provider_name, event_type, collection_id, collection_name, "
|
||||
"assets_count, details, created_at) "
|
||||
"SELECT id, tracker_id, tracker_name, provider_id, "
|
||||
"provider_name, event_type, collection_id, collection_name, "
|
||||
"assets_count, details, created_at "
|
||||
"FROM event_log"
|
||||
))
|
||||
await conn.execute(text("DROP TABLE event_log"))
|
||||
await conn.execute(text("ALTER TABLE event_log_new RENAME TO event_log"))
|
||||
logger.info("Renamed column tracker_id → notification_tracker_id in event_log")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Create command_config table
|
||||
# ------------------------------------------------------------------
|
||||
if not await _has_table(conn, "command_config"):
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE command_config ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" user_id INTEGER NOT NULL REFERENCES user(id),"
|
||||
" provider_type TEXT NOT NULL,"
|
||||
" name TEXT NOT NULL,"
|
||||
" icon TEXT DEFAULT '',"
|
||||
" enabled_commands TEXT DEFAULT '[]',"
|
||||
" locale TEXT DEFAULT 'en',"
|
||||
" response_mode TEXT DEFAULT 'media',"
|
||||
" default_count INTEGER DEFAULT 5,"
|
||||
" rate_limits TEXT DEFAULT '{}',"
|
||||
" created_at TIMESTAMP"
|
||||
")"
|
||||
))
|
||||
logger.info("Created command_config table")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Create command_tracker table
|
||||
# ------------------------------------------------------------------
|
||||
if not await _has_table(conn, "command_tracker"):
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE command_tracker ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" user_id INTEGER NOT NULL REFERENCES user(id),"
|
||||
" provider_id INTEGER NOT NULL REFERENCES service_provider(id),"
|
||||
" command_config_id INTEGER NOT NULL REFERENCES command_config(id),"
|
||||
" name TEXT NOT NULL,"
|
||||
" icon TEXT DEFAULT '',"
|
||||
" enabled INTEGER DEFAULT 1,"
|
||||
" created_at TIMESTAMP"
|
||||
")"
|
||||
))
|
||||
logger.info("Created command_tracker table")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. Create command_tracker_listener table
|
||||
# ------------------------------------------------------------------
|
||||
if not await _has_table(conn, "command_tracker_listener"):
|
||||
await conn.execute(text(
|
||||
"CREATE TABLE command_tracker_listener ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" command_tracker_id INTEGER NOT NULL REFERENCES command_tracker(id),"
|
||||
" listener_type TEXT NOT NULL,"
|
||||
" listener_id INTEGER NOT NULL,"
|
||||
" created_at TIMESTAMP,"
|
||||
" UNIQUE(command_tracker_id, listener_type, listener_id)"
|
||||
")"
|
||||
))
|
||||
logger.info("Created command_tracker_listener table")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. Migrate TelegramBot.commands_config → CommandConfig rows
|
||||
# ------------------------------------------------------------------
|
||||
if await _has_table(conn, "telegram_bot") and await _has_column(conn, "telegram_bot", "commands_config"):
|
||||
# Only migrate if command_config table is empty (idempotent)
|
||||
cc_count = (await conn.execute(text("SELECT COUNT(*) FROM command_config"))).scalar()
|
||||
if cc_count == 0:
|
||||
bots = (await conn.execute(text(
|
||||
"SELECT id, user_id, commands_config FROM telegram_bot"
|
||||
))).fetchall()
|
||||
migrated = 0
|
||||
for bot in bots:
|
||||
bot_id, user_id, raw_config = bot[0], bot[1], bot[2]
|
||||
if not raw_config:
|
||||
continue
|
||||
try:
|
||||
cfg = json.loads(raw_config) if isinstance(raw_config, str) else raw_config
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
# Skip empty/default configs
|
||||
if not cfg or cfg == {}:
|
||||
continue
|
||||
|
||||
# Extract fields from legacy commands_config
|
||||
enabled_commands = json.dumps(cfg.get("enabled_commands", []))
|
||||
locale = cfg.get("locale", "en")
|
||||
response_mode = cfg.get("response_mode", "media")
|
||||
default_count = cfg.get("default_count", 5)
|
||||
rate_limits = json.dumps(cfg.get("rate_limits", {}))
|
||||
provider_type = cfg.get("provider_type", "immich")
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO command_config "
|
||||
"(user_id, provider_type, name, enabled_commands, locale, "
|
||||
"response_mode, default_count, rate_limits, created_at) "
|
||||
"VALUES (:uid, :pt, :name, :ec, :locale, :rm, :dc, :rl, CURRENT_TIMESTAMP)"
|
||||
),
|
||||
{
|
||||
"uid": user_id,
|
||||
"pt": provider_type,
|
||||
"name": f"Bot #{bot_id} Commands",
|
||||
"ec": enabled_commands,
|
||||
"locale": locale,
|
||||
"rm": response_mode,
|
||||
"dc": default_count,
|
||||
"rl": rate_limits,
|
||||
},
|
||||
)
|
||||
migrated += 1
|
||||
|
||||
if migrated:
|
||||
logger.info("Migrated %d bot commands_config → command_config rows", migrated)
|
||||
|
||||
# NOTE: We intentionally do NOT drop commands_config from telegram_bot
|
||||
# or notification_tracker_target. SQLite doesn't support DROP COLUMN in
|
||||
# all versions, and SQLModel will simply ignore columns not defined on
|
||||
# the model class. The columns will remain in the DB but are unused.
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlmodel import JSON, Column, Field, SQLModel
|
||||
|
||||
|
||||
@@ -47,7 +48,8 @@ class TelegramBot(SQLModel, table=True):
|
||||
bot_id: int = Field(default=0)
|
||||
webhook_path_id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
update_mode: str = Field(default="polling") # "polling" or "webhook"
|
||||
commands_config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
# NOTE: commands_config column remains in the DB for backward compat,
|
||||
# but is no longer part of the SQLModel class. Data migrated to CommandConfig.
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
@@ -162,13 +164,14 @@ class NotificationTarget(SQLModel, table=True):
|
||||
name: str
|
||||
icon: str = Field(default="")
|
||||
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
chat_action: str | None = Field(default=None) # e.g. "typing", "upload_photo"
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class Tracker(SQLModel, table=True):
|
||||
class NotificationTracker(SQLModel, table=True):
|
||||
"""Watches a provider's collections for changes."""
|
||||
|
||||
__tablename__ = "tracker"
|
||||
__tablename__ = "notification_tracker"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
@@ -182,13 +185,18 @@ class Tracker(SQLModel, table=True):
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class TrackerTarget(SQLModel, table=True):
|
||||
"""Junction between Tracker and NotificationTarget with per-link config."""
|
||||
class NotificationTrackerTarget(SQLModel, table=True):
|
||||
"""Junction between NotificationTracker and NotificationTarget with per-link config."""
|
||||
|
||||
__tablename__ = "tracker_target"
|
||||
__tablename__ = "notification_tracker_target"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
tracker_id: int = Field(foreign_key="tracker.id", index=True)
|
||||
# Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id
|
||||
tracker_id: int = Field(
|
||||
foreign_key="notification_tracker.id",
|
||||
index=True,
|
||||
sa_column_kwargs={"name": "notification_tracker_id"},
|
||||
)
|
||||
target_id: int = Field(foreign_key="notification_target.id", index=True)
|
||||
tracking_config_id: int | None = Field(
|
||||
default=None, foreign_key="tracking_config.id"
|
||||
@@ -199,19 +207,22 @@ class TrackerTarget(SQLModel, table=True):
|
||||
enabled: bool = Field(default=True)
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
commands_config: dict[str, Any] | None = Field(
|
||||
default=None, sa_column=Column(JSON)
|
||||
)
|
||||
# NOTE: commands_config column remains in the DB for backward compat,
|
||||
# but is no longer part of the SQLModel class. Data migrated to CommandConfig.
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class TrackerState(SQLModel, table=True):
|
||||
class NotificationTrackerState(SQLModel, table=True):
|
||||
"""Persisted state for change detection."""
|
||||
|
||||
__tablename__ = "tracker_state"
|
||||
__tablename__ = "notification_tracker_state"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
tracker_id: int = Field(foreign_key="tracker.id")
|
||||
# Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id
|
||||
tracker_id: int = Field(
|
||||
foreign_key="notification_tracker.id",
|
||||
sa_column_kwargs={"name": "notification_tracker_id"},
|
||||
)
|
||||
collection_id: str
|
||||
collection_name: str = Field(default="")
|
||||
shared: bool = Field(default=False)
|
||||
@@ -220,13 +231,70 @@ class TrackerState(SQLModel, table=True):
|
||||
last_updated: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class CommandConfig(SQLModel, table=True):
|
||||
"""Configuration for bot commands (e.g., which commands are enabled, rate limits)."""
|
||||
|
||||
__tablename__ = "command_config"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
provider_type: str
|
||||
name: str
|
||||
icon: str = Field(default="")
|
||||
enabled_commands: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
locale: str = Field(default="en")
|
||||
response_mode: str = Field(default="media") # "media" or "text"
|
||||
default_count: int = Field(default=5)
|
||||
rate_limits: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class CommandTracker(SQLModel, table=True):
|
||||
"""Links a provider to a command config for interactive bot commands."""
|
||||
|
||||
__tablename__ = "command_tracker"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
provider_id: int = Field(foreign_key="service_provider.id")
|
||||
command_config_id: int = Field(foreign_key="command_config.id")
|
||||
name: str
|
||||
icon: str = Field(default="")
|
||||
enabled: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class CommandTrackerListener(SQLModel, table=True):
|
||||
"""Links a CommandTracker to a listener (e.g., a telegram bot chat)."""
|
||||
|
||||
__tablename__ = "command_tracker_listener"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"command_tracker_id", "listener_type", "listener_id",
|
||||
name="uq_command_tracker_listener",
|
||||
),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
command_tracker_id: int = Field(foreign_key="command_tracker.id")
|
||||
listener_type: str # e.g. "telegram_bot"
|
||||
listener_id: int
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class EventLog(SQLModel, table=True):
|
||||
"""Log of detected events."""
|
||||
|
||||
__tablename__ = "event_log"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
tracker_id: int | None = Field(default=None, foreign_key="tracker.id", index=True)
|
||||
# Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id
|
||||
tracker_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key="notification_tracker.id",
|
||||
index=True,
|
||||
sa_column_kwargs={"name": "notification_tracker_id"},
|
||||
)
|
||||
tracker_name: str = Field(default="")
|
||||
provider_id: int | None = Field(default=None, index=True)
|
||||
provider_name: str = Field(default="")
|
||||
|
||||
@@ -15,8 +15,8 @@ from .database.models import * # noqa: F401,F403 — ensure all models register
|
||||
|
||||
from .auth.routes import router as auth_router
|
||||
from .api.providers import router as providers_router
|
||||
from .api.trackers import router as trackers_router
|
||||
from .api.tracker_targets import router as tracker_targets_router
|
||||
from .api.notification_trackers import router as notification_trackers_router
|
||||
from .api.notification_tracker_targets import router as notification_tracker_targets_router
|
||||
from .api.tracking_configs import router as tracking_configs_router
|
||||
from .api.template_configs import router as template_configs_router
|
||||
from .api.targets import router as targets_router
|
||||
@@ -25,6 +25,8 @@ from .api.users import router as users_router
|
||||
from .api.status import router as status_router
|
||||
from .api.template_vars import router as template_vars_router
|
||||
from .api.app_settings import router as app_settings_router
|
||||
from .api.command_configs import router as command_configs_router
|
||||
from .api.command_trackers import router as command_trackers_router
|
||||
from .commands.webhook import router as webhook_router, set_webhook_secret
|
||||
|
||||
|
||||
@@ -33,10 +35,11 @@ async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
# Run data migrations (idempotent)
|
||||
from .database.engine import get_engine
|
||||
from .database.migrations import migrate_schema, migrate_tracker_targets
|
||||
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor
|
||||
engine = get_engine()
|
||||
await migrate_schema(engine)
|
||||
await migrate_tracker_targets(engine)
|
||||
await migrate_entity_refactor(engine)
|
||||
await _seed_default_templates()
|
||||
# Configure webhook secret from DB setting (falls back to env var)
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
|
||||
@@ -55,8 +58,8 @@ app = FastAPI(title="Notify Bridge", version="0.1.0", lifespan=lifespan)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(template_vars_router)
|
||||
app.include_router(providers_router)
|
||||
app.include_router(trackers_router)
|
||||
app.include_router(tracker_targets_router)
|
||||
app.include_router(notification_trackers_router)
|
||||
app.include_router(notification_tracker_targets_router)
|
||||
app.include_router(tracking_configs_router)
|
||||
app.include_router(template_configs_router)
|
||||
app.include_router(targets_router)
|
||||
@@ -64,6 +67,8 @@ app.include_router(telegram_bots_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(status_router)
|
||||
app.include_router(app_settings_router)
|
||||
app.include_router(command_configs_router)
|
||||
app.include_router(command_trackers_router)
|
||||
app.include_router(webhook_router)
|
||||
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ async def start_scheduler() -> None:
|
||||
|
||||
await _load_tracker_jobs()
|
||||
|
||||
# Start Telegram bot polling for bots in polling mode
|
||||
from .telegram_poller import start_bot_polling
|
||||
await start_bot_polling()
|
||||
# Start Telegram bot polling for bots with active command listeners
|
||||
from .telegram_poller import start_command_listener_polling
|
||||
await start_command_listener_polling()
|
||||
|
||||
|
||||
async def _load_tracker_jobs() -> None:
|
||||
@@ -36,13 +36,13 @@ async def _load_tracker_jobs() -> None:
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import Tracker
|
||||
from ..database.models import NotificationTracker
|
||||
|
||||
engine = get_engine()
|
||||
scheduler = get_scheduler()
|
||||
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(select(Tracker).where(Tracker.enabled == True))
|
||||
result = await session.exec(select(NotificationTracker).where(NotificationTracker.enabled == True))
|
||||
trackers = result.all()
|
||||
|
||||
for tracker in trackers:
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
Uses APScheduler to run getUpdates periodically for each bot
|
||||
with update_mode == "polling". Processes updates identically
|
||||
to the webhook handler (auto-save chat, dispatch commands).
|
||||
|
||||
Ref-counted: only starts/stops polling for bots that have active
|
||||
CommandTrackerListeners with enabled CommandTrackers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,7 +20,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import TelegramBot
|
||||
from ..database.models import CommandTracker, CommandTrackerListener, TelegramBot
|
||||
from ..services.telegram import save_chat_from_webhook
|
||||
from .scheduler import get_scheduler
|
||||
|
||||
@@ -27,18 +30,82 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_last_update_id: dict[int, int] = {}
|
||||
|
||||
|
||||
async def start_bot_polling() -> None:
|
||||
"""Schedule polling jobs for all bots with update_mode == 'polling'."""
|
||||
async def _get_bot_ids_with_active_listeners() -> set[int]:
|
||||
"""Return bot IDs that have at least one active command tracker listener.
|
||||
|
||||
A bot is "active" if there is a CommandTrackerListener with
|
||||
listener_type="telegram_bot" pointing to it, AND the associated
|
||||
CommandTracker is enabled.
|
||||
"""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(TelegramBot).where(TelegramBot.update_mode == "polling")
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.listener_type == "telegram_bot"
|
||||
)
|
||||
)
|
||||
listeners = result.all()
|
||||
|
||||
active_bot_ids: set[int] = set()
|
||||
for listener in listeners:
|
||||
tracker = await session.get(CommandTracker, listener.command_tracker_id)
|
||||
if tracker and tracker.enabled:
|
||||
active_bot_ids.add(listener.listener_id)
|
||||
|
||||
return active_bot_ids
|
||||
|
||||
|
||||
async def start_command_listener_polling() -> None:
|
||||
"""Schedule polling jobs only for bots with active command tracker listeners."""
|
||||
active_bot_ids = await _get_bot_ids_with_active_listeners()
|
||||
if not active_bot_ids:
|
||||
_LOGGER.info("No bots with active command listeners to poll")
|
||||
return
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(TelegramBot).where(
|
||||
TelegramBot.update_mode == "polling",
|
||||
TelegramBot.id.in_(active_bot_ids),
|
||||
)
|
||||
)
|
||||
bots = result.all()
|
||||
|
||||
for bot in bots:
|
||||
schedule_bot_polling(bot.id)
|
||||
|
||||
_LOGGER.info("Started command listener polling for %d bot(s)", len(bots))
|
||||
|
||||
|
||||
async def start_bot_polling() -> None:
|
||||
"""Schedule polling jobs for all bots with update_mode == 'polling'.
|
||||
|
||||
Deprecated: prefer start_command_listener_polling() which only starts
|
||||
bots with active command tracker listeners.
|
||||
"""
|
||||
await start_command_listener_polling()
|
||||
|
||||
|
||||
async def start_bot_if_needed(bot_id: int) -> None:
|
||||
"""Start polling for a bot if it has active listeners and is not already running."""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
bot = await session.get(TelegramBot, bot_id)
|
||||
if not bot or bot.update_mode != "polling":
|
||||
return
|
||||
|
||||
active_bot_ids = await _get_bot_ids_with_active_listeners()
|
||||
if bot_id in active_bot_ids:
|
||||
schedule_bot_polling(bot_id)
|
||||
|
||||
|
||||
async def stop_bot_if_unused(bot_id: int) -> None:
|
||||
"""Stop polling for a bot if it has no enabled command tracker listeners."""
|
||||
active_bot_ids = await _get_bot_ids_with_active_listeners()
|
||||
if bot_id not in active_bot_ids:
|
||||
unschedule_bot_polling(bot_id)
|
||||
|
||||
|
||||
def schedule_bot_polling(bot_id: int) -> None:
|
||||
"""Add a polling job for a bot (idempotent)."""
|
||||
@@ -70,76 +137,82 @@ def unschedule_bot_polling(bot_id: int) -> None:
|
||||
async def _poll_bot(bot_id: int) -> None:
|
||||
"""Fetch updates from Telegram and process them."""
|
||||
engine = get_engine()
|
||||
|
||||
# Eagerly load bot data and close session before aiohttp work
|
||||
# (cannot nest aiohttp inside active SQLAlchemy async session)
|
||||
async with AsyncSession(engine) as session:
|
||||
bot = await session.get(TelegramBot, bot_id)
|
||||
if not bot or bot.update_mode != "polling":
|
||||
unschedule_bot_polling(bot_id)
|
||||
return
|
||||
# Extract what we need before closing session
|
||||
bot_token = bot.token
|
||||
bot_obj = bot
|
||||
|
||||
offset = _last_update_id.get(bot_id, 0)
|
||||
params: dict[str, Any] = {
|
||||
"timeout": 0,
|
||||
"limit": 50,
|
||||
"allowed_updates": '["message"]',
|
||||
}
|
||||
if offset:
|
||||
params["offset"] = offset + 1
|
||||
offset = _last_update_id.get(bot_id, 0)
|
||||
params: dict[str, Any] = {
|
||||
"timeout": 0,
|
||||
"limit": 50,
|
||||
"allowed_updates": '["message"]',
|
||||
}
|
||||
if offset:
|
||||
params["offset"] = offset + 1
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get(
|
||||
f"{TELEGRAM_API_BASE_URL}{bot_token}/getUpdates",
|
||||
params=params,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
if not data.get("ok"):
|
||||
return
|
||||
updates = data.get("result", [])
|
||||
except Exception as e:
|
||||
_LOGGER.debug("Polling error for bot %d: %s", bot_id, e)
|
||||
return
|
||||
|
||||
if not updates:
|
||||
return
|
||||
|
||||
# Update offset to latest
|
||||
_last_update_id[bot_id] = updates[-1]["update_id"]
|
||||
|
||||
# Process each update
|
||||
from ..commands.handler import handle_command, send_media_group
|
||||
|
||||
for update in updates:
|
||||
message = update.get("message")
|
||||
if not message:
|
||||
continue
|
||||
|
||||
chat_info = message.get("chat", {})
|
||||
chat_id = str(chat_info.get("id", ""))
|
||||
text = message.get("text", "")
|
||||
|
||||
if not chat_id:
|
||||
continue
|
||||
|
||||
# Auto-persist chat (fresh session per save)
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get(
|
||||
f"{TELEGRAM_API_BASE_URL}{bot.token}/getUpdates",
|
||||
params=params,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
if not data.get("ok"):
|
||||
return
|
||||
updates = data.get("result", [])
|
||||
except Exception as e:
|
||||
_LOGGER.debug("Polling error for bot %d: %s", bot_id, e)
|
||||
return
|
||||
async with AsyncSession(engine) as save_session:
|
||||
await save_chat_from_webhook(save_session, bot_obj.id, chat_info)
|
||||
await save_session.commit()
|
||||
except Exception:
|
||||
_LOGGER.debug("Failed to auto-save chat %s", chat_id, exc_info=True)
|
||||
|
||||
if not updates:
|
||||
return
|
||||
|
||||
# Update offset to latest
|
||||
_last_update_id[bot_id] = updates[-1]["update_id"]
|
||||
|
||||
# Process each update
|
||||
from ..commands.handler import handle_command, send_media_group
|
||||
|
||||
for update in updates:
|
||||
message = update.get("message")
|
||||
if not message:
|
||||
continue
|
||||
|
||||
chat_info = message.get("chat", {})
|
||||
chat_id = str(chat_info.get("id", ""))
|
||||
text = message.get("text", "")
|
||||
|
||||
if not chat_id:
|
||||
continue
|
||||
|
||||
# Auto-persist chat
|
||||
# Dispatch commands
|
||||
if text and text.startswith("/"):
|
||||
try:
|
||||
async with AsyncSession(engine) as save_session:
|
||||
await save_chat_from_webhook(save_session, bot.id, chat_info)
|
||||
await save_session.commit()
|
||||
cmd_response = await handle_command(bot_obj, chat_id, text)
|
||||
if cmd_response is not None:
|
||||
if isinstance(cmd_response, list):
|
||||
await send_media_group(bot_token, chat_id, cmd_response)
|
||||
else:
|
||||
await _send_reply(bot_token, chat_id, cmd_response)
|
||||
except Exception:
|
||||
_LOGGER.debug("Failed to auto-save chat %s", chat_id, exc_info=True)
|
||||
|
||||
# Dispatch commands
|
||||
if text and text.startswith("/"):
|
||||
try:
|
||||
cmd_response = await handle_command(bot, chat_id, text)
|
||||
if cmd_response is not None:
|
||||
if isinstance(cmd_response, list):
|
||||
await send_media_group(bot.token, chat_id, cmd_response)
|
||||
else:
|
||||
await _send_reply(bot.token, chat_id, cmd_response)
|
||||
except Exception:
|
||||
_LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True)
|
||||
_LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True)
|
||||
|
||||
|
||||
async def _send_reply(bot_token: str, chat_id: str, text: str) -> None:
|
||||
|
||||
@@ -19,11 +19,11 @@ from ..database.engine import get_engine
|
||||
from ..database.models import (
|
||||
EventLog,
|
||||
NotificationTarget,
|
||||
NotificationTracker,
|
||||
NotificationTrackerState,
|
||||
NotificationTrackerTarget,
|
||||
ServiceProvider,
|
||||
TemplateConfig,
|
||||
Tracker,
|
||||
TrackerState,
|
||||
TrackerTarget,
|
||||
TrackingConfig,
|
||||
)
|
||||
|
||||
@@ -89,7 +89,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
|
||||
# Load all DB data eagerly before entering aiohttp context
|
||||
async with AsyncSession(engine) as session:
|
||||
tracker = await session.get(Tracker, tracker_id)
|
||||
tracker = await session.get(NotificationTracker, tracker_id)
|
||||
if not tracker or not tracker.enabled:
|
||||
return {"status": "skipped", "reason": "disabled or not found"}
|
||||
|
||||
@@ -99,7 +99,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
|
||||
# Load tracker state
|
||||
result = await session.exec(
|
||||
select(TrackerState).where(TrackerState.tracker_id == tracker_id)
|
||||
select(NotificationTrackerState).where(NotificationTrackerState.tracker_id == tracker_id)
|
||||
)
|
||||
states = result.all()
|
||||
state_dict: dict[str, Any] = {}
|
||||
@@ -113,7 +113,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
|
||||
# Load tracker-target links (replaces old target_ids JSON array)
|
||||
tt_result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == tracker_id)
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == tracker_id)
|
||||
)
|
||||
tracker_targets = tt_result.all()
|
||||
|
||||
@@ -188,7 +188,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
existing.shared = cstate.get("shared", False)
|
||||
session.add(existing)
|
||||
else:
|
||||
new_ts = TrackerState(
|
||||
new_ts = NotificationTrackerState(
|
||||
tracker_id=tracker_id,
|
||||
collection_id=cid,
|
||||
collection_name=cstate.get("name", ""),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Feature Context: Entity Relationship Refactor
|
||||
|
||||
## Current State
|
||||
Starting — no changes made yet. Branch created from master with all telegram-commands work merged.
|
||||
|
||||
## Key Design Decisions
|
||||
- Provider capabilities (notifications, commands) inferred from provider type config, not explicit DB flags
|
||||
- Tracker renamed to NotificationTracker; TrackerTarget renamed to NotificationTrackerTarget
|
||||
- New entities: CommandConfig, CommandTracker, CommandTrackerListener
|
||||
- CommandConfig is provider_type-scoped, shareable across multiple CommandTrackers
|
||||
- CommandTrackerListener is a junction table (command_tracker_id, listener_type, listener_id) for extensibility
|
||||
- TelegramBot is dual-purpose: notification target backend + commands listener
|
||||
- TelegramBot polling/webhook lifecycle tied to CommandTrackerListener ref-counting
|
||||
- Telegram targets gain chat_action field
|
||||
- commands_config moves from TelegramBot to CommandConfig entity
|
||||
|
||||
## Entity Schema (Target State)
|
||||
```
|
||||
ServiceProvider (type: "immich" → infers has_notifications=true, has_commands=true)
|
||||
│
|
||||
├─ NotificationTracker (renamed from Tracker)
|
||||
│ └─ NotificationTrackerTarget (renamed from TrackerTarget)
|
||||
│ ├─ NotificationTarget (+ chat_action for telegram type)
|
||||
│ ├─ TrackingConfig (unchanged)
|
||||
│ └─ TemplateConfig (unchanged)
|
||||
│
|
||||
└─ CommandTracker (new)
|
||||
├─ CommandConfig (new, shared, provider_type-scoped)
|
||||
└─ CommandTrackerListener (junction → listener_type + listener_id)
|
||||
└─ TelegramBot as "telegram_bot" listener type
|
||||
|
||||
TelegramBot
|
||||
├─ Used by NotificationTarget (sending messages)
|
||||
└─ Used by CommandTrackerListener (receiving commands)
|
||||
└─ Smart ref-counting: start polling/webhook when first listener added, stop when last removed
|
||||
```
|
||||
|
||||
## Temporary Workarounds
|
||||
None yet.
|
||||
|
||||
## Cross-Phase Dependencies
|
||||
- Phase 2 depends on Phase 1 (renamed models)
|
||||
- Phase 3 depends on Phase 1 (new models for CommandConfig, CommandTracker, CommandTrackerListener)
|
||||
- Phase 4 depends on Phase 3 (command entities exist in DB/API)
|
||||
- Phase 5 depends on Phase 2 (renamed API endpoints)
|
||||
- Phase 6 depends on Phase 3 (command entity APIs)
|
||||
- Phase 7 depends on all prior phases
|
||||
|
||||
## Implementation Notes
|
||||
- SQLite + async SQLAlchemy via sqlmodel — table renames done via idempotent ALTER TABLE / CREATE TABLE
|
||||
- No formal test suite — verification via server startup + health check + frontend build
|
||||
- Migration must handle existing data: rename tables, migrate TelegramBot.commands_config → CommandConfig rows
|
||||
- Incremental strategy: each phase leaves the codebase fully working
|
||||
@@ -0,0 +1,52 @@
|
||||
# Feature: Entity Relationship Refactor
|
||||
|
||||
**Branch:** `feature/entity-relationship-refactor`
|
||||
**Base branch:** `master`
|
||||
**Created:** 2026-03-20
|
||||
**Status:** ✅ Complete
|
||||
**Strategy:** Incremental
|
||||
**Mode:** Automated
|
||||
**Execution:** Orchestrator
|
||||
|
||||
## Summary
|
||||
|
||||
Rework the entity schema so that ServiceProvider capabilities (notifications, commands) are
|
||||
inferred from provider type config. Current Trackers become NotificationTrackers. A new
|
||||
CommandTracker entity links providers to CommandConfigs and CommandsListeners (TelegramBot
|
||||
as first implementation). TelegramBot becomes dual-purpose: notification target backend +
|
||||
commands listener with smart ref-counted polling/webhook. CommandConfig is a new shareable
|
||||
entity scoped to provider type. Telegram targets gain a chat_action setting.
|
||||
|
||||
## Build & Test Commands
|
||||
- **Build (backend):** `cd packages/server && pip install -e .`
|
||||
- **Verify (backend):** Server startup + `curl -s http://localhost:8420/api/health`
|
||||
- **Build (frontend):** `cd frontend && npm install && npx vite build`
|
||||
- **Test:** No automated test suite yet — verification via server startup and frontend build
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] Phase 1: Database Schema & Migration [domain: backend] → [subplan](./phase-1-db-schema.md)
|
||||
- [x] Phase 2: Notification Tracker Rename (API) [domain: backend] → [subplan](./phase-2-notification-tracker-rename.md)
|
||||
- [x] Phase 3: CommandConfig & CommandTracker CRUD [domain: backend] → [subplan](./phase-3-command-entities-api.md)
|
||||
- [x] Phase 4: Command System Refactor [domain: backend] → [subplan](./phase-4-command-system-refactor.md)
|
||||
- [x] Phase 5: Frontend Rename & Restructure [domain: frontend] → [subplan](./phase-5-frontend-rename.md)
|
||||
- [x] Phase 6: Frontend Command Entities [domain: frontend] → [subplan](./phase-6-frontend-commands.md)
|
||||
- [x] Phase 7: Integration & Cleanup [domain: fullstack] → [subplan](./phase-7-integration-cleanup.md)
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 1: DB Schema & Migration | backend | ✅ Complete | ✅ | ✅ | ✅ |
|
||||
| Phase 2: Notification Tracker Rename | backend | ✅ Complete | ✅ | ✅ | ✅ |
|
||||
| Phase 3: Command Entities API | backend | ✅ Complete | ✅ | ✅ | ✅ |
|
||||
| Phase 4: Command System Refactor | backend | ✅ Complete | ✅ | ✅ | ✅ |
|
||||
| Phase 5: Frontend Rename | frontend | ✅ Complete | ✅ | ✅ | ✅ |
|
||||
| Phase 6: Frontend Commands | frontend | ✅ Complete | ✅ | ✅ | ✅ |
|
||||
| Phase 7: Integration & Cleanup | fullstack | ✅ Complete | ✅ | ✅ | ✅ |
|
||||
|
||||
## Final Review
|
||||
- [x] Comprehensive code review
|
||||
- [x] Full build passes
|
||||
- [x] Full test suite passes
|
||||
- [ ] Merged to `master`
|
||||
@@ -0,0 +1,61 @@
|
||||
# Phase 1: Database Schema & Migration
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Add new database models (CommandConfig, CommandTracker, CommandTrackerListener), rename
|
||||
existing models (Tracker → NotificationTracker, TrackerTarget → NotificationTrackerTarget),
|
||||
add chat_action to NotificationTarget, and write idempotent migration logic.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Rename `Tracker` model to `NotificationTracker` — update class name, `__tablename__` to `"notification_tracker"`, and all field references. Keep all existing fields (provider_id, collection_ids, scan_interval, batch_duration, enabled, etc.)
|
||||
- [ ] Task 2: Rename `TrackerTarget` model to `NotificationTrackerTarget` — update class name, `__tablename__` to `"notification_tracker_target"`, rename `tracker_id` FK to `notification_tracker_id`
|
||||
- [ ] Task 3: Rename `TrackerState` model to `NotificationTrackerState` — update class name, `__tablename__` to `"notification_tracker_state"`, rename `tracker_id` FK to `notification_tracker_id`
|
||||
- [ ] Task 4: Add `chat_action` optional string field to `NotificationTarget` model (for telegram targets, e.g. "typing", "upload_photo")
|
||||
- [ ] Task 5: Create `CommandConfig` model — fields: id, user_id (FK→User), provider_type (str), name, icon, enabled_commands (JSON list), locale (str, default "en"), response_mode (str, default "media"), default_count (int, default 5), rate_limits (JSON dict), created_at
|
||||
- [ ] Task 6: Create `CommandTracker` model — fields: id, user_id (FK→User), provider_id (FK→ServiceProvider), command_config_id (FK→CommandConfig), name, icon, enabled (bool), created_at
|
||||
- [ ] Task 7: Create `CommandTrackerListener` model — fields: id, command_tracker_id (FK→CommandTracker), listener_type (str, e.g. "telegram_bot"), listener_id (int), created_at. Add unique constraint on (command_tracker_id, listener_type, listener_id)
|
||||
- [ ] Task 8: Remove `commands_config` field from `TelegramBot` model (will be migrated to CommandConfig)
|
||||
- [ ] Task 9: Remove `commands_config` field from `TrackerTarget`/`NotificationTrackerTarget` model
|
||||
- [ ] Task 10: Write idempotent migration in `migrations.py`:
|
||||
- Rename table `tracker` → `notification_tracker`
|
||||
- Rename table `tracker_target` → `notification_tracker_target` and rename column `tracker_id` → `notification_tracker_id`
|
||||
- Rename table `tracker_state` → `notification_tracker_state` and rename column `tracker_id` → `notification_tracker_id`
|
||||
- Add `chat_action` column to `notification_target`
|
||||
- Create `command_config` table
|
||||
- Create `command_tracker` table
|
||||
- Create `command_tracker_listener` table
|
||||
- Migrate existing `TelegramBot.commands_config` JSON → `CommandConfig` rows (one per bot that has non-default config)
|
||||
- Drop `commands_config` column from old telegram_bot table
|
||||
- Drop `commands_config` column from notification_tracker_target table
|
||||
- [ ] Task 11: Update all model imports in `models.py` `__init__` / re-exports — ensure other modules can still import the models
|
||||
- [ ] Task 12: Update `EventLog` model — rename `tracker_id` field to `notification_tracker_id` (nullable FK), add migration for column rename
|
||||
|
||||
## Files to Modify/Create
|
||||
- `packages/server/src/notify_bridge_server/database/models.py` — rename models, add new models, remove fields
|
||||
- `packages/server/src/notify_bridge_server/database/migrations.py` — add migration functions
|
||||
|
||||
## Acceptance Criteria
|
||||
- All new tables are created on startup via migration
|
||||
- Existing data is preserved and migrated (table renames, column renames, commands_config → CommandConfig)
|
||||
- Server starts without errors with existing test-data database
|
||||
- All existing imports still resolve (may need temporary aliases)
|
||||
|
||||
## Notes
|
||||
- SQLite does not support `ALTER TABLE RENAME COLUMN` in older versions. Use the existing pattern of adding new columns + copying data if needed.
|
||||
- The migration must be idempotent — safe to run multiple times.
|
||||
- Other modules (API routes, services) will still reference old model names after this phase. That's OK — Phase 2 will update the API layer. For now, add Python-level aliases (e.g., `Tracker = NotificationTracker`) so existing code continues to work.
|
||||
- TrackerTarget.commands_config was unused in practice — safe to drop without data loss.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -0,0 +1,60 @@
|
||||
# Phase 2: Notification Tracker Rename (API)
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Rename all tracker-related API routes, service functions, and internal references to use
|
||||
"notification_tracker" naming. Add chat_action support to the targets API. Ensure the
|
||||
watcher, scheduler, and notifier services work with the renamed models.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Rename `api/trackers.py` → `api/notification_trackers.py`. Update all route paths from `/api/trackers` to `/api/notification-trackers`. Update function names (e.g., `list_trackers` → `list_notification_trackers`). Update all model references to use `NotificationTracker`.
|
||||
- [ ] Task 2: Rename `api/tracker_targets.py` → `api/notification_tracker_targets.py`. Update route paths from `/api/tracker-targets` to `/api/notification-tracker-targets`. Update model references to `NotificationTrackerTarget`, field references to `notification_tracker_id`.
|
||||
- [ ] Task 3: Update `api/targets.py` — add `chat_action` to create/update request schemas and response serialization for telegram-type targets.
|
||||
- [ ] Task 4: Update `services/watcher.py` — replace all `Tracker` references with `NotificationTracker`, `TrackerTarget` with `NotificationTrackerTarget`, `TrackerState` with `NotificationTrackerState`, `tracker_id` with `notification_tracker_id` where applicable.
|
||||
- [ ] Task 5: Update `services/scheduler.py` — rename tracker job references, function parameters, and log messages to use notification_tracker naming.
|
||||
- [ ] Task 6: Update `services/notifier.py` — update model references and any tracker-related parameter names.
|
||||
- [ ] Task 7: Update `main.py` — change router imports and registration to use new module names and route prefixes.
|
||||
- [ ] Task 8: Update `api/status.py` — rename any tracker count queries to use new model names.
|
||||
- [ ] Task 9: Update `commands/handler.py` — update any tracker model references used for command context resolution.
|
||||
- [ ] Task 10: Update `commands/webhook.py` — update any tracker model references.
|
||||
- [ ] Task 11: Update `services/telegram_poller.py` — update any tracker model references.
|
||||
- [ ] Task 12: Remove backward-compatibility aliases from models.py (if added in Phase 1) — all consumers now use new names.
|
||||
|
||||
## Files to Modify/Create
|
||||
- `packages/server/src/notify_bridge_server/api/trackers.py` → rename to `notification_trackers.py`
|
||||
- `packages/server/src/notify_bridge_server/api/tracker_targets.py` → rename to `notification_tracker_targets.py`
|
||||
- `packages/server/src/notify_bridge_server/api/targets.py` — add chat_action
|
||||
- `packages/server/src/notify_bridge_server/services/watcher.py` — model name updates
|
||||
- `packages/server/src/notify_bridge_server/services/scheduler.py` — model name updates
|
||||
- `packages/server/src/notify_bridge_server/services/notifier.py` — model name updates
|
||||
- `packages/server/src/notify_bridge_server/main.py` — router registration
|
||||
- `packages/server/src/notify_bridge_server/api/status.py` — model name updates
|
||||
- `packages/server/src/notify_bridge_server/commands/handler.py` — model references
|
||||
- `packages/server/src/notify_bridge_server/commands/webhook.py` — model references
|
||||
- `packages/server/src/notify_bridge_server/services/telegram_poller.py` — model references
|
||||
|
||||
## Acceptance Criteria
|
||||
- All API routes work under new `/api/notification-trackers` and `/api/notification-tracker-targets` paths
|
||||
- Old `/api/trackers` routes no longer exist
|
||||
- Telegram targets accept and return `chat_action` field
|
||||
- Server starts and health check passes
|
||||
- Watcher/scheduler/notifier services function correctly with renamed models
|
||||
|
||||
## Notes
|
||||
- This is a breaking API change — frontend will need updating in Phase 5.
|
||||
- The watcher service is the most complex consumer of tracker models — test carefully.
|
||||
- The EventLog model references notification_tracker_id (renamed in Phase 1).
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -0,0 +1,72 @@
|
||||
# Phase 3: CommandConfig & CommandTracker CRUD API
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create full CRUD API routes for CommandConfig, CommandTracker, and CommandTrackerListener
|
||||
management. These endpoints let users create command configurations (scoped to provider type),
|
||||
create command trackers that link a provider to a command config, and attach/detach listeners
|
||||
(telegram bots) to command trackers.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create `api/command_configs.py` with CRUD routes:
|
||||
- `GET /api/command-configs` — list all for current user (+ system defaults with user_id=0)
|
||||
- `POST /api/command-configs` — create new (validate provider_type, enabled_commands against registry)
|
||||
- `GET /api/command-configs/{id}` — get single
|
||||
- `PUT /api/command-configs/{id}` — update (validate ownership)
|
||||
- `DELETE /api/command-configs/{id}` — delete (check not in use by any command tracker)
|
||||
- Response should include all fields: id, user_id, provider_type, name, icon, enabled_commands, locale, response_mode, default_count, rate_limits, created_at
|
||||
|
||||
- [ ] Task 2: Create `api/command_trackers.py` with CRUD routes:
|
||||
- `GET /api/command-trackers` — list all for current user, include linked listeners count
|
||||
- `POST /api/command-trackers` — create new (validate provider_id exists, command_config_id exists, provider_type matches between provider and config)
|
||||
- `GET /api/command-trackers/{id}` — get single with listeners
|
||||
- `PUT /api/command-trackers/{id}` — update (name, icon, enabled, command_config_id — validate provider_type match)
|
||||
- `DELETE /api/command-trackers/{id}` — delete (cascade delete listeners)
|
||||
- `POST /api/command-trackers/{id}/enable` — enable
|
||||
- `POST /api/command-trackers/{id}/disable` — disable
|
||||
|
||||
- [ ] Task 3: Add listener management endpoints to command_trackers.py:
|
||||
- `GET /api/command-trackers/{id}/listeners` — list listeners for a command tracker
|
||||
- `POST /api/command-trackers/{id}/listeners` — add listener (body: {listener_type, listener_id}). Validate: listener exists (e.g., TelegramBot with that ID), no duplicate (unique constraint), user owns the listener.
|
||||
- `DELETE /api/command-trackers/{id}/listeners/{listener_id}` — remove listener
|
||||
|
||||
- [ ] Task 4: Add validation helpers:
|
||||
- Validate `enabled_commands` against `commands/registry.py` known commands for the given provider_type
|
||||
- Validate `provider_type` match: CommandConfig.provider_type must match ServiceProvider.type of the CommandTracker's provider
|
||||
- Validate listener ownership: user must own the TelegramBot being attached
|
||||
|
||||
- [ ] Task 5: Register new routers in `main.py`
|
||||
|
||||
- [ ] Task 6: Update `api/telegram_bots.py` — remove the commands config endpoints (POST `/telegram-bots/{id}/commands`, GET `/telegram-bots/{id}/commands`) since commands config now lives in CommandConfig entity. Keep the sync-commands endpoint but update it to accept a command_config_id parameter or read from command trackers.
|
||||
|
||||
## Files to Modify/Create
|
||||
- `packages/server/src/notify_bridge_server/api/command_configs.py` — new file
|
||||
- `packages/server/src/notify_bridge_server/api/command_trackers.py` — new file
|
||||
- `packages/server/src/notify_bridge_server/main.py` — register new routers
|
||||
- `packages/server/src/notify_bridge_server/api/telegram_bots.py` — remove old commands config endpoints
|
||||
|
||||
## Acceptance Criteria
|
||||
- Full CRUD for CommandConfig with provider_type validation
|
||||
- Full CRUD for CommandTracker with provider↔config type matching
|
||||
- Listener add/remove with ownership validation and uniqueness
|
||||
- Old telegram bot commands config endpoints removed
|
||||
- Server starts and all new endpoints respond correctly
|
||||
|
||||
## Notes
|
||||
- The command registry currently defines commands globally. In future, commands could be provider-scoped. For now, validate enabled_commands against the flat registry list.
|
||||
- CommandConfig with user_id=0 could serve as system defaults (like TemplateConfig), but this is optional for Phase 3.
|
||||
- The sync-commands endpoint on TelegramBot may need to resolve which commands to sync from attached CommandTrackers — this is wired up in Phase 4.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -0,0 +1,87 @@
|
||||
# Phase 4: Command System Refactor
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Refactor the command handling system to resolve commands through CommandTracker → CommandConfig
|
||||
instead of TelegramBot.commands_config. Implement smart ref-counted polling/webhook management
|
||||
for TelegramBot when used as a commands listener. Handle multi-tracker routing (one bot serving
|
||||
multiple command trackers for different providers).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Refactor `commands/handler.py` — `handle_command()`:
|
||||
- Instead of reading `bot.commands_config`, resolve command config through CommandTrackerListeners:
|
||||
1. Find all CommandTrackerListener rows where listener_type="telegram_bot" AND listener_id=bot.id
|
||||
2. Load the associated CommandTracker for each (filter enabled=True)
|
||||
3. Load CommandConfig for each tracker
|
||||
4. Load ServiceProvider for each tracker
|
||||
- For each incoming command, check which CommandConfig(s) have it enabled
|
||||
- If multiple trackers enable the same command (e.g., two Immich providers with /latest), use the first match or let the user disambiguate (future enhancement — for now, use first enabled match)
|
||||
- Pass the resolved provider config to command execution functions
|
||||
|
||||
- [ ] Task 2: Update `_get_bot_context()` in handler.py:
|
||||
- Currently finds trackers/providers by matching bot_token in notification target configs
|
||||
- New approach: resolve through CommandTracker → provider_id → ServiceProvider
|
||||
- Return a list of (command_tracker, command_config, provider) tuples
|
||||
|
||||
- [ ] Task 3: Implement smart ref-counted polling/webhook in `services/telegram_poller.py`:
|
||||
- Track active listener count per bot: when a CommandTrackerListener is added for a bot, increment ref count; when removed, decrement
|
||||
- `start_bot_if_needed(bot_id)` — start polling/webhook only if not already running
|
||||
- `stop_bot_if_unused(bot_id)` — stop polling/webhook only if ref count reaches 0
|
||||
- Export these functions for use by the command_trackers API (when adding/removing listeners)
|
||||
|
||||
- [ ] Task 4: Update `commands/webhook.py`:
|
||||
- Webhook handler already receives messages for a specific bot (by webhook_path_id)
|
||||
- Update to use the new command resolution flow from Task 1
|
||||
- Ensure chat auto-discovery still works
|
||||
|
||||
- [ ] Task 5: Update `services/scheduler.py`:
|
||||
- On startup, instead of starting polling for all bots with update_mode="polling", start polling only for bots that have active CommandTrackerListeners
|
||||
- Use ref-counting logic from Task 3
|
||||
|
||||
- [ ] Task 6: Update telegram bot sync-commands endpoint:
|
||||
- `POST /api/telegram-bots/{id}/sync-commands` should now:
|
||||
1. Find all CommandTrackerListeners for this bot
|
||||
2. Collect all enabled commands across all linked CommandConfigs
|
||||
3. Merge command lists (union of enabled commands)
|
||||
4. Call setMyCommands with the merged list
|
||||
5. Use locale from the first CommandConfig (or a bot-level default)
|
||||
|
||||
- [ ] Task 7: Update `services/__init__.py` startup logic:
|
||||
- On startup, enumerate all enabled CommandTrackers with listeners
|
||||
- For each unique bot referenced, call `start_bot_if_needed(bot_id)`
|
||||
|
||||
## Files to Modify/Create
|
||||
- `packages/server/src/notify_bridge_server/commands/handler.py` — new command resolution flow
|
||||
- `packages/server/src/notify_bridge_server/commands/webhook.py` — updated handler
|
||||
- `packages/server/src/notify_bridge_server/services/telegram_poller.py` — ref-counted polling
|
||||
- `packages/server/src/notify_bridge_server/services/scheduler.py` — startup logic
|
||||
- `packages/server/src/notify_bridge_server/services/__init__.py` — startup logic
|
||||
- `packages/server/src/notify_bridge_server/api/telegram_bots.py` — sync-commands update
|
||||
|
||||
## Acceptance Criteria
|
||||
- Commands resolve through CommandTracker → CommandConfig instead of TelegramBot.commands_config
|
||||
- Bot polling/webhook starts only when at least one CommandTrackerListener references the bot
|
||||
- Bot polling/webhook stops when last listener is removed
|
||||
- Multiple command trackers can share the same bot — commands are merged
|
||||
- Telegram bot sync-commands syncs the merged command set
|
||||
- Existing command functionality (search, latest, random, etc.) still works end-to-end
|
||||
|
||||
## Notes
|
||||
- Rate limiting can stay in-memory per (bot_id, chat_id, category) — no schema change needed.
|
||||
- The handler currently uses `_get_bot_context()` to find providers via notification targets. The new flow resolves providers via CommandTracker.provider_id — this is cleaner and decouples commands from notification targets.
|
||||
- Edge case: a bot with no CommandTrackerListeners should not poll/webhook. If a user deletes all command trackers referencing a bot, polling should stop.
|
||||
- Edge case: a command tracker can be disabled (enabled=False) — disabled trackers don't count for ref-counting.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -0,0 +1,75 @@
|
||||
# Phase 5: Frontend — Rename & Restructure
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Rename all tracker-related frontend pages, routes, API calls, and i18n keys to use
|
||||
"notification tracker" naming. Add chat_action dropdown to telegram target form.
|
||||
Update navigation.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Rename route directory `frontend/src/routes/trackers/` → `frontend/src/routes/notification-trackers/`. Update `+page.svelte` to use new API endpoints (`/api/notification-trackers`, `/api/notification-tracker-targets`).
|
||||
|
||||
- [ ] Task 2: Update `+layout.svelte` navigation:
|
||||
- Change "Trackers" nav item to "Notification Trackers" (or shorter "Notif. Trackers") with route `/notification-trackers`
|
||||
- Keep icon the same
|
||||
|
||||
- [ ] Task 3: Update `frontend/src/lib/i18n/en.json`:
|
||||
- Rename `tracker.*` keys to `notificationTracker.*`
|
||||
- Rename `trackerTarget.*` keys to `notificationTrackerTarget.*`
|
||||
- Add nav key: `nav.notificationTrackers`
|
||||
- Add `targets.chatAction`, `targets.chatActionHelp` keys
|
||||
- Remove old `tracker.*` keys
|
||||
|
||||
- [ ] Task 4: Update `frontend/src/lib/i18n/ru.json` — same key renames as en.json with Russian translations
|
||||
|
||||
- [ ] Task 5: Update `frontend/src/routes/targets/+page.svelte`:
|
||||
- Add `chat_action` dropdown to telegram target form (options: none/typing/upload_photo/upload_video/upload_document/record_video/record_voice)
|
||||
- Include chat_action in create/update API calls
|
||||
- Display chat_action in target list if set
|
||||
|
||||
- [ ] Task 6: Update `frontend/src/routes/notification-trackers/+page.svelte` (renamed from trackers):
|
||||
- All API calls point to `/api/notification-trackers` and `/api/notification-tracker-targets`
|
||||
- All variable names reflect "notificationTracker" naming
|
||||
- i18n keys updated to new prefixes
|
||||
|
||||
- [ ] Task 7: Update `frontend/src/routes/+page.svelte` (dashboard):
|
||||
- Update any tracker references/stats to use new API endpoints and naming
|
||||
|
||||
- [ ] Task 8: Update any other pages that reference trackers:
|
||||
- `tracking-configs/+page.svelte` — update if it links to trackers
|
||||
- `template-configs/+page.svelte` — update if it references trackers
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/src/routes/trackers/+page.svelte` → move to `frontend/src/routes/notification-trackers/+page.svelte`
|
||||
- `frontend/src/routes/+layout.svelte` — nav updates
|
||||
- `frontend/src/lib/i18n/en.json` — key renames
|
||||
- `frontend/src/lib/i18n/ru.json` — key renames
|
||||
- `frontend/src/routes/targets/+page.svelte` — chat_action
|
||||
- `frontend/src/routes/+page.svelte` — dashboard updates
|
||||
|
||||
## Acceptance Criteria
|
||||
- Navigation shows "Notification Trackers" linking to `/notification-trackers`
|
||||
- Notification trackers page works with renamed API endpoints
|
||||
- Telegram targets have chat_action dropdown
|
||||
- All i18n keys updated in both en and ru
|
||||
- Frontend builds without errors
|
||||
- No references to old `/api/trackers` endpoints remain
|
||||
|
||||
## Notes
|
||||
- The old `/trackers` route should be removed entirely (no redirect needed — this is an admin tool).
|
||||
- chat_action values map to Telegram's sendChatAction API parameter.
|
||||
- Keep the UI structure the same — this is a rename, not a redesign.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -0,0 +1,84 @@
|
||||
# Phase 6: Frontend — Command Entities
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Create new frontend pages for CommandConfig and CommandTracker management. Update the
|
||||
Telegram Bots page to remove inline commands config (now managed via CommandConfig entity)
|
||||
and show listener status instead.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create `frontend/src/routes/command-configs/+page.svelte`:
|
||||
- List view showing all command configs with name, provider_type badge, enabled command count, locale
|
||||
- Create form: name, icon, provider_type selector, enabled_commands checkboxes (from registry), locale dropdown, response_mode dropdown, default_count slider, rate_limits inputs
|
||||
- Edit/delete functionality
|
||||
- Follow existing page patterns (show/hide form toggle, icon picker, confirm modal for delete)
|
||||
|
||||
- [ ] Task 2: Create `frontend/src/routes/command-trackers/+page.svelte`:
|
||||
- List view showing command trackers: name, provider name, command config name, listener count, enabled status
|
||||
- Create form: name, icon, provider selector, command_config selector (filtered by matching provider_type), enabled toggle
|
||||
- Edit/delete functionality
|
||||
- Expandable section per tracker showing:
|
||||
- Linked listeners with type badge and name
|
||||
- "Add Listener" dropdown (select from user's telegram bots)
|
||||
- Remove listener button per listener
|
||||
|
||||
- [ ] Task 3: Update `frontend/src/routes/telegram-bots/+page.svelte`:
|
||||
- Remove the "Commands" expandable section (command enable/disable checkboxes, locale, response_mode, default_count, rate_limits)
|
||||
- Replace with "Listener Status" section showing:
|
||||
- List of command trackers using this bot as a listener
|
||||
- Each showing: tracker name, provider name, command config name, enabled status
|
||||
- Link to command tracker page
|
||||
- Keep: Chats section, Webhook section, Settings section (update_mode)
|
||||
|
||||
- [ ] Task 4: Update `frontend/src/routes/+layout.svelte` navigation:
|
||||
- Add "Command Configs" nav item (route `/command-configs`, icon: settings/cog)
|
||||
- Add "Command Trackers" nav item (route `/command-trackers`, icon: terminal/command)
|
||||
- Group navigation logically: Providers, Notification Trackers, Tracking, Templates, Targets, Bots | Command Trackers, Command Configs
|
||||
|
||||
- [ ] Task 5: Update `frontend/src/lib/i18n/en.json`:
|
||||
- Add `commandConfig.*` keys (title, form labels, validation messages)
|
||||
- Add `commandTracker.*` keys (title, form labels, listener management)
|
||||
- Add `nav.commandConfigs`, `nav.commandTrackers` keys
|
||||
- Remove `telegramBot.commands*` keys (moved to commandConfig)
|
||||
|
||||
- [ ] Task 6: Update `frontend/src/lib/i18n/ru.json` — same additions/removals as en.json with Russian translations
|
||||
|
||||
- [ ] Task 7: Update `frontend/src/routes/+page.svelte` (dashboard):
|
||||
- Add command tracker count/status to dashboard stats
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/src/routes/command-configs/+page.svelte` — new page
|
||||
- `frontend/src/routes/command-trackers/+page.svelte` — new page
|
||||
- `frontend/src/routes/telegram-bots/+page.svelte` — remove commands section, add listener status
|
||||
- `frontend/src/routes/+layout.svelte` — navigation
|
||||
- `frontend/src/lib/i18n/en.json` — new keys
|
||||
- `frontend/src/lib/i18n/ru.json` — new keys
|
||||
- `frontend/src/routes/+page.svelte` — dashboard
|
||||
|
||||
## Acceptance Criteria
|
||||
- CommandConfig page: full CRUD with provider_type filtering and command checkboxes
|
||||
- CommandTracker page: full CRUD with provider/config selection and listener management
|
||||
- Telegram Bots page: no more inline commands config, shows listener status instead
|
||||
- Navigation includes new pages in logical grouping
|
||||
- Both i18n languages updated
|
||||
- Frontend builds without errors
|
||||
|
||||
## Notes
|
||||
- Command checkboxes should show all 13 commands from the registry (help, status, albums, events, summary, latest, memory, random, search, find, person, place, favorites, people).
|
||||
- Provider_type filtering: when user selects a provider in CommandTracker form, only show CommandConfigs with matching provider_type.
|
||||
- The telegram bot "Sync with Telegram" button should remain — it now syncs commands from all linked command trackers.
|
||||
- Follow existing UI patterns closely (ConfirmModal, icon picker, collapsible sections, snackbar notifications).
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -0,0 +1,73 @@
|
||||
# Phase 7: Integration & Cleanup
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Final integration pass: verify end-to-end flows, clean up deprecated code paths,
|
||||
update CLAUDE.md entity relationship documentation, and ensure everything works
|
||||
together.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Verify notification flow end-to-end:
|
||||
- ServiceProvider → NotificationTracker → NotificationTrackerTarget → NotificationTarget
|
||||
- Watcher detects changes → dispatches through renamed entities
|
||||
- Scheduled/periodic/memory notifications still work
|
||||
|
||||
- [ ] Task 2: Verify command flow end-to-end:
|
||||
- CommandTracker → CommandConfig + CommandTrackerListener (TelegramBot)
|
||||
- Incoming command via webhook/polling → resolved through command tracker
|
||||
- Bot ref-counting: start/stop polling based on listener count
|
||||
|
||||
- [ ] Task 3: Clean up deprecated code:
|
||||
- Remove any remaining backward-compatibility aliases in models.py
|
||||
- Remove any old route files that were renamed (trackers.py, tracker_targets.py)
|
||||
- Remove any unused imports
|
||||
- Ensure no references to old model names remain anywhere
|
||||
|
||||
- [ ] Task 4: Update CLAUDE.md "Entity Relationships" section:
|
||||
- Document new schema: ServiceProvider capabilities, NotificationTracker, CommandTracker, CommandConfig, CommandTrackerListener
|
||||
- Update the entity relationship diagram
|
||||
- Update Template System Sync Rules if affected
|
||||
|
||||
- [ ] Task 5: Verify migration idempotency:
|
||||
- Fresh database: all tables created correctly
|
||||
- Existing database with old schema: migration runs without errors, data preserved
|
||||
- Running migration twice: no errors
|
||||
|
||||
- [ ] Task 6: Clean up any TODO markers left by previous phases
|
||||
|
||||
- [ ] Task 7: Verify frontend-backend integration:
|
||||
- All frontend pages load and display data correctly
|
||||
- CRUD operations work for all entities
|
||||
- Command tracker listener add/remove triggers bot polling start/stop
|
||||
|
||||
## Files to Modify/Create
|
||||
- `packages/server/src/notify_bridge_server/database/models.py` — cleanup aliases
|
||||
- `CLAUDE.md` — update entity relationships documentation
|
||||
- Various files — cleanup TODOs and unused code
|
||||
|
||||
## Acceptance Criteria
|
||||
- Full notification flow works: provider → notification tracker → target
|
||||
- Full command flow works: command tracker → command config → listener → bot
|
||||
- No references to old model/route names remain
|
||||
- CLAUDE.md accurately documents new entity schema
|
||||
- Server starts cleanly with both fresh and migrated databases
|
||||
- Frontend builds and all pages functional
|
||||
|
||||
## Notes
|
||||
- This phase is primarily verification and cleanup — no major new features.
|
||||
- If integration issues are found, fix them in this phase rather than going back.
|
||||
- The old plans/entity-relationship-refactor/ files from previous attempts can be kept as historical record.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- This is the final phase — no handoff needed. -->
|
||||
Reference in New Issue
Block a user