feat: telegram commands, app settings, bot polling, webhook handling, UI improvements
Adds telegram bot command system with 13 commands (search, latest, random, etc.), webhook/polling handlers, rate limiting, app settings page, and various UI/UX improvements across all entity pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,10 @@ PID=$(netstat -ano 2>/dev/null | grep ':8420.*LISTENING' | awk '{print $5}' | he
|
||||
PID=$(netstat -ano 2>/dev/null | grep ':5173.*LISTENING' | awk '{print $5}' | head -1) && [ -n "$PID" ] && taskkill //F //PID $PID 2>/dev/null; sleep 1 && cd frontend && npx vite dev --port 5173 --host > /dev/null 2>&1 & sleep 4 && curl -s -o /dev/null -w "Frontend: %{http_code}" http://localhost:5173/
|
||||
```
|
||||
|
||||
## Test Credentials
|
||||
|
||||
Default test account: username `admin`, password `admin1`.
|
||||
|
||||
## Frontend Architecture Notes
|
||||
|
||||
- **i18n**: Uses `$state` rune in `.svelte.ts` file. Locale auto-detects from localStorage. `t()` is reactive via `$state`. `setLocale()` updates immediately without page reload.
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
function buildExtensions(isDark: boolean) {
|
||||
const extensions = [
|
||||
jinjaLang,
|
||||
errorLineField,
|
||||
@@ -88,17 +88,14 @@
|
||||
'.ͼ5': { color: '#6b7280' },
|
||||
}),
|
||||
];
|
||||
if (isDark) extensions.push(oneDark);
|
||||
if (placeholder) extensions.push(cmPlaceholder(placeholder));
|
||||
return extensions;
|
||||
}
|
||||
|
||||
if (theme.isDark) {
|
||||
extensions.push(oneDark);
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
extensions.push(cmPlaceholder(placeholder));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc: value, extensions }),
|
||||
state: EditorState.create({ doc: value, extensions: buildExtensions(theme.isDark) }),
|
||||
parent: container,
|
||||
});
|
||||
|
||||
@@ -127,31 +124,8 @@
|
||||
const currentDoc = view.state.doc.toString();
|
||||
view.destroy();
|
||||
|
||||
const extensions = [
|
||||
jinjaLang,
|
||||
errorLineField,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onchange(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.theme({
|
||||
'&': { fontSize: '13px', fontFamily: "'Consolas', 'Monaco', 'Courier New', monospace" },
|
||||
'.cm-content': { minHeight: `${rows * 1.5}em`, padding: '8px' },
|
||||
'.cm-editor': { borderRadius: '0.375rem', border: '1px solid var(--color-border)' },
|
||||
'.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '0px' },
|
||||
'.cm-error-line': { backgroundColor: 'rgba(239, 68, 68, 0.2)', outline: '1px solid rgba(239, 68, 68, 0.4)' },
|
||||
'.ͼc': { color: '#e879f9' },
|
||||
'.ͼd': { color: '#38bdf8' },
|
||||
'.ͼ5': { color: '#6b7280' },
|
||||
}),
|
||||
];
|
||||
if (isDark) extensions.push(oneDark);
|
||||
if (placeholder) extensions.push(cmPlaceholder(placeholder));
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc: currentDoc, extensions }),
|
||||
state: EditorState.create({ doc: currentDoc, extensions: buildExtensions(isDark) }),
|
||||
parent: container,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"telegramBots": "Bots",
|
||||
"targets": "Targets",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"auth": {
|
||||
@@ -62,6 +63,7 @@
|
||||
"assets": "assets",
|
||||
"eventActivity": "Event Activity",
|
||||
"last14days": "Last 14 days",
|
||||
"event": "event",
|
||||
"events": "events",
|
||||
"noChartData": "No event data yet"
|
||||
},
|
||||
@@ -85,7 +87,11 @@
|
||||
"checking": "Checking...",
|
||||
"loadError": "Failed to load providers.",
|
||||
"externalDomain": "External Domain",
|
||||
"optional": "optional"
|
||||
"optional": "optional",
|
||||
"urlApiKeyRequired": "URL and API Key are required",
|
||||
"externalDomainHint": "Public-facing URL for notification links. Falls back to server URL.",
|
||||
"testAndSave": "Test & Save",
|
||||
"saveWithoutTest": "Save without testing"
|
||||
},
|
||||
"trackers": {
|
||||
"title": "Trackers",
|
||||
@@ -134,7 +140,16 @@
|
||||
"testBasic": "Send test message",
|
||||
"testPeriodic": "Test periodic summary",
|
||||
"testScheduled": "Test scheduled assets",
|
||||
"testMemory": "Test memory / On This Day"
|
||||
"testMemory": "Test memory / On This Day",
|
||||
"checkingLinks": "Checking links...",
|
||||
"missingLinksTitle": "Albums Missing Public Links",
|
||||
"missingLinksDesc": "The following albums don't have public shared links. Without links, notification recipients won't be able to view photos.",
|
||||
"expired": "Expired",
|
||||
"passwordProtected": "Password Protected",
|
||||
"noLink": "No Link",
|
||||
"saveWithoutLinks": "Save without links",
|
||||
"createLinks": "Create {count} link(s)",
|
||||
"linksNote": "You can also create links manually in Immich."
|
||||
},
|
||||
"templates": {
|
||||
"title": "Templates",
|
||||
@@ -198,7 +213,8 @@
|
||||
"create": "Create User",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this user?",
|
||||
"joined": "joined"
|
||||
"joined": "joined",
|
||||
"noUsers": "No users found"
|
||||
},
|
||||
"telegramBot": {
|
||||
"title": "Telegram Bots",
|
||||
@@ -220,21 +236,48 @@
|
||||
"channel": "Channel",
|
||||
"confirmDelete": "Delete this bot?",
|
||||
"commands": "Commands",
|
||||
"enabledCommands": "Enabled Commands",
|
||||
"defaultCount": "Default result count",
|
||||
"enabledCommands": "Enabled commands",
|
||||
"defaultCount": "Default count",
|
||||
"responseMode": "Response mode",
|
||||
"modeMedia": "Media (send photos)",
|
||||
"modeText": "Text (send links)",
|
||||
"modeMedia": "Media (photos)",
|
||||
"modeText": "Text only",
|
||||
"botLocale": "Bot language",
|
||||
"rateLimits": "Rate Limits",
|
||||
"rateSearch": "Search cooldown",
|
||||
"rateFind": "Find cooldown",
|
||||
"rateDefault": "Default cooldown",
|
||||
"syncCommands": "Sync to Telegram",
|
||||
"syncCommands": "Sync with Telegram",
|
||||
"discoverChats": "Discover chats from Telegram",
|
||||
"clickToCopy": "Click to copy chat ID",
|
||||
"chatsDiscovered": "Chats discovered",
|
||||
"chatDeleted": "Chat removed"
|
||||
"chatDeleted": "Chat removed",
|
||||
"cmdLocale": "Bot language",
|
||||
"searchCooldown": "Search cooldown (s)",
|
||||
"saveConfig": "Save config",
|
||||
"commandsSynced": "Commands synced with Telegram",
|
||||
"registerWebhook": "Register webhook",
|
||||
"unregisterWebhook": "Unregister webhook",
|
||||
"webhookRegistered": "Webhook registered",
|
||||
"webhookUnregistered": "Webhook unregistered",
|
||||
"updateMode": "Update mode",
|
||||
"polling": "Polling",
|
||||
"webhook": "Webhook",
|
||||
"webhookStatus": "Webhook status",
|
||||
"webhookActive": "Webhook active",
|
||||
"webhookNotSet": "No webhook set",
|
||||
"webhookVerified": "Webhook verified",
|
||||
"webhookError": "Last error",
|
||||
"pendingUpdates": "pending updates",
|
||||
"pollingActive": "Polling active",
|
||||
"telegramSettings": "Telegram Settings",
|
||||
"externalUrl": "External URL",
|
||||
"externalUrlHint": "Public URL of this Notify Bridge instance. Required for webhook mode.",
|
||||
"webhookSecret": "Webhook secret",
|
||||
"webhookSecretHint": "Optional secret token to verify webhook requests from Telegram",
|
||||
"cacheTtl": "Media cache TTL (hours)",
|
||||
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading (default: 48h)",
|
||||
"settingsSaved": "Settings saved",
|
||||
"noExternalDomain": "External domain URL not configured"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"title": "Tracking Configs",
|
||||
@@ -269,6 +312,9 @@
|
||||
"assetType": "Asset type",
|
||||
"minRating": "Min rating",
|
||||
"memoryMode": "Memory Mode (On This Day)",
|
||||
"memorySource": "Memory source",
|
||||
"memorySourceAlbums": "Scan tracked albums",
|
||||
"memorySourceNative": "Immich native memories",
|
||||
"test": "Test",
|
||||
"confirmDelete": "Delete this tracking config?",
|
||||
"sortNone": "None",
|
||||
@@ -282,7 +328,14 @@
|
||||
"albumModeRandom": "Random",
|
||||
"assetTypeAll": "All",
|
||||
"assetTypePhoto": "Photo",
|
||||
"assetTypeVideo": "Video"
|
||||
"assetTypeVideo": "Video",
|
||||
"periodic": "periodic",
|
||||
"scheduled": "scheduled",
|
||||
"memory": "memory",
|
||||
"added": "added",
|
||||
"removed": "removed",
|
||||
"renamed": "renamed",
|
||||
"deleted": "deleted"
|
||||
},
|
||||
"templateConfig": {
|
||||
"title": "Template Configs",
|
||||
@@ -324,7 +377,8 @@
|
||||
"variables": "Variables",
|
||||
"assetFields": "Asset fields (in {% for asset in added_assets %})",
|
||||
"albumFields": "Album fields (in {% for album in albums %})",
|
||||
"confirmDelete": "Delete this template config?"
|
||||
"confirmDelete": "Delete this template config?",
|
||||
"invalidFormat": "Invalid format string"
|
||||
},
|
||||
"templateVars": {
|
||||
"message_assets_added": { "description": "Notification when new assets are added to an album" },
|
||||
@@ -378,10 +432,24 @@
|
||||
"album_url_field": "Album share URL",
|
||||
"album_shared": "Whether album is shared"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"description": "Global application settings",
|
||||
"general": "General",
|
||||
"externalUrl": "External URL",
|
||||
"externalUrlHint": "Public URL of this Notify Bridge instance (e.g. https://notify.example.com)",
|
||||
"telegram": "Telegram",
|
||||
"webhookSecret": "Webhook Secret",
|
||||
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
||||
"cacheTtl": "Media Cache TTL (hours)",
|
||||
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
|
||||
"saved": "Settings saved"
|
||||
},
|
||||
"hints": {
|
||||
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
|
||||
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
|
||||
"memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.",
|
||||
"memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).",
|
||||
"favoritesOnly": "Only include assets marked as favorites.",
|
||||
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
||||
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
|
||||
@@ -406,6 +474,10 @@
|
||||
"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."
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details"
|
||||
},
|
||||
"snack": {
|
||||
"providerSaved": "Provider saved",
|
||||
"providerDeleted": "Provider deleted",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"telegramBots": "Боты",
|
||||
"targets": "Получатели",
|
||||
"users": "Пользователи",
|
||||
"settings": "Настройки",
|
||||
"logout": "Выход"
|
||||
},
|
||||
"auth": {
|
||||
@@ -62,6 +63,7 @@
|
||||
"assets": "файлов",
|
||||
"eventActivity": "Активность событий",
|
||||
"last14days": "Последние 14 дней",
|
||||
"event": "событие",
|
||||
"events": "событий",
|
||||
"noChartData": "Нет данных о событиях"
|
||||
},
|
||||
@@ -85,7 +87,11 @@
|
||||
"checking": "Проверка...",
|
||||
"loadError": "Не удалось загрузить провайдеры.",
|
||||
"externalDomain": "Внешний домен",
|
||||
"optional": "необязательно"
|
||||
"optional": "необязательно",
|
||||
"urlApiKeyRequired": "URL и API ключ обязательны",
|
||||
"externalDomainHint": "Публичный URL для ссылок в уведомлениях. По умолчанию используется URL сервера.",
|
||||
"testAndSave": "Проверить и сохранить",
|
||||
"saveWithoutTest": "Сохранить без проверки"
|
||||
},
|
||||
"trackers": {
|
||||
"title": "Трекеры",
|
||||
@@ -134,7 +140,16 @@
|
||||
"testBasic": "Отправить тестовое сообщение",
|
||||
"testPeriodic": "Тест периодической сводки",
|
||||
"testScheduled": "Тест запланированных фото",
|
||||
"testMemory": "Тест воспоминаний"
|
||||
"testMemory": "Тест воспоминаний",
|
||||
"checkingLinks": "Проверка ссылок...",
|
||||
"missingLinksTitle": "Альбомы без публичных ссылок",
|
||||
"missingLinksDesc": "У следующих альбомов нет публичных ссылок. Без ссылок получатели уведомлений не смогут просматривать фото.",
|
||||
"expired": "Истёк",
|
||||
"passwordProtected": "Защищён паролем",
|
||||
"noLink": "Нет ссылки",
|
||||
"saveWithoutLinks": "Сохранить без ссылок",
|
||||
"createLinks": "Создать {count} ссылку(и)",
|
||||
"linksNote": "Вы также можете создать ссылки вручную в Immich."
|
||||
},
|
||||
"templates": {
|
||||
"title": "Шаблоны",
|
||||
@@ -198,7 +213,8 @@
|
||||
"create": "Создать",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить этого пользователя?",
|
||||
"joined": "зарегистрирован"
|
||||
"joined": "зарегистрирован",
|
||||
"noUsers": "Пользователи не найдены"
|
||||
},
|
||||
"telegramBot": {
|
||||
"title": "Telegram боты",
|
||||
@@ -221,10 +237,10 @@
|
||||
"confirmDelete": "Удалить этого бота?",
|
||||
"commands": "Команды",
|
||||
"enabledCommands": "Включённые команды",
|
||||
"defaultCount": "Кол-во результатов",
|
||||
"defaultCount": "Кол-во по умолчанию",
|
||||
"responseMode": "Режим ответа",
|
||||
"modeMedia": "Медиа (отправка фото)",
|
||||
"modeText": "Текст (ссылки)",
|
||||
"modeMedia": "Медиа (фото)",
|
||||
"modeText": "Только текст",
|
||||
"botLocale": "Язык бота",
|
||||
"rateLimits": "Ограничения частоты",
|
||||
"rateSearch": "Кулдаун поиска",
|
||||
@@ -234,7 +250,34 @@
|
||||
"discoverChats": "Обнаружить чаты из Telegram",
|
||||
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
||||
"chatsDiscovered": "Чаты обнаружены",
|
||||
"chatDeleted": "Чат удалён"
|
||||
"chatDeleted": "Чат удалён",
|
||||
"cmdLocale": "Язык бота",
|
||||
"searchCooldown": "Кулдаун поиска (с)",
|
||||
"saveConfig": "Сохранить настройки",
|
||||
"commandsSynced": "Команды синхронизированы с Telegram",
|
||||
"registerWebhook": "Зарегистрировать вебхук",
|
||||
"unregisterWebhook": "Удалить вебхук",
|
||||
"webhookRegistered": "Вебхук зарегистрирован",
|
||||
"webhookUnregistered": "Вебхук удалён",
|
||||
"updateMode": "Режим обновлений",
|
||||
"polling": "Опрос",
|
||||
"webhook": "Вебхук",
|
||||
"webhookStatus": "Статус вебхука",
|
||||
"webhookActive": "Вебхук активен",
|
||||
"webhookNotSet": "Вебхук не установлен",
|
||||
"webhookVerified": "Вебхук проверен",
|
||||
"webhookError": "Последняя ошибка",
|
||||
"pendingUpdates": "ожидающих обновлений",
|
||||
"pollingActive": "Опрос активен",
|
||||
"telegramSettings": "Настройки Telegram",
|
||||
"externalUrl": "Внешний URL",
|
||||
"externalUrlHint": "Публичный URL этого экземпляра Notify Bridge. Необходим для режима вебхука.",
|
||||
"webhookSecret": "Секрет вебхука",
|
||||
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram (необязательно)",
|
||||
"cacheTtl": "TTL кэша медиа (часы)",
|
||||
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой (по умолчанию: 48ч)",
|
||||
"settingsSaved": "Настройки сохранены",
|
||||
"noExternalDomain": "Внешний URL домена не настроен"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"title": "Конфигурации отслеживания",
|
||||
@@ -269,6 +312,9 @@
|
||||
"assetType": "Тип файлов",
|
||||
"minRating": "Мин. рейтинг",
|
||||
"memoryMode": "Воспоминания (В этот день)",
|
||||
"memorySource": "Источник воспоминаний",
|
||||
"memorySourceAlbums": "Сканировать альбомы",
|
||||
"memorySourceNative": "Встроенные воспоминания Immich",
|
||||
"test": "Тест",
|
||||
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
|
||||
"sortNone": "Нет",
|
||||
@@ -282,7 +328,14 @@
|
||||
"albumModeRandom": "Случайный",
|
||||
"assetTypeAll": "Все",
|
||||
"assetTypePhoto": "Фото",
|
||||
"assetTypeVideo": "Видео"
|
||||
"assetTypeVideo": "Видео",
|
||||
"periodic": "периодический",
|
||||
"scheduled": "запланированный",
|
||||
"memory": "воспоминания",
|
||||
"added": "добавление",
|
||||
"removed": "удаление",
|
||||
"renamed": "переименование",
|
||||
"deleted": "удалён"
|
||||
},
|
||||
"templateConfig": {
|
||||
"title": "Конфигурации шаблонов",
|
||||
@@ -324,7 +377,8 @@
|
||||
"variables": "Переменные",
|
||||
"assetFields": "Поля файла (в {% for asset in added_assets %})",
|
||||
"albumFields": "Поля альбома (в {% for album in albums %})",
|
||||
"confirmDelete": "Удалить эту конфигурацию шаблона?"
|
||||
"confirmDelete": "Удалить эту конфигурацию шаблона?",
|
||||
"invalidFormat": "Некорректная строка формата"
|
||||
},
|
||||
"templateVars": {
|
||||
"message_assets_added": { "description": "Уведомление о добавлении файлов в альбом" },
|
||||
@@ -378,10 +432,24 @@
|
||||
"album_url_field": "Ссылка на альбом",
|
||||
"album_shared": "Общий альбом"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
"description": "Глобальные настройки приложения",
|
||||
"general": "Общие",
|
||||
"externalUrl": "Внешний URL",
|
||||
"externalUrlHint": "Публичный URL этого экземпляра Notify Bridge (напр. https://notify.example.com)",
|
||||
"telegram": "Telegram",
|
||||
"webhookSecret": "Секрет вебхука",
|
||||
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
||||
"cacheTtl": "TTL кэша медиа (часы)",
|
||||
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
|
||||
"saved": "Настройки сохранены"
|
||||
},
|
||||
"hints": {
|
||||
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
|
||||
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
|
||||
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
|
||||
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
|
||||
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
|
||||
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
||||
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
|
||||
@@ -406,6 +474,10 @@
|
||||
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали"
|
||||
},
|
||||
"snack": {
|
||||
"providerSaved": "Провайдер сохранён",
|
||||
"providerDeleted": "Провайдер удалён",
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="text-center animate-fade-slide-in">
|
||||
<h1 class="text-6xl font-bold text-muted-foreground mb-4">{page.status}</h1>
|
||||
<p class="text-lg text-muted-foreground mb-8">{page.error?.message || 'Page not found'}</p>
|
||||
<a href="/" class="px-6 py-3 bg-primary text-primary-foreground rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity">
|
||||
<h1 class="text-6xl font-bold text-[var(--color-muted-foreground)] mb-4">{page.status}</h1>
|
||||
<p class="text-lg text-[var(--color-muted-foreground)] mb-8">{page.error?.message || 'Page not found'}</p>
|
||||
<a href="/" class="px-6 py-3 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity">
|
||||
Go home
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
|
||||
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
|
||||
import { t, getLocale, setLocale, type Locale } from '$lib/i18n';
|
||||
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
@@ -37,7 +36,7 @@
|
||||
|
||||
let collapsed = $state(false);
|
||||
|
||||
const navItems = [
|
||||
const baseNavItems = [
|
||||
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
|
||||
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
|
||||
{ href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
|
||||
@@ -46,20 +45,23 @@
|
||||
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
];
|
||||
const navItems = $derived(auth.isAdmin
|
||||
? [...baseNavItems, { href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' }, { href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' }]
|
||||
: baseNavItems
|
||||
);
|
||||
|
||||
const isAuthPage = $derived(
|
||||
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
initLocale();
|
||||
initTheme();
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
}
|
||||
await loadUser();
|
||||
if (!auth.user && !isAuthPage) {
|
||||
goto('/login');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -139,22 +141,6 @@
|
||||
{#if !collapsed}<span class="truncate">{t(item.key)}</span>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{#if auth.isAdmin}
|
||||
<a
|
||||
href="/users"
|
||||
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
|
||||
style="color: {isActive('/users') ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive('/users') ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive('/users') ? '500' : '400'};"
|
||||
onmouseenter={(e) => { if (!isActive('/users')) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||
onmouseleave={(e) => { if (!isActive('/users')) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||
title={collapsed ? t('nav.users') : ''}
|
||||
>
|
||||
{#if isActive('/users')}
|
||||
<div style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||
{/if}
|
||||
<MdiIcon name="mdiAccountGroup" size={18} />
|
||||
{#if !collapsed}<span class="truncate">{t('nav.users')}</span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
@@ -105,7 +105,11 @@
|
||||
eventsLimit = calcPageSize();
|
||||
window.addEventListener('resize', onResize);
|
||||
loadInitial();
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
clearTimeout(searchTimeout);
|
||||
clearTimeout(resizeTimeout);
|
||||
};
|
||||
});
|
||||
|
||||
async function loadInitial() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { login } from '$lib/auth.svelte';
|
||||
import { t, initLocale, getLocale, setLocale } from '$lib/i18n';
|
||||
import { t, getLocale, setLocale } from '$lib/i18n';
|
||||
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
let mounted = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
initLocale();
|
||||
initTheme();
|
||||
mounted = true;
|
||||
try {
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
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 } from '$lib/types';
|
||||
|
||||
let providers = $state<any[]>([]);
|
||||
let providers = $state<ServiceProvider[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' });
|
||||
@@ -20,7 +22,7 @@
|
||||
let loadError = $state('');
|
||||
let submitting = $state(false);
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
let confirmDelete = $state<ServiceProvider | null>(null);
|
||||
|
||||
let health = $state<Record<number, boolean | null>>({});
|
||||
|
||||
@@ -143,10 +145,7 @@
|
||||
|
||||
{#if providers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiServer" size={40} /></div>
|
||||
<p class="text-sm">{t('providers.noProviders')}</p>
|
||||
</div>
|
||||
<EmptyState icon="mdiServer" message={t('providers.noProviders')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
let saving = $state(false);
|
||||
|
||||
async function testAndSave() {
|
||||
if (!url || !apiKey) { error = 'URL and API Key are required'; return; }
|
||||
if (!url || !apiKey) { error = t('providers.urlApiKeyRequired'); return; }
|
||||
testing = true; error = '';
|
||||
let createdId: number | null = null;
|
||||
try {
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
async function saveWithoutTest() {
|
||||
if (!url || !apiKey) { error = 'URL and API Key are required'; return; }
|
||||
if (!url || !apiKey) { error = t('providers.urlApiKeyRequired'); return; }
|
||||
saving = true; error = '';
|
||||
try {
|
||||
await api('/providers', {
|
||||
@@ -86,7 +86,7 @@
|
||||
<div>
|
||||
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-ext" type="url" bind:value={externalDomain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">Public-facing URL for notification links. Falls back to server URL.</p>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.externalDomainHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -97,11 +97,11 @@
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button onclick={testAndSave} disabled={testing || saving}
|
||||
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">
|
||||
{testing ? t('providers.connecting') : 'Test & Save'}
|
||||
{testing ? t('providers.connecting') : t('providers.testAndSave')}
|
||||
</button>
|
||||
<button onclick={saveWithoutTest} disabled={testing || saving}
|
||||
class="px-4 py-2 bg-[var(--color-muted)] text-[var(--color-foreground)] rounded-md text-sm font-medium hover:opacity-80 disabled:opacity-50">
|
||||
{saving ? t('common.loading') : 'Save without testing'}
|
||||
{saving ? t('common.loading') : t('providers.saveWithoutTest')}
|
||||
</button>
|
||||
<a href="/providers" class="px-4 py-2 bg-[var(--color-muted)] text-[var(--color-muted-foreground)] rounded-md text-sm font-medium hover:opacity-80">
|
||||
{t('common.cancel')}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<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 MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let loaded = $state(false);
|
||||
let saving = $state(false);
|
||||
let settings = $state({
|
||||
external_url: '',
|
||||
telegram_webhook_secret: '',
|
||||
telegram_cache_ttl_hours: '48',
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
settings = await api('/settings');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
finally { loaded = true; }
|
||||
});
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
|
||||
snackSuccess(t('settings.saved'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
saving = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('settings.title')} description={t('settings.description')} />
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- General section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiCog" size={18} />
|
||||
{t('settings.general')}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.externalUrl')}<Hint text={t('settings.externalUrlHint')} /></label>
|
||||
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Telegram section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiSend" size={18} />
|
||||
{t('settings.telegram')}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
||||
<input bind:value={settings.telegram_webhook_secret} type="password" placeholder="optional"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
|
||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="1" max="720"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<button onclick={save} disabled={saving}
|
||||
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">
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { setup } from '$lib/auth.svelte';
|
||||
import { t, initLocale } from '$lib/i18n';
|
||||
import { t } from '$lib/i18n';
|
||||
import { initTheme } from '$lib/theme.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
let submitting = $state(false);
|
||||
let mounted = $state(false);
|
||||
|
||||
onMount(() => { initLocale(); initTheme(); mounted = true; });
|
||||
onMount(() => { initTheme(); mounted = true; });
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -8,14 +8,16 @@
|
||||
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 Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import type { NotificationTarget, TelegramBot, TelegramChat } from '$lib/types';
|
||||
|
||||
let targets = $state<any[]>([]);
|
||||
let bots = $state<any[]>([]);
|
||||
let botChats = $state<Record<number, any[]>>({});
|
||||
let targets = $state<NotificationTarget[]>([]);
|
||||
let bots = $state<TelegramBot[]>([]);
|
||||
let botChats = $state<Record<number, TelegramChat[]>>({});
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let formType = $state<'telegram' | 'webhook'>('telegram');
|
||||
@@ -29,7 +31,7 @@
|
||||
let submitting = $state(false);
|
||||
let loadError = $state('');
|
||||
let showTelegramSettings = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
let confirmDelete = $state<NotificationTarget | null>(null);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
@@ -223,10 +225,7 @@
|
||||
|
||||
{#if targets.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiTarget" size={40} /></div>
|
||||
<p class="text-sm">{t('targets.noTargets')}</p>
|
||||
</div>
|
||||
<EmptyState icon="mdiTarget" message={t('targets.noTargets')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
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, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||
import type { TelegramBot, TelegramChat } from '$lib/types';
|
||||
|
||||
let bots = $state<any[]>([]);
|
||||
let bots = $state<TelegramBot[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
@@ -21,15 +23,25 @@
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
// Global settings (loaded for webhook mode checks)
|
||||
let settings = $state<any>({});
|
||||
|
||||
// Per-bot expandable sections
|
||||
let chats = $state<Record<number, any[]>>({});
|
||||
let chats = $state<Record<number, TelegramChat[]>>({});
|
||||
let chatsLoading = $state<Record<number, boolean>>({});
|
||||
let expandedSection = $state<Record<number, string>>({});
|
||||
|
||||
// Webhook status per bot
|
||||
let webhookStatus = $state<Record<number, any>>({});
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { bots = await api('/telegram-bots'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
try {
|
||||
[bots, settings] = await Promise.all([
|
||||
api('/telegram-bots'),
|
||||
api('/settings'),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
@@ -48,7 +60,7 @@
|
||||
}
|
||||
form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
submitting = false;
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
@@ -96,6 +108,116 @@
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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 };
|
||||
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 };
|
||||
}
|
||||
|
||||
async function syncCommands(botId: number) {
|
||||
cmdSyncing = { ...cmdSyncing, [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 };
|
||||
}
|
||||
|
||||
async function switchMode(botId: number, mode: string) {
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/telegram-bots/${botId}`, { method: 'PUT', body: JSON.stringify({ update_mode: mode }) });
|
||||
await load();
|
||||
if (mode === 'webhook') {
|
||||
// Load webhook status after switching
|
||||
await loadWebhookStatus(botId);
|
||||
}
|
||||
snackSuccess(t('snack.botUpdated'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
async function loadWebhookStatus(botId: number) {
|
||||
try {
|
||||
webhookStatus = { ...webhookStatus, [botId]: await api(`/telegram-bots/${botId}/webhook/status`) };
|
||||
} catch { webhookStatus = { ...webhookStatus, [botId]: null }; }
|
||||
}
|
||||
|
||||
async function registerWebhook(botId: number) {
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/telegram-bots/${botId}/webhook/register`, { method: 'POST' });
|
||||
if (res.success) {
|
||||
snackSuccess(res.verified ? t('telegramBot.webhookVerified') : t('telegramBot.webhookRegistered'));
|
||||
await loadWebhookStatus(botId);
|
||||
} else {
|
||||
snackError(res.error || 'Failed to register webhook');
|
||||
}
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
async function unregisterWebhook(botId: number) {
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
|
||||
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
function copyChatId(e: Event, chatId: string) {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(chatId);
|
||||
@@ -164,10 +286,7 @@
|
||||
|
||||
{#if bots.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiRobot" size={40} /></div>
|
||||
<p class="text-sm">{t('telegramBot.noBots')}</p>
|
||||
</div>
|
||||
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
@@ -181,6 +300,12 @@
|
||||
{#if bot.bot_username}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||
{/if}
|
||||
<!-- Mode badge -->
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook'
|
||||
? 'bg-blue-500/10 text-blue-500'
|
||||
: 'bg-emerald-500/10 text-emerald-500'}">
|
||||
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||
</div>
|
||||
@@ -190,6 +315,10 @@
|
||||
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'); }}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('telegramBot.commands')} {expandedSection[bot.id] === 'commands' ? '▲' : '▼'}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,6 +360,135 @@
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Commands section -->
|
||||
{#if expandedSection[bot.id] === 'commands' && cmdConfig[bot.id]}
|
||||
<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>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<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">
|
||||
<div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
|
||||
<button onclick={() => switchMode(bot.id, 'polling')}
|
||||
disabled={modeChanging[bot.id] || bot.update_mode === 'polling'}
|
||||
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling'
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
|
||||
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
{t('telegramBot.polling')}
|
||||
</button>
|
||||
<button onclick={() => switchMode(bot.id, 'webhook')}
|
||||
disabled={modeChanging[bot.id] || bot.update_mode === 'webhook'}
|
||||
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'webhook'
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
|
||||
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
|
||||
<MdiIcon name="mdiWebhook" size={14} />
|
||||
{t('telegramBot.webhook')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if bot.update_mode === 'polling'}
|
||||
<span class="text-xs text-emerald-500 flex items-center gap-1">
|
||||
<MdiIcon name="mdiCheckCircle" size={14} />
|
||||
{t('telegramBot.pollingActive')}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if bot.update_mode === 'webhook'}
|
||||
<button onclick={() => registerWebhook(bot.id)} disabled={modeChanging[bot.id]}
|
||||
class="px-2 py-1 text-xs border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
{t('telegramBot.registerWebhook')}
|
||||
</button>
|
||||
<button onclick={() => unregisterWebhook(bot.id)} disabled={modeChanging[bot.id]}
|
||||
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)]'}">
|
||||
{ws.url ? t('telegramBot.webhookActive') : t('telegramBot.webhookNotSet')}
|
||||
{#if ws.pending_update_count > 0}
|
||||
({ws.pending_update_count} {t('telegramBot.pendingUpdates')})
|
||||
{/if}
|
||||
</span>
|
||||
{#if ws.last_error_message}
|
||||
<span class="text-xs text-red-500">{t('telegramBot.webhookError')}: {ws.last_error_message}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<button onclick={() => loadWebhookStatus(bot.id)}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline">
|
||||
{t('telegramBot.webhookStatus')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !settings.external_url && bot.update_mode === 'webhook'}
|
||||
<span class="text-xs text-amber-500 flex items-center gap-1">
|
||||
<MdiIcon name="mdiAlert" size={14} />
|
||||
{t('telegramBot.noExternalDomain')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -8,26 +8,47 @@
|
||||
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 Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import type { TemplateConfig } from '$lib/types';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let configs = $state<TemplateConfig[]>([]);
|
||||
let loaded = $state(false);
|
||||
let varsRef = $state<Record<string, any>>({});
|
||||
let showVarsFor = $state<string | null>(null);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<any>(null);
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
let slotPreview = $state<Record<string, string>>({});
|
||||
let slotErrors = $state<Record<string, string>>({});
|
||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||
let slotErrorTypes = $state<Record<string, string>>({});
|
||||
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
let dateFormatPreview = $state<Record<string, string | null>>({});
|
||||
|
||||
function refreshDateFormatPreview() {
|
||||
clearTimeout(validateTimers['_dateFmt']);
|
||||
validateTimers['_dateFmt'] = setTimeout(async () => {
|
||||
try {
|
||||
const res = await api('/template-configs/preview-date-format', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
date_format: (form as any).date_format,
|
||||
date_only_format: (form as any).date_only_format,
|
||||
}),
|
||||
});
|
||||
dateFormatPreview = res;
|
||||
} catch {
|
||||
dateFormatPreview = {};
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function validateSlot(slotKey: string, template: string, immediate = false) {
|
||||
if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]);
|
||||
@@ -71,6 +92,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshDateFormatPreview();
|
||||
}
|
||||
|
||||
const defaultForm = () => ({
|
||||
@@ -119,10 +141,10 @@
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; }
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); }
|
||||
function edit(c: any) {
|
||||
form = { ...defaultForm(), ...c }; editing = c.id; showForm = true;
|
||||
slotPreview = {}; slotErrors = {};
|
||||
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
@@ -154,8 +176,8 @@
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Restore allowed tags
|
||||
.replace(/<a href="([^"]*)">/g, '<a href="$1" target="_blank" rel="noopener">')
|
||||
// Restore allowed tags — only http(s) URLs for <a> to prevent javascript: XSS
|
||||
.replace(/<a href="(https?:\/\/[^&]*)">/g, '<a href="$1" target="_blank" rel="noopener noreferrer">')
|
||||
.replace(/<\/a>/g, '</a>')
|
||||
.replace(/<b>/g, '<b>').replace(/<\/b>/g, '</b>')
|
||||
.replace(/<i>/g, '<i>').replace(/<\/i>/g, '</i>')
|
||||
@@ -229,8 +251,13 @@
|
||||
</div>
|
||||
{#if slot.key === 'date_format' || slot.key === 'date_only_format'}
|
||||
<input bind:value={(form as any)[slot.key]}
|
||||
oninput={() => { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); }}
|
||||
oninput={() => { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
{#if dateFormatPreview[slot.key]}
|
||||
<p class="mt-1 text-xs font-mono" style="color: var(--color-muted-foreground);">{t('templateConfig.preview')}: <span style="color: var(--color-foreground);">{dateFormatPreview[slot.key]}</span></p>
|
||||
{:else if dateFormatPreview[slot.key] === null}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v: string) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
|
||||
{#if slotErrors[slot.key]}
|
||||
@@ -262,10 +289,7 @@
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiFileDocumentEdit" size={40} /></div>
|
||||
<p class="text-sm">{t('templateConfig.noConfigs')}</p>
|
||||
</div>
|
||||
<EmptyState icon="mdiFileDocumentEdit" message={t('templateConfig.noConfigs')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
|
||||
@@ -8,24 +8,27 @@
|
||||
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 Modal from '$lib/components/Modal.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import type { Tracker, ServiceProvider, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
|
||||
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
let trackers = $state<any[]>([]);
|
||||
let providers = $state<any[]>([]);
|
||||
let targets = $state<any[]>([]);
|
||||
let trackingConfigs = $state<any[]>([]);
|
||||
let templateConfigs = $state<any[]>([]);
|
||||
let trackers = $state<Tracker[]>([]);
|
||||
let providers = $state<ServiceProvider[]>([]);
|
||||
let targets = $state<NotificationTarget[]>([]);
|
||||
let trackingConfigs = $state<TrackingConfig[]>([]);
|
||||
let templateConfigs = $state<TemplateConfig[]>([]);
|
||||
let collections = $state<any[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let collectionFilter = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
let confirmDelete = $state<Tracker | null>(null);
|
||||
let toggling = $state<Record<number, boolean>>({});
|
||||
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
|
||||
let ttTesting = $state<Record<string, string>>({});
|
||||
@@ -153,9 +156,9 @@
|
||||
await doSave();
|
||||
}
|
||||
|
||||
function dismissLinkWarning() {
|
||||
async function dismissLinkWarning() {
|
||||
linkWarning = null;
|
||||
doSave();
|
||||
await doSave();
|
||||
}
|
||||
async function toggle(tracker: any) {
|
||||
if (toggling[tracker.id]) return;
|
||||
@@ -334,7 +337,7 @@
|
||||
</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}Checking links...{:else}{editing ? t('common.save') : t('trackers.createTracker')}{/if}
|
||||
{#if linkCheckLoading}{t('trackers.checkingLinks')}{:else}{editing ? t('common.save') : t('trackers.createTracker')}{/if}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
@@ -344,10 +347,7 @@
|
||||
{#if loaded && !loadError}
|
||||
{#if trackers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiRadar" size={40} /></div>
|
||||
<p class="text-sm">{t('trackers.noTrackers')}</p>
|
||||
</div>
|
||||
<EmptyState icon="mdiRadar" message={t('trackers.noTrackers')} />
|
||||
</Card>
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
@@ -478,47 +478,38 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if linkWarning}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998; background:rgba(0,0,0,0.5);"
|
||||
onclick={() => { linkWarning = null; }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') linkWarning = null; }}>
|
||||
</div>
|
||||
<div style="position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999; width:28rem; max-width:90vw; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1.5rem; box-shadow:0 20px 60px rgba(0,0,0,0.4);">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span style="color: var(--color-warning-fg);"><MdiIcon name="mdiAlertCircle" size={22} /></span>
|
||||
<h3 class="font-semibold">Albums Missing Public Links</h3>
|
||||
</div>
|
||||
<Modal open={linkWarning !== null} title={t('trackers.missingLinksTitle')} onclose={() => { linkWarning = null; }}>
|
||||
{#if linkWarning}
|
||||
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
|
||||
The following albums don't have valid public shared links. Without public links, notification messages won't include clickable URLs to albums or assets.
|
||||
{t('trackers.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' ? 'Expired' : album.issue === 'password-protected' ? 'Password Protected' : 'No Link'}
|
||||
{album.issue === 'expired' ? t('trackers.expired') : album.issue === 'password-protected' ? t('trackers.passwordProtected') : t('trackers.noLink')}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiInformation" size={14} /> Public links allow anyone with the URL to view album contents. Albums without links will still be tracked and assets sent to chats, but messages won't include clickable links.
|
||||
<MdiIcon name="mdiInformation" size={14} /> {t('trackers.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)]">
|
||||
Save without links
|
||||
{t('trackers.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 ? 'Creating...' : `Create ${linkWarning.albums.filter(a => a.issue === 'missing').length} link(s)`}
|
||||
{linkCreating ? t('common.loading') : t('trackers.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
|
||||
@@ -8,17 +8,19 @@
|
||||
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 Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import type { TrackingConfig } from '$lib/types';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let configs = $state<TrackingConfig[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<any>(null);
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
|
||||
const defaultForm = () => ({
|
||||
provider_type: 'immich', name: '', icon: '',
|
||||
@@ -31,7 +33,7 @@
|
||||
scheduled_enabled: false, scheduled_times: '09:00', scheduled_collection_mode: 'per_collection',
|
||||
scheduled_limit: 10, scheduled_favorite_only: false, scheduled_asset_type: 'all',
|
||||
scheduled_min_rating: 0, scheduled_order_by: 'random', scheduled_order: 'descending',
|
||||
memory_enabled: false, memory_times: '09:00', memory_collection_mode: 'combined',
|
||||
memory_enabled: false, memory_source: 'albums', memory_times: '09:00', memory_collection_mode: 'combined',
|
||||
memory_limit: 10, memory_favorite_only: false, memory_asset_type: 'all', memory_min_rating: 0,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
@@ -170,6 +172,10 @@
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.memory_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.memory_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.memorySource')}<Hint text={t('hints.memorySource')} /></label>
|
||||
<select bind:value={form.memory_source} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="albums">{t('trackingConfig.memorySourceAlbums')}</option><option value="native">{t('trackingConfig.memorySourceNative')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}<Hint text={t('hints.albumMode')} /></label>
|
||||
<select bind:value={form.memory_collection_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
@@ -196,10 +202,7 @@
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiCog" size={40} /></div>
|
||||
<p class="text-sm">{t('trackingConfig.noConfigs')}</p>
|
||||
</div>
|
||||
<EmptyState icon="mdiCog" message={t('trackingConfig.noConfigs')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
@@ -212,10 +215,10 @@
|
||||
<p class="font-medium">{config.name}</p>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{[config.track_assets_added && 'added', config.track_assets_removed && 'removed', config.track_collection_renamed && 'renamed', config.track_collection_deleted && 'deleted'].filter(Boolean).join(', ')}
|
||||
{config.periodic_enabled ? ' · periodic' : ''}
|
||||
{config.scheduled_enabled ? ' · scheduled' : ''}
|
||||
{config.memory_enabled ? ' · memory' : ''}
|
||||
{[config.track_assets_added && t('trackingConfig.added'), config.track_assets_removed && t('trackingConfig.removed'), config.track_collection_renamed && t('trackingConfig.renamed'), config.track_collection_deleted && t('trackingConfig.deleted')].filter(Boolean).join(', ')}
|
||||
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
|
||||
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
|
||||
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
|
||||
@@ -9,16 +9,18 @@
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import type { User } from '$lib/types';
|
||||
|
||||
const auth = getAuth();
|
||||
let users = $state<any[]>([]);
|
||||
let users = $state<User[]>([]);
|
||||
let showForm = $state(false);
|
||||
let form = $state({ username: '', password: '', role: 'user' });
|
||||
let error = $state('');
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
|
||||
// Admin reset password
|
||||
let resetUserId = $state<number | null>(null);
|
||||
@@ -99,10 +101,7 @@
|
||||
|
||||
{#if users.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiAccountGroup" size={40} /></div>
|
||||
<p class="text-sm">{t('common.loadError')}</p>
|
||||
</div>
|
||||
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
|
||||
@@ -12,17 +12,13 @@ from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.templates.context import build_template_context
|
||||
from notify_bridge_core.templates.renderer import render_template
|
||||
|
||||
from .telegram.cache import TelegramFileCache
|
||||
from .telegram.client import TelegramClient
|
||||
from .webhook.client import WebhookClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TEMPLATE = (
|
||||
'{{ added_count }} new item(s) added to '
|
||||
'{% if public_url %}<a href="{{ public_url }}">{{ collection_name }}</a>'
|
||||
'{% else %}"{{ collection_name }}"{% endif %}.'
|
||||
'{% if people %}\nPeople: {{ people | join(", ") }}{% endif %}'
|
||||
)
|
||||
DEFAULT_TEMPLATE = '{{ event_type }}: "{{ collection_name }}"'
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,6 +38,15 @@ class TargetConfig:
|
||||
class NotificationDispatcher:
|
||||
"""Dispatches ServiceEvent notifications to configured targets."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url_cache: TelegramFileCache | None = None,
|
||||
asset_cache: TelegramFileCache | None = None,
|
||||
) -> None:
|
||||
self._url_cache = url_cache
|
||||
self._asset_cache = asset_cache
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
event: ServiceEvent,
|
||||
@@ -104,13 +109,17 @@ class NotificationDispatcher:
|
||||
return {"success": False, "error": "Missing bot_token or chat_id"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = TelegramClient(session, bot_token)
|
||||
client = TelegramClient(
|
||||
session, bot_token,
|
||||
url_cache=self._url_cache,
|
||||
asset_cache=self._asset_cache,
|
||||
)
|
||||
|
||||
# Step 1: Send the text message first
|
||||
text_result = await client.send_message(
|
||||
chat_id=str(chat_id),
|
||||
text=message,
|
||||
disable_web_page_preview=disable_preview or None,
|
||||
disable_web_page_preview=bool(disable_preview),
|
||||
)
|
||||
if not text_result.get("success"):
|
||||
return text_result
|
||||
|
||||
@@ -16,8 +16,10 @@ from .media import (
|
||||
TELEGRAM_API_BASE_URL,
|
||||
TELEGRAM_MAX_PHOTO_SIZE,
|
||||
TELEGRAM_MAX_VIDEO_SIZE,
|
||||
asset_id_from_cache_key,
|
||||
check_photo_limits,
|
||||
extract_asset_id_from_url,
|
||||
is_asset_cache_key,
|
||||
is_asset_id,
|
||||
split_media_by_upload_size,
|
||||
)
|
||||
@@ -61,16 +63,17 @@ class TelegramClient:
|
||||
if is_asset_id(url):
|
||||
thumbhash = self._thumbhash_resolver(url) if self._thumbhash_resolver else None
|
||||
return self._asset_cache, url, thumbhash
|
||||
asset_id = extract_asset_id_from_url(url)
|
||||
if asset_id:
|
||||
thumbhash = self._thumbhash_resolver(asset_id) if self._thumbhash_resolver else None
|
||||
return self._asset_cache, asset_id, thumbhash
|
||||
asset_cache_key = extract_asset_id_from_url(url)
|
||||
if asset_cache_key:
|
||||
bare_id = asset_id_from_cache_key(asset_cache_key)
|
||||
thumbhash = self._thumbhash_resolver(bare_id) if self._thumbhash_resolver else None
|
||||
return self._asset_cache, asset_cache_key, thumbhash
|
||||
return self._url_cache, url, None
|
||||
return None, None, None
|
||||
|
||||
def _get_cache_for_key(self, key: str, is_asset: bool | None = None) -> TelegramFileCache | None:
|
||||
if is_asset is None:
|
||||
is_asset = is_asset_id(key)
|
||||
is_asset = is_asset_cache_key(key)
|
||||
return self._asset_cache if is_asset else self._url_cache
|
||||
|
||||
async def send_notification(
|
||||
@@ -163,8 +166,8 @@ class TelegramClient:
|
||||
}
|
||||
if reply_to_message_id:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
if disable_web_page_preview is not None:
|
||||
payload["disable_web_page_preview"] = disable_web_page_preview
|
||||
if disable_web_page_preview:
|
||||
payload["link_preview_options"] = {"is_disabled": True}
|
||||
|
||||
try:
|
||||
async with self._session.post(telegram_url, json=payload) as response:
|
||||
@@ -429,9 +432,10 @@ class TelegramClient:
|
||||
|
||||
# Check cache
|
||||
ck = custom_cache_key or extract_asset_id_from_url(url) or url
|
||||
ck_is_asset = is_asset_id(ck)
|
||||
ck_is_asset = is_asset_cache_key(ck)
|
||||
item_cache = self._get_cache_for_key(ck, ck_is_asset)
|
||||
item_thumbhash = self._thumbhash_resolver(ck) if ck_is_asset and self._thumbhash_resolver else None
|
||||
bare_ck = asset_id_from_cache_key(ck) if ck_is_asset else ck
|
||||
item_thumbhash = self._thumbhash_resolver(bare_ck) if ck_is_asset and self._thumbhash_resolver else None
|
||||
cached = item_cache.get(ck, thumbhash=item_thumbhash) if item_cache else None
|
||||
|
||||
if cached and cached.get("file_id"):
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Final
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Telegram constants
|
||||
TELEGRAM_API_BASE_URL: Final = "https://api.telegram.org/bot"
|
||||
@@ -13,6 +14,8 @@ TELEGRAM_MAX_DIMENSION_SUM: Final = 10000
|
||||
|
||||
# Generic UUID pattern for asset IDs
|
||||
_ASSET_ID_PATTERN = re.compile(r"^[a-f0-9-]{36}$")
|
||||
# Cache key: "host:uuid" or bare "uuid"
|
||||
_ASSET_CACHE_KEY_PATTERN = re.compile(r"^(?:[^:]+:)?[a-f0-9-]{36}$")
|
||||
|
||||
# URL patterns to extract asset IDs (generic enough for Immich-style URLs)
|
||||
_ASSET_ID_URL_PATTERNS = [
|
||||
@@ -26,14 +29,26 @@ def is_asset_id(value: str) -> bool:
|
||||
return bool(_ASSET_ID_PATTERN.match(value))
|
||||
|
||||
|
||||
def is_asset_cache_key(value: str) -> bool:
|
||||
"""Check if a string is an asset cache key (bare UUID or host:UUID)."""
|
||||
return bool(_ASSET_CACHE_KEY_PATTERN.match(value))
|
||||
|
||||
|
||||
def asset_id_from_cache_key(key: str) -> str:
|
||||
"""Extract bare asset ID from a cache key (strips host: prefix if present)."""
|
||||
idx = key.find(":")
|
||||
return key[idx + 1:] if idx != -1 else key
|
||||
|
||||
|
||||
def extract_asset_id_from_url(url: str) -> str | None:
|
||||
"""Extract asset ID from a URL if possible."""
|
||||
"""Extract host-qualified asset cache key (host:uuid) from a URL."""
|
||||
if not url:
|
||||
return None
|
||||
for pattern in _ASSET_ID_URL_PATTERNS:
|
||||
match = pattern.search(url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
host = urlparse(url).hostname or ""
|
||||
return f"{host}:{match.group(1)}" if host else match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -47,14 +47,27 @@ def _asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
|
||||
)
|
||||
|
||||
|
||||
def _make_base_extra(new_album: ImmichAlbumData, external_url: str) -> dict:
|
||||
"""Build the common extra dict for album events."""
|
||||
return {
|
||||
"album_url": f"{external_url}/albums/{new_album.id}",
|
||||
"people": list(new_album.people),
|
||||
"shared": new_album.shared,
|
||||
"photo_count": new_album.photo_count,
|
||||
"video_count": new_album.video_count,
|
||||
"asset_count": new_album.asset_count,
|
||||
"owner": new_album.owner,
|
||||
}
|
||||
|
||||
|
||||
def detect_album_changes(
|
||||
old_album: ImmichAlbumData,
|
||||
new_album: ImmichAlbumData,
|
||||
pending_asset_ids: set[str],
|
||||
provider_name: str,
|
||||
external_url: str,
|
||||
) -> tuple[ServiceEvent | None, set[str]]:
|
||||
"""Detect changes between two album states, producing a generic ServiceEvent.
|
||||
) -> tuple[list[ServiceEvent], set[str]]:
|
||||
"""Detect changes between two album states, producing generic ServiceEvents.
|
||||
|
||||
Args:
|
||||
old_album: Previous album data
|
||||
@@ -64,7 +77,7 @@ def detect_album_changes(
|
||||
external_url: External URL for building asset URLs
|
||||
|
||||
Returns:
|
||||
Tuple of (ServiceEvent or None, updated pending_asset_ids)
|
||||
Tuple of (list of ServiceEvents, updated pending_asset_ids)
|
||||
"""
|
||||
added_ids = new_album.asset_ids - old_album.asset_ids
|
||||
removed_ids = old_album.asset_ids - new_album.asset_ids
|
||||
@@ -97,47 +110,76 @@ def detect_album_changes(
|
||||
sharing_changed = old_album.shared != new_album.shared
|
||||
|
||||
if not added_assets and not removed_ids and not name_changed and not sharing_changed:
|
||||
return None, pending
|
||||
return [], pending
|
||||
|
||||
# Determine event type
|
||||
if name_changed and not added_assets and not removed_ids and not sharing_changed:
|
||||
event_type = EventType.COLLECTION_RENAMED
|
||||
elif sharing_changed and not added_assets and not removed_ids and not name_changed:
|
||||
event_type = EventType.SHARING_CHANGED
|
||||
elif added_assets and not removed_ids and not name_changed and not sharing_changed:
|
||||
event_type = EventType.ASSETS_ADDED
|
||||
elif removed_ids and not added_assets and not name_changed and not sharing_changed:
|
||||
event_type = EventType.ASSETS_REMOVED
|
||||
else:
|
||||
event_type = EventType.ASSETS_ADDED # default for mixed changes
|
||||
now = datetime.now(timezone.utc)
|
||||
extra = _make_base_extra(new_album, external_url)
|
||||
events: list[ServiceEvent] = []
|
||||
|
||||
# Convert to generic MediaAssets
|
||||
media_assets = [_asset_to_media(a, external_url) for a in added_assets]
|
||||
# Emit one event per change type detected
|
||||
if added_assets:
|
||||
media_assets = [_asset_to_media(a, external_url) for a in added_assets]
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.ASSETS_ADDED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=provider_name,
|
||||
collection_id=new_album.id,
|
||||
collection_name=new_album.name,
|
||||
timestamp=now,
|
||||
added_assets=media_assets,
|
||||
removed_asset_ids=[],
|
||||
added_count=len(added_assets),
|
||||
removed_count=0,
|
||||
extra=dict(extra),
|
||||
))
|
||||
|
||||
event = ServiceEvent(
|
||||
event_type=event_type,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=provider_name,
|
||||
collection_id=new_album.id,
|
||||
collection_name=new_album.name,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
added_assets=media_assets,
|
||||
removed_asset_ids=list(removed_ids),
|
||||
added_count=len(added_assets),
|
||||
removed_count=len(removed_ids),
|
||||
old_name=old_album.name if name_changed else None,
|
||||
new_name=new_album.name if name_changed else None,
|
||||
old_shared=old_album.shared if sharing_changed else None,
|
||||
new_shared=new_album.shared if sharing_changed else None,
|
||||
extra={
|
||||
"album_url": f"{external_url}/albums/{new_album.id}",
|
||||
"people": list(new_album.people),
|
||||
"shared": new_album.shared,
|
||||
"photo_count": new_album.photo_count,
|
||||
"video_count": new_album.video_count,
|
||||
"asset_count": new_album.asset_count,
|
||||
"owner": new_album.owner,
|
||||
},
|
||||
)
|
||||
if removed_ids:
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.ASSETS_REMOVED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=provider_name,
|
||||
collection_id=new_album.id,
|
||||
collection_name=new_album.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=list(removed_ids),
|
||||
added_count=0,
|
||||
removed_count=len(removed_ids),
|
||||
extra=dict(extra),
|
||||
))
|
||||
|
||||
return event, pending
|
||||
if name_changed:
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.COLLECTION_RENAMED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=provider_name,
|
||||
collection_id=new_album.id,
|
||||
collection_name=new_album.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=[],
|
||||
added_count=0,
|
||||
removed_count=0,
|
||||
old_name=old_album.name,
|
||||
new_name=new_album.name,
|
||||
extra=dict(extra),
|
||||
))
|
||||
|
||||
if sharing_changed:
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.SHARING_CHANGED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=provider_name,
|
||||
collection_id=new_album.id,
|
||||
collection_name=new_album.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=[],
|
||||
added_count=0,
|
||||
removed_count=0,
|
||||
old_shared=old_album.shared,
|
||||
new_shared=new_album.shared,
|
||||
extra=dict(extra),
|
||||
))
|
||||
|
||||
return events, pending
|
||||
|
||||
@@ -30,6 +30,14 @@ class ImmichClient:
|
||||
def url(self) -> str:
|
||||
return self._url
|
||||
|
||||
@property
|
||||
def external_domain(self) -> str | None:
|
||||
return self._external_domain
|
||||
|
||||
@external_domain.setter
|
||||
def external_domain(self, value: str | None) -> None:
|
||||
self._external_domain = value
|
||||
|
||||
@property
|
||||
def external_url(self) -> str:
|
||||
if self._external_domain:
|
||||
@@ -252,6 +260,79 @@ class ImmichClient:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def search_metadata(
|
||||
self,
|
||||
query: str,
|
||||
album_ids: list[str] | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
payload: dict[str, Any] = {"originalFileName": query, "page": 1, "size": limit}
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/metadata",
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
items = data.get("assets", {}).get("items", [])
|
||||
if album_ids:
|
||||
tracked = set(album_ids)
|
||||
items = [
|
||||
a for a in items
|
||||
if any(alb.get("id") in tracked for alb in a.get("albums", []))
|
||||
]
|
||||
return items[:limit]
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def search_by_person(
|
||||
self, person_id: str, limit: int = 10
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/people/{person_id}/assets",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data[:limit] if isinstance(data, list) else []
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def get_memories(
|
||||
self,
|
||||
date: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch native Immich memories (On This Day).
|
||||
|
||||
Args:
|
||||
date: ISO date string (e.g. "2026-03-20") to fetch memories for.
|
||||
If None, Immich returns memories for today.
|
||||
|
||||
Returns a list of memory objects, each containing an ``assets`` list
|
||||
with full ``AssetResponseDto`` items.
|
||||
"""
|
||||
params: dict[str, str] = {}
|
||||
if date:
|
||||
params["for"] = date
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/memories",
|
||||
headers=self._headers,
|
||||
params=params,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch memories: HTTP %s", response.status
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch memories: %s", err)
|
||||
return []
|
||||
|
||||
async def get_asset_thumbnail(self, asset_id: str, size: str = "preview") -> bytes | None:
|
||||
try:
|
||||
async with self._session.get(
|
||||
|
||||
@@ -134,7 +134,7 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
if ok:
|
||||
await self._client.get_server_config()
|
||||
if self._external_domain:
|
||||
self._client._external_domain = self._external_domain
|
||||
self._client.external_domain = self._external_domain
|
||||
self._users_cache = await self._client.get_users()
|
||||
return ok
|
||||
|
||||
@@ -179,12 +179,12 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
old_album = _deserialize_album_state(album_id, prev)
|
||||
pending = set(prev.get("pending_asset_ids", []))
|
||||
|
||||
event, updated_pending = detect_album_changes(
|
||||
detected_events, updated_pending = detect_album_changes(
|
||||
old_album, album, pending, self._name, external_url
|
||||
)
|
||||
|
||||
if event:
|
||||
# Fetch shared links to enrich event with public_url
|
||||
if detected_events:
|
||||
# Fetch shared links to enrich events with public_url
|
||||
shared_links = await self._client.get_shared_links(album_id)
|
||||
public_link = None
|
||||
protected_link = None
|
||||
@@ -197,13 +197,13 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
break # prefer non-password link
|
||||
|
||||
ext_domain = self._external_domain or self._client.external_url
|
||||
if public_link:
|
||||
event.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
|
||||
elif protected_link:
|
||||
event.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
|
||||
# If no links, public_url stays absent — templates handle gracefully
|
||||
for evt in detected_events:
|
||||
if public_link:
|
||||
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
|
||||
elif protected_link:
|
||||
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
|
||||
|
||||
events.append(event)
|
||||
events.extend(detected_events)
|
||||
|
||||
# Update state
|
||||
state = _serialize_album_state(album)
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
{%- if people %}
|
||||
👤 {{ people | join(", ") }}
|
||||
{%- endif %}
|
||||
{%- if public_url %}
|
||||
🔗 <a href="{{ public_url }}">Album URL</a>
|
||||
{%- endif %}
|
||||
{%- if added_assets %}
|
||||
{%- for asset in added_assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
📅 On this day:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})
|
||||
{%- endfor %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
|
||||
{%- endfor %}
|
||||
@@ -1,5 +1,4 @@
|
||||
📋 Tracked Albums Summary ({{ albums | length }} albums):
|
||||
{%- for album in albums %}
|
||||
• {{ album.name }}: {{ album.asset_count }} assets
|
||||
{%- if album.url %} — {{ album.url }}{% endif %}
|
||||
{%- endfor %}
|
||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
|
||||
{%- endfor %}
|
||||
@@ -1,4 +1,4 @@
|
||||
📸 Photos from "{{ album_name }}":
|
||||
📸 Photos from {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
||||
{%- endfor %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -4,6 +4,9 @@
|
||||
{%- if people %}
|
||||
👤 {{ people | join(", ") }}
|
||||
{%- endif %}
|
||||
{%- if public_url %}
|
||||
🔗 <a href="{{ public_url }}">Ссылка на альбом</a>
|
||||
{%- endif %}
|
||||
{%- if added_assets %}
|
||||
{%- for asset in added_assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
📅 В этот день:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }} ({{ asset.created_at[:4] }})
|
||||
{%- endfor %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
|
||||
{%- endfor %}
|
||||
@@ -1,5 +1,4 @@
|
||||
📋 Сводка альбомов ({{ albums | length }}):
|
||||
{%- for album in albums %}
|
||||
• {{ album.name }}: {{ album.asset_count }} файлов
|
||||
{%- if album.url %} — {{ album.url }}{% endif %}
|
||||
{%- endfor %}
|
||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
|
||||
{%- endfor %}
|
||||
@@ -1,4 +1,4 @@
|
||||
📸 Фото из "{{ album_name }}":
|
||||
📸 Фото из {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
||||
{%- for asset in assets %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
|
||||
{%- endfor %}
|
||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||
{%- endfor %}
|
||||
@@ -8,7 +8,7 @@ version = "0.1.0"
|
||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"notify-bridge-core==0.1.0",
|
||||
"notify-bridge-core>=0.1.0",
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"sqlmodel>=0.0.22",
|
||||
@@ -18,7 +18,6 @@ dependencies = [
|
||||
"apscheduler>=3.10,<4",
|
||||
"aiohttp>=3.9",
|
||||
"pydantic-settings>=2.0",
|
||||
"anthropic>=0.42",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""App-level settings API (admin only)."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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 AppSetting, TelegramBot, User
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
# Keys exposed to the frontend with their env-var fallbacks
|
||||
_SETTING_KEYS = {
|
||||
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
|
||||
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
||||
"telegram_cache_ttl_hours": None, # no env fallback, default 48
|
||||
}
|
||||
|
||||
_DEFAULTS = {
|
||||
"external_url": "",
|
||||
"telegram_webhook_secret": "",
|
||||
"telegram_cache_ttl_hours": "48",
|
||||
}
|
||||
|
||||
|
||||
async def get_setting(session: AsyncSession, key: str) -> str:
|
||||
"""Read a setting from DB, falling back to env var then default."""
|
||||
row = await session.get(AppSetting, key)
|
||||
if row and row.value:
|
||||
return row.value
|
||||
env_key = _SETTING_KEYS.get(key)
|
||||
if env_key:
|
||||
env_val = os.environ.get(env_key, "")
|
||||
if env_val:
|
||||
return env_val
|
||||
return _DEFAULTS.get(key, "")
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
external_url: str | None = None
|
||||
telegram_webhook_secret: str | None = None
|
||||
telegram_cache_ttl_hours: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_settings(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Return all app settings."""
|
||||
result = {}
|
||||
for key in _SETTING_KEYS:
|
||||
result[key] = await get_setting(session, key)
|
||||
return result
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def update_settings(
|
||||
body: SettingsUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
|
||||
old_base_url = await get_setting(session, "external_url")
|
||||
old_secret = await get_setting(session, "telegram_webhook_secret")
|
||||
|
||||
for key in _SETTING_KEYS:
|
||||
value = getattr(body, key, None)
|
||||
if value is None:
|
||||
continue
|
||||
row = await session.get(AppSetting, key)
|
||||
if row:
|
||||
row.value = value
|
||||
else:
|
||||
row = AppSetting(key=key, value=value)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
|
||||
new_base_url = await get_setting(session, "external_url")
|
||||
new_secret = await get_setting(session, "telegram_webhook_secret")
|
||||
|
||||
# Update webhook secret in the webhook handler module
|
||||
if new_secret != old_secret:
|
||||
from ..commands.webhook import set_webhook_secret
|
||||
set_webhook_secret(new_secret or None)
|
||||
|
||||
# Re-register webhooks for all bots in webhook mode if URL or secret changed
|
||||
if new_base_url and (new_base_url != old_base_url or new_secret != old_secret):
|
||||
await _reregister_webhooks(session, new_base_url, new_secret)
|
||||
|
||||
result = {}
|
||||
for key in _SETTING_KEYS:
|
||||
result[key] = await get_setting(session, key)
|
||||
return result
|
||||
|
||||
|
||||
async def _reregister_webhooks(
|
||||
session: AsyncSession, base_url: str, secret: str
|
||||
) -> None:
|
||||
"""Re-register webhooks for all bots in webhook mode."""
|
||||
from ..commands.webhook import register_webhook
|
||||
|
||||
result = await session.exec(
|
||||
select(TelegramBot).where(TelegramBot.update_mode == "webhook")
|
||||
)
|
||||
bots = result.all()
|
||||
for bot in bots:
|
||||
webhook_url = f"{base_url.rstrip('/')}/api/telegram/webhook/{bot.webhook_path_id}"
|
||||
res = await register_webhook(bot.token, webhook_url, secret or None)
|
||||
if res.get("success"):
|
||||
_LOGGER.info("Re-registered webhook for bot %d (%s)", bot.id, bot.name)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Failed to re-register webhook for bot %d: %s",
|
||||
bot.id, res.get("error"),
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Service provider management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -11,6 +13,9 @@ import aiohttp
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import ServiceProvider, User
|
||||
from ..services import make_immich_provider
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/providers", tags=["providers"])
|
||||
|
||||
@@ -63,11 +68,8 @@ async def create_provider(
|
||||
config = body.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
body.name,
|
||||
http_session, config.get("url", ""), config.get("api_key", ""),
|
||||
config.get("external_domain"), body.name,
|
||||
)
|
||||
test_result = await immich.test_connection()
|
||||
if not test_result.get("ok"):
|
||||
@@ -124,16 +126,8 @@ async def update_provider(
|
||||
# Re-validate connection when config changes for known provider types
|
||||
if config_changed and provider.type == "immich":
|
||||
try:
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
test_result = await immich.test_connection()
|
||||
if not test_result.get("ok"):
|
||||
raise HTTPException(
|
||||
@@ -176,16 +170,8 @@ async def test_provider(
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
return await immich.test_connection()
|
||||
|
||||
return {"ok": False, "message": f"Unknown provider type: {provider.type}"}
|
||||
@@ -201,16 +187,8 @@ async def list_collections(
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
return await immich.list_collections()
|
||||
|
||||
return []
|
||||
@@ -227,16 +205,8 @@ async def get_album_shared_links(
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
links = await immich.client.get_shared_links(album_id)
|
||||
return [
|
||||
{
|
||||
@@ -263,16 +233,8 @@ async def create_album_shared_link(
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
if provider.type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
config = provider.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
success = await immich.client.create_shared_link(album_id)
|
||||
if success:
|
||||
return {"success": True}
|
||||
|
||||
@@ -108,7 +108,7 @@ async def get_event_chart(
|
||||
select(
|
||||
day_col.label("day"),
|
||||
EventLog.event_type,
|
||||
func.count().label("count"),
|
||||
func.count().label("total"),
|
||||
)
|
||||
.join(Tracker, EventLog.tracker_id == Tracker.id)
|
||||
.where(Tracker.user_id == user.id, EventLog.created_at >= cutoff)
|
||||
@@ -118,13 +118,13 @@ async def get_event_chart(
|
||||
|
||||
rows = (await session.exec(query)).all()
|
||||
|
||||
# Build a dict: { "2026-03-15": { "assets_added": 3, ... }, ... }
|
||||
# Build a dict: { "2026-03-15": { "assets_added": 18, ... }, ... }
|
||||
by_day: dict[str, dict[str, int]] = {}
|
||||
for row in rows:
|
||||
day_str = str(row.day)
|
||||
if day_str not in by_day:
|
||||
by_day[day_str] = {}
|
||||
by_day[day_str][row.event_type] = row.count
|
||||
by_day[day_str][row.event_type] = row.total
|
||||
|
||||
# Fill in missing days so the frontend gets a continuous series
|
||||
result = []
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Notification target management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -9,6 +11,9 @@ 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 ..services.notifier import send_test_notification
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/targets", tags=["targets"])
|
||||
|
||||
@@ -137,7 +142,6 @@ async def test_target(
|
||||
):
|
||||
"""Send a test notification to a target."""
|
||||
target = await _get_user_target(session, target_id, user.id)
|
||||
from ..services.notifier import send_test_notification
|
||||
result = await send_test_notification(target, locale=locale)
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Telegram bot management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -10,8 +12,15 @@ import aiohttp
|
||||
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..commands.handler import register_commands_with_telegram
|
||||
from ..commands.webhook import register_webhook, unregister_webhook
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TelegramBot, TelegramChat, User
|
||||
from ..database.models import AppSetting, TelegramBot, TelegramChat, User
|
||||
from ..services.notifier import _get_test_message
|
||||
from ..services.telegram_poller import schedule_bot_polling, unschedule_bot_polling
|
||||
from .app_settings import get_setting
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/telegram-bots", tags=["telegram-bots"])
|
||||
|
||||
@@ -24,6 +33,8 @@ class BotCreate(BaseModel):
|
||||
class BotUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
update_mode: str | None = None
|
||||
commands_config: dict | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -69,12 +80,41 @@ async def update_bot(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a bot's display name and icon."""
|
||||
"""Update a bot's display name, icon, commands config, and update mode."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
if body.name is not None:
|
||||
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":
|
||||
# Validate and register webhook BEFORE stopping polling
|
||||
base_url = await get_setting(session, "external_url")
|
||||
if not base_url:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot switch to webhook: external domain URL not configured. Set it in Settings.",
|
||||
)
|
||||
webhook_url = f"{base_url.rstrip('/')}/api/telegram/webhook/{bot.webhook_path_id}"
|
||||
secret = await get_setting(session, "telegram_webhook_secret")
|
||||
result = await register_webhook(bot.token, webhook_url, secret or None)
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Webhook registration failed: {result.get('error', 'unknown')}",
|
||||
)
|
||||
# Webhook registered successfully — now stop polling
|
||||
unschedule_bot_polling(bot.id)
|
||||
elif body.update_mode == "polling":
|
||||
# Switching to polling: unregister webhook, start polling
|
||||
await unregister_webhook(bot.token)
|
||||
schedule_bot_polling(bot.id)
|
||||
bot.update_mode = body.update_mode
|
||||
|
||||
session.add(bot)
|
||||
await session.commit()
|
||||
await session.refresh(bot)
|
||||
@@ -173,6 +213,75 @@ async def discover_chats(
|
||||
return [_chat_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.post("/{bot_id}/sync-commands")
|
||||
async def sync_commands(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Register enabled commands with Telegram BotFather API."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
success = await register_commands_with_telegram(bot)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@router.post("/{bot_id}/webhook/register")
|
||||
async def register_bot_webhook(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Register Telegram webhook for this bot."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
base_url = await get_setting(session, "external_url")
|
||||
if not base_url:
|
||||
return {"success": False, "error": "External domain URL not configured. Set it in Telegram Settings."}
|
||||
webhook_url = f"{base_url.rstrip('/')}/api/telegram/webhook/{bot.webhook_path_id}"
|
||||
secret = await get_setting(session, "telegram_webhook_secret")
|
||||
result = await register_webhook(bot.token, webhook_url, secret or None)
|
||||
if not result.get("success"):
|
||||
return result
|
||||
# Verify with getWebhookInfo
|
||||
info = await _get_webhook_info(bot.token)
|
||||
if info and info.get("url") == webhook_url:
|
||||
result["verified"] = True
|
||||
result["webhook_url"] = webhook_url
|
||||
else:
|
||||
result["verified"] = False
|
||||
result["warning"] = "Webhook set but verification failed"
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{bot_id}/webhook/unregister")
|
||||
async def unregister_bot_webhook(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Unregister Telegram webhook for this bot."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
result = await unregister_webhook(bot.token)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{bot_id}/webhook/status")
|
||||
async def get_webhook_status(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get current webhook status from Telegram."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
info = await _get_webhook_info(bot.token)
|
||||
return {
|
||||
"url": info.get("url", "") if info else "",
|
||||
"has_custom_certificate": info.get("has_custom_certificate", False) if info else False,
|
||||
"pending_update_count": info.get("pending_update_count", 0) if info else 0,
|
||||
"last_error_message": info.get("last_error_message", "") if info else "",
|
||||
"last_error_date": info.get("last_error_date", 0) if info else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{bot_id}/chats/{chat_id}/test")
|
||||
async def test_chat(
|
||||
bot_id: int,
|
||||
@@ -182,8 +291,6 @@ async def test_chat(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test message to a chat via the bot."""
|
||||
from ..services.notifier import _get_test_message
|
||||
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
message = _get_test_message(locale, "telegram")
|
||||
try:
|
||||
@@ -222,6 +329,19 @@ async def delete_chat(
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
async def _get_webhook_info(token: str) -> dict | None:
|
||||
"""Call Telegram getWebhookInfo to check current webhook state."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get(f"{TELEGRAM_API_BASE_URL}{token}/getWebhookInfo") as resp:
|
||||
data = await resp.json()
|
||||
if data.get("ok"):
|
||||
return data.get("result", {})
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _get_me(token: str) -> dict | None:
|
||||
"""Call Telegram getMe to validate token and get bot info."""
|
||||
try:
|
||||
@@ -281,6 +401,9 @@ def _bot_response(b: TelegramBot) -> dict:
|
||||
"icon": b.icon,
|
||||
"bot_username": b.bot_username,
|
||||
"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(),
|
||||
}
|
||||
@@ -293,38 +416,6 @@ async def _get_user_bot(session: AsyncSession, bot_id: int, user_id: int) -> Tel
|
||||
return bot
|
||||
|
||||
|
||||
async def save_chat_from_webhook(
|
||||
session: AsyncSession, bot_id: int, chat_data: dict
|
||||
) -> None:
|
||||
"""Save or update a chat entry from an incoming webhook message.
|
||||
|
||||
Called by the webhook handler to auto-persist chats.
|
||||
"""
|
||||
chat_id = str(chat_data.get("id", ""))
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
result = await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)
|
||||
existing = result.first()
|
||||
|
||||
title = chat_data.get("title") or (
|
||||
chat_data.get("first_name", "") + (" " + chat_data.get("last_name", "")).strip()
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.title = title
|
||||
existing.username = chat_data.get("username", existing.username)
|
||||
session.add(existing)
|
||||
else:
|
||||
session.add(TelegramChat(
|
||||
bot_id=bot_id,
|
||||
chat_id=chat_id,
|
||||
title=title,
|
||||
chat_type=chat_data.get("type", "private"),
|
||||
username=chat_data.get("username", ""),
|
||||
))
|
||||
# Re-export for backward compatibility
|
||||
from ..services.telegram import save_chat_from_webhook # noqa: F401
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Template configuration CRUD API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -11,103 +13,12 @@ from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TemplateConfig, User
|
||||
from ..services.sample_context import _SAMPLE_CONTEXT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/template-configs", tags=["template-configs"])
|
||||
|
||||
# Sample asset matching what build_asset_detail() actually returns
|
||||
_SAMPLE_ASSET = {
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"filename": "IMG_001.jpg",
|
||||
"type": "IMAGE",
|
||||
"created_at": "2026-03-19T10:30:00",
|
||||
"owner": "Alice",
|
||||
"owner_id": "user-uuid-1",
|
||||
"description": "Family picnic",
|
||||
"people": ["Alice", "Bob"],
|
||||
"is_favorite": True,
|
||||
"rating": 5,
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"city": "Paris",
|
||||
"state": "Ile-de-France",
|
||||
"country": "France",
|
||||
"url": "https://immich.example.com/photos/abc123",
|
||||
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"download_url": "https://immich.example.com/api/assets/abc123/original",
|
||||
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
|
||||
}
|
||||
|
||||
_SAMPLE_VIDEO_ASSET = {
|
||||
**_SAMPLE_ASSET,
|
||||
"id": "d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"filename": "VID_002.mp4",
|
||||
"type": "VIDEO",
|
||||
"is_favorite": False,
|
||||
"rating": None,
|
||||
"photo_url": None,
|
||||
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"playback_url": "https://immich.example.com/api/assets/def456/video",
|
||||
}
|
||||
|
||||
_SAMPLE_COLLECTION = {
|
||||
"name": "Family Photos",
|
||||
"url": "https://immich.example.com/share/abc123",
|
||||
"public_url": "https://immich.example.com/share/abc123",
|
||||
"asset_count": 42,
|
||||
"shared": True,
|
||||
}
|
||||
|
||||
# Full context covering ALL possible template variables
|
||||
_SAMPLE_CONTEXT = {
|
||||
# Core event fields (always present)
|
||||
"collection_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
|
||||
"collection_name": "Family Photos",
|
||||
"collection_url": "https://immich.example.com/share/abc123",
|
||||
"event_type": "assets_added",
|
||||
"timestamp": "2026-03-19T10:30:00+00:00",
|
||||
"service_name": "Immich",
|
||||
"service_type": "immich",
|
||||
# Immich aliases (always present alongside collection_*)
|
||||
"album_name": "Family Photos",
|
||||
"album_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
|
||||
"album_url": "https://immich.example.com/share/abc123",
|
||||
"old_album_name": "Old Album",
|
||||
"new_album_name": "New Album",
|
||||
"change_type": "assets_added",
|
||||
"added_count": 3,
|
||||
"removed_count": 1,
|
||||
"added_assets": [_SAMPLE_ASSET, _SAMPLE_VIDEO_ASSET],
|
||||
"removed_assets": ["asset-id-1", "asset-id-2"],
|
||||
"people": ["Alice", "Bob"],
|
||||
"shared": True,
|
||||
"target_type": "telegram",
|
||||
"has_videos": True,
|
||||
"has_photos": True,
|
||||
# Rename fields (always present, empty for non-rename events)
|
||||
"old_name": "Old Album",
|
||||
"new_name": "New Album",
|
||||
"old_shared": False,
|
||||
"new_shared": True,
|
||||
# Public share URLs (may be empty if no shared link exists)
|
||||
"public_url": "https://immich.example.com/share/abc123",
|
||||
"protected_url": "",
|
||||
"album_url": "https://immich.example.com/albums/b2eeeaa4",
|
||||
# Common date/location (set when all assets share the same value)
|
||||
"common_date": "19.03.2026",
|
||||
"common_location": "Paris, France",
|
||||
# Date format strings (from template config)
|
||||
"date_format": "%d.%m.%Y, %H:%M UTC",
|
||||
"date_only_format": "%d.%m.%Y",
|
||||
# Scheduled/periodic variables (for those templates)
|
||||
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"albums": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/abc123/photos/x1y2z3"}],
|
||||
"date": "2026-03-19",
|
||||
"photo_count": 30,
|
||||
"video_count": 5,
|
||||
"owner": "Alice",
|
||||
}
|
||||
|
||||
|
||||
class TemplateConfigCreate(BaseModel):
|
||||
provider_type: str
|
||||
@@ -335,6 +246,32 @@ async def preview_config(
|
||||
raise HTTPException(status_code=400, detail=f"Template error: {e}")
|
||||
|
||||
|
||||
class DateFormatPreviewRequest(BaseModel):
|
||||
date_format: str = "%d.%m.%Y, %H:%M UTC"
|
||||
date_only_format: str = "%d.%m.%Y"
|
||||
|
||||
|
||||
@router.post("/preview-date-format")
|
||||
async def preview_date_format(
|
||||
body: DateFormatPreviewRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Preview what date/datetime formats look like with sample data."""
|
||||
from datetime import datetime, timezone
|
||||
sample_dt = datetime(2026, 3, 19, 14, 30, 0, tzinfo=timezone.utc)
|
||||
sample_date = datetime(2026, 3, 19)
|
||||
result: dict[str, str | None] = {}
|
||||
for key, fmt, sample in [
|
||||
("date_format", body.date_format, sample_dt),
|
||||
("date_only_format", body.date_only_format, sample_date),
|
||||
]:
|
||||
try:
|
||||
result[key] = sample.strftime(fmt)
|
||||
except (ValueError, TypeError):
|
||||
result[key] = None
|
||||
return result
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
template: str
|
||||
target_type: str = "telegram" # "telegram" or "webhook"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tracker-Target link management API routes."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -18,6 +19,9 @@ from ..database.models import (
|
||||
TrackingConfig,
|
||||
User,
|
||||
)
|
||||
from ..services.notifier import send_real_data_notification, send_test_notification
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/trackers/{tracker_id}/targets", tags=["tracker-targets"])
|
||||
|
||||
@@ -176,7 +180,6 @@ async def test_tracker_target(
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
|
||||
if test_type == "basic":
|
||||
from ..services.notifier import send_test_notification
|
||||
r = await send_test_notification(target, locale=locale)
|
||||
return {"target": target.name, **r}
|
||||
|
||||
@@ -199,8 +202,14 @@ async def test_tracker_target(
|
||||
provider_config = dict(provider.config)
|
||||
collection_ids = list(tracker.collection_ids or [])
|
||||
|
||||
# Load tracking config to get memory_source
|
||||
memory_source = "albums"
|
||||
if tt.tracking_config_id:
|
||||
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
if tracking_config:
|
||||
memory_source = tracking_config.memory_source or "albums"
|
||||
|
||||
# Fetch real data from provider
|
||||
from ..services.notifier import send_real_data_notification
|
||||
r = await send_real_data_notification(
|
||||
target=target,
|
||||
template_str=template_str,
|
||||
@@ -209,7 +218,8 @@ async def test_tracker_target(
|
||||
provider_config=provider_config,
|
||||
collection_ids=collection_ids,
|
||||
date_format=template_config.date_format if template_config else "%d.%m.%Y, %H:%M UTC",
|
||||
date_only_format=template_config.date_only_format if template_config and hasattr(template_config, "date_only_format") else "%d.%m.%Y",
|
||||
date_only_format=template_config.date_only_format if template_config and template_config.date_only_format else "%d.%m.%Y",
|
||||
memory_source=memory_source,
|
||||
)
|
||||
return {"target": target.name, **r}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tracker management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -9,13 +11,17 @@ from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import (
|
||||
EventLog,
|
||||
NotificationTarget,
|
||||
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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
|
||||
|
||||
@@ -66,7 +72,6 @@ async def create_tracker(
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
if tracker.enabled:
|
||||
from ..services.scheduler import schedule_tracker
|
||||
await schedule_tracker(tracker.id, tracker.scan_interval)
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
@@ -94,7 +99,6 @@ async def update_tracker(
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
from ..services.scheduler import schedule_tracker, unschedule_tracker
|
||||
if tracker.enabled:
|
||||
await schedule_tracker(tracker.id, tracker.scan_interval)
|
||||
else:
|
||||
@@ -130,7 +134,6 @@ async def delete_tracker(
|
||||
session.add(el)
|
||||
await session.delete(tracker)
|
||||
await session.commit()
|
||||
from ..services.scheduler import unschedule_tracker
|
||||
await unschedule_tracker(tracker_id)
|
||||
|
||||
|
||||
@@ -141,71 +144,10 @@ async def trigger_tracker(
|
||||
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.post("/{tracker_id}/test-periodic")
|
||||
async def test_periodic(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test periodic summary notification using actual templates."""
|
||||
from ..services.notifier import send_test_template_notification
|
||||
from ..database.models import TemplateConfig
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(
|
||||
TrackerTarget.tracker_id == tracker.id,
|
||||
TrackerTarget.enabled == True,
|
||||
)
|
||||
)
|
||||
results = []
|
||||
for tt in result.all():
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
continue
|
||||
template_config = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
template_str = (template_config.periodic_summary_message if template_config else "") or ""
|
||||
r = await send_test_template_notification(target, "periodic_summary", template_str)
|
||||
results.append({"target": target.name, **r})
|
||||
return {"test": "periodic_summary", "results": results}
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/test-memory")
|
||||
async def test_memory(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test memory/on-this-day notification using actual templates."""
|
||||
from ..services.notifier import send_test_template_notification
|
||||
from ..database.models import TemplateConfig
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(
|
||||
TrackerTarget.tracker_id == tracker.id,
|
||||
TrackerTarget.enabled == True,
|
||||
)
|
||||
)
|
||||
results = []
|
||||
for tt in result.all():
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
continue
|
||||
template_config = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
template_str = (template_config.memory_mode_message if template_config else "") or ""
|
||||
r = await send_test_template_notification(target, "memory_mode", template_str)
|
||||
results.append({"target": target.name, **r})
|
||||
return {"test": "memory_mode", "results": results}
|
||||
|
||||
|
||||
@router.get("/{tracker_id}/history")
|
||||
async def tracker_history(
|
||||
tracker_id: int,
|
||||
@@ -238,23 +180,7 @@ async def _tracker_response(session: AsyncSession, t: Tracker) -> dict:
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == t.id)
|
||||
)
|
||||
tracker_targets = []
|
||||
for tt in result.all():
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
tracker_targets.append({
|
||||
"id": tt.id,
|
||||
"target_id": tt.target_id,
|
||||
"target_name": target.name if target else None,
|
||||
"target_type": target.type if target else None,
|
||||
"target_icon": target.icon if target else None,
|
||||
"tracking_config_id": tt.tracking_config_id,
|
||||
"template_config_id": tt.template_config_id,
|
||||
"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(),
|
||||
})
|
||||
tracker_targets = [await _tt_response(session, tt) for tt in result.all()]
|
||||
|
||||
return {
|
||||
"id": t.id,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tracking configuration CRUD API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -9,6 +11,8 @@ from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TrackingConfig, User
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"])
|
||||
|
||||
|
||||
@@ -43,6 +47,7 @@ class TrackingConfigCreate(BaseModel):
|
||||
scheduled_order_by: str = "random"
|
||||
scheduled_order: str = "descending"
|
||||
memory_enabled: bool = False
|
||||
memory_source: str = "albums"
|
||||
memory_times: str = "09:00"
|
||||
memory_collection_mode: str = "combined"
|
||||
memory_limit: int = 10
|
||||
@@ -81,6 +86,7 @@ class TrackingConfigUpdate(BaseModel):
|
||||
scheduled_order_by: str | None = None
|
||||
scheduled_order: str | None = None
|
||||
memory_enabled: bool | None = None
|
||||
memory_source: str | None = None
|
||||
memory_times: str | None = None
|
||||
memory_collection_mode: str | None = None
|
||||
memory_limit: int | None = None
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""User management API routes (admin only)."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -11,6 +13,8 @@ from ..auth.dependencies import require_admin
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import User
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Telegram bot commands package."""
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,615 @@
|
||||
"""Telegram bot command handler — implements all /commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random as rng
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from sqlmodel import select
|
||||
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 ..services import make_immich_provider
|
||||
from ..database.models import (
|
||||
EventLog,
|
||||
NotificationTarget,
|
||||
ServiceProvider,
|
||||
TelegramBot,
|
||||
Tracker,
|
||||
TrackerTarget,
|
||||
TrackingConfig,
|
||||
)
|
||||
from .parser import parse_command
|
||||
from .registry import COMMAND_DESCRIPTIONS, get_rate_category
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Rate limit state: { (bot_id, chat_id, category): last_used_timestamp }
|
||||
_rate_limits: dict[tuple[int, str, str], float] = {}
|
||||
|
||||
|
||||
def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int]) -> int | None:
|
||||
"""Check rate limit. Returns seconds to wait, or None if OK."""
|
||||
category = get_rate_category(cmd)
|
||||
cooldown = limits.get(category, limits.get("default", 10))
|
||||
if cooldown <= 0:
|
||||
return None
|
||||
key = (bot_id, chat_id, category)
|
||||
now = time.time()
|
||||
last = _rate_limits.get(key, 0)
|
||||
if now - last < cooldown:
|
||||
return int(cooldown - (now - last)) + 1
|
||||
_rate_limits[key] = now
|
||||
return None
|
||||
|
||||
|
||||
async def handle_command(
|
||||
bot: TelegramBot,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
) -> str | list[dict[str, Any]] | None:
|
||||
"""Handle a bot command. Returns text response, media list, or None."""
|
||||
cmd, args, count_override = parse_command(text)
|
||||
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", {})
|
||||
|
||||
if cmd == "start":
|
||||
msgs = {
|
||||
"en": "Hi! I'm your Notify Bridge bot. Use /help to see available commands.",
|
||||
"ru": "Привет! Я бот Notify Bridge. Используйте /help для списка команд.",
|
||||
}
|
||||
return msgs.get(locale, msgs["en"])
|
||||
|
||||
if cmd not in enabled and cmd != "start":
|
||||
return None # Silently ignore disabled commands
|
||||
|
||||
# Rate limit check
|
||||
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
|
||||
if wait is not None:
|
||||
msgs = {
|
||||
"en": f"Please wait {wait}s before using this command again.",
|
||||
"ru": f"Подождите {wait} сек. перед повторным использованием.",
|
||||
}
|
||||
return msgs.get(locale, msgs["en"])
|
||||
|
||||
count = min(count_override or default_count, 20)
|
||||
|
||||
# Dispatch
|
||||
if cmd == "help":
|
||||
return _cmd_help(enabled, locale)
|
||||
if cmd == "status":
|
||||
return await _cmd_status(bot, locale)
|
||||
if cmd == "albums":
|
||||
return await _cmd_albums(bot, locale)
|
||||
if cmd == "events":
|
||||
return await _cmd_events(bot, count, locale)
|
||||
if cmd == "people":
|
||||
return await _cmd_people(bot, locale)
|
||||
if cmd in ("search", "find", "person", "place", "latest", "random",
|
||||
"favorites", "summary", "memory"):
|
||||
return await _cmd_immich(bot, cmd, args, count, locale)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _cmd_help(enabled: list[str], locale: str) -> str:
|
||||
lines = []
|
||||
for cmd in enabled:
|
||||
desc = COMMAND_DESCRIPTIONS.get(cmd, {})
|
||||
lines.append(f"/{cmd} — {desc.get(locale, desc.get('en', ''))}")
|
||||
header = {"en": "Available commands:", "ru": "Доступные команды:"}
|
||||
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."""
|
||||
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,
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
async def _check_native_memory(bot: TelegramBot) -> bool:
|
||||
"""Check if any tracker-target linked to this bot uses native memory source."""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(NotificationTarget).where(
|
||||
NotificationTarget.type == "telegram",
|
||||
NotificationTarget.user_id == bot.user_id,
|
||||
)
|
||||
)
|
||||
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 False
|
||||
tt_result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.target_id.in_(bot_target_ids))
|
||||
)
|
||||
for tt in tt_result.all():
|
||||
if tt.tracking_config_id:
|
||||
tc = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
if tc and tc.memory_source == "native":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _cmd_status(bot: TelegramBot, locale: str) -> str:
|
||||
trackers, _ = await _get_bot_context(bot)
|
||||
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)
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(EventLog).order_by(EventLog.created_at.desc()).limit(1)
|
||||
)
|
||||
last_event = result.first()
|
||||
last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
|
||||
|
||||
if locale == "ru":
|
||||
return (
|
||||
f"📊 Статус\n"
|
||||
f"Трекеры: {active}/{total} активных\n"
|
||||
f"Альбомы: {total_albums}\n"
|
||||
f"Последнее событие: {last_str}"
|
||||
)
|
||||
return (
|
||||
f"📊 Status\n"
|
||||
f"Trackers: {active}/{total} active\n"
|
||||
f"Albums: {total_albums}\n"
|
||||
f"Last event: {last_str}"
|
||||
)
|
||||
|
||||
|
||||
async def _cmd_albums(bot: TelegramBot, locale: str) -> str:
|
||||
trackers, providers_map = await _get_bot_context(bot)
|
||||
if not trackers:
|
||||
return "No tracked albums." if locale == "en" else "Нет отслеживаемых альбомов."
|
||||
|
||||
lines = []
|
||||
async with aiohttp.ClientSession() as http:
|
||||
for tracker in trackers:
|
||||
provider = providers_map.get(tracker.provider_id)
|
||||
if not provider or provider.type != "immich":
|
||||
continue
|
||||
immich = make_immich_provider(http, provider)
|
||||
for album_id in (tracker.collection_ids or []):
|
||||
try:
|
||||
album = await immich.client.get_album(album_id)
|
||||
if album:
|
||||
lines.append(f" • {album.name} ({album.asset_count} assets)")
|
||||
except Exception:
|
||||
lines.append(f" • {album_id[:8]}... (error)")
|
||||
|
||||
header = "📚 Tracked albums:" if locale == "en" else "📚 Отслеживаемые альбомы:"
|
||||
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)
|
||||
tracker_ids = [t.id for t in trackers]
|
||||
if not tracker_ids:
|
||||
return "No events." if locale == "en" else "Нет событий."
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(EventLog)
|
||||
.where(EventLog.tracker_id.in_(tracker_ids))
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(count)
|
||||
)
|
||||
events = result.all()
|
||||
|
||||
if not events:
|
||||
return "No events yet." if locale == "en" else "Пока нет событий."
|
||||
|
||||
header = f"📋 Last {len(events)} events:" if locale == "en" else f"📋 Последние {len(events)} событий:"
|
||||
lines = []
|
||||
for e in events:
|
||||
ts = e.created_at.strftime("%m/%d %H:%M")
|
||||
lines.append(f" {ts} — {e.event_type}: {e.collection_name}")
|
||||
return header + "\n" + "\n".join(lines)
|
||||
|
||||
|
||||
async def _cmd_people(bot: TelegramBot, locale: str) -> str:
|
||||
_, providers_map = await _get_bot_context(bot)
|
||||
all_people: dict[str, str] = {}
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
for provider in providers_map.values():
|
||||
if provider.type != "immich":
|
||||
continue
|
||||
immich = make_immich_provider(http, provider)
|
||||
people = await immich.client.get_people()
|
||||
all_people.update(people)
|
||||
|
||||
if not all_people:
|
||||
return "No people detected." if locale == "en" else "Люди не обнаружены."
|
||||
|
||||
names = sorted(all_people.values())
|
||||
header = f"👥 {len(names)} people:" if locale == "en" else f"👥 {len(names)} людей:"
|
||||
return header + "\n" + ", ".join(names)
|
||||
|
||||
|
||||
async def _cmd_immich(
|
||||
bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
|
||||
) -> 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:
|
||||
return "No trackers configured." if locale == "en" else "Трекеры не настроены."
|
||||
|
||||
all_album_ids: list[str] = []
|
||||
for t in 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":
|
||||
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
|
||||
|
||||
if cmd == "search":
|
||||
if not args:
|
||||
return "Usage: /search <query>" if locale == "en" else "Использование: /search <запрос>"
|
||||
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count)
|
||||
return _format_assets(assets, cmd, args, locale, response_mode, client)
|
||||
|
||||
if cmd == "find":
|
||||
if not args:
|
||||
return "Usage: /find <text>" if locale == "en" else "Использование: /find <текст>"
|
||||
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count)
|
||||
return _format_assets(assets, cmd, args, locale, response_mode, client)
|
||||
|
||||
if cmd == "person":
|
||||
if not args:
|
||||
return "Usage: /person <name>" if locale == "en" else "Использование: /person <имя>"
|
||||
people = await client.get_people()
|
||||
person_id = None
|
||||
for pid, pname in people.items():
|
||||
if args.lower() in pname.lower():
|
||||
person_id = pid
|
||||
break
|
||||
if not person_id:
|
||||
return f"Person '{args}' not found." if locale == "en" else f"Человек '{args}' не найден."
|
||||
assets = await client.search_by_person(person_id, limit=count)
|
||||
return _format_assets(assets, cmd, args, locale, response_mode, client)
|
||||
|
||||
if cmd == "place":
|
||||
if not args:
|
||||
return "Usage: /place <location>" if locale == "en" else "Использование: /place <место>"
|
||||
assets = await client.search_smart(
|
||||
f"photos taken in {args}", album_ids=all_album_ids, limit=count
|
||||
)
|
||||
return _format_assets(assets, cmd, args, locale, response_mode, client)
|
||||
|
||||
if cmd == "favorites":
|
||||
fav_assets: list[dict[str, Any]] = []
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
for aid, asset in list(album.assets.items())[:50]:
|
||||
if asset.is_favorite and len(fav_assets) < count:
|
||||
fav_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
if len(fav_assets) >= count:
|
||||
break
|
||||
return _format_assets(fav_assets, cmd, "", locale, response_mode, client)
|
||||
|
||||
if cmd == "latest":
|
||||
latest_assets: list[dict[str, Any]] = []
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
for aid, asset in list(album.assets.items())[:count]:
|
||||
latest_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type, "createdAt": asset.created_at,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
latest_assets.sort(key=lambda a: a.get("createdAt", ""), reverse=True)
|
||||
return _format_assets(latest_assets[:count], cmd, "", locale, response_mode, client)
|
||||
|
||||
if cmd == "random":
|
||||
random_assets: list[dict[str, Any]] = []
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
asset_list = list(album.assets.values())
|
||||
sampled = rng.sample(asset_list, min(count, len(asset_list)))
|
||||
for asset in sampled:
|
||||
random_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
rng.shuffle(random_assets)
|
||||
return _format_assets(random_assets[:count], cmd, "", locale, response_mode, client)
|
||||
|
||||
if cmd == "summary":
|
||||
lines = []
|
||||
for album_id in all_album_ids:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
lines.append(f" • {album.name}: {album.asset_count} assets")
|
||||
except Exception:
|
||||
pass
|
||||
header = f"📋 Album summary ({len(lines)}):" if locale == "en" else f"📋 Сводка альбомов ({len(lines)}):"
|
||||
return header + "\n" + "\n".join(lines) if lines else header
|
||||
|
||||
if cmd == "memory":
|
||||
# Check if any linked tracking config uses native memories
|
||||
use_native = await _check_native_memory(bot)
|
||||
|
||||
today = datetime.now(timezone.utc)
|
||||
memory_assets: list[dict[str, Any]] = []
|
||||
|
||||
if use_native:
|
||||
# Use Immich native memories API
|
||||
memories = await client.get_memories()
|
||||
tracked_ids = set(all_album_ids) if all_album_ids else None
|
||||
for mem in memories:
|
||||
year = mem.get("data", {}).get("year")
|
||||
for raw_asset in mem.get("assets", []):
|
||||
# Optional album filtering
|
||||
if tracked_ids:
|
||||
asset_albums = raw_asset.get("albums", [])
|
||||
if not any(a.get("id") in tracked_ids for a in asset_albums):
|
||||
continue
|
||||
memory_assets.append({
|
||||
"id": raw_asset.get("id", ""),
|
||||
"originalFileName": raw_asset.get("originalFileName", ""),
|
||||
"type": raw_asset.get("type", "IMAGE"),
|
||||
"createdAt": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")),
|
||||
"year": year,
|
||||
})
|
||||
else:
|
||||
# Album-scanning fallback
|
||||
month_day = (today.month, today.day)
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
for aid, asset in album.assets.items():
|
||||
try:
|
||||
dt = datetime.fromisoformat(asset.created_at.replace("Z", "+00:00"))
|
||||
if (dt.month, dt.day) == month_day and dt.year != today.year:
|
||||
memory_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type, "createdAt": asset.created_at,
|
||||
"year": dt.year,
|
||||
})
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
memory_assets = memory_assets[:count]
|
||||
if not memory_assets:
|
||||
return "No memories for today." if locale == "en" else "Нет воспоминаний за сегодня."
|
||||
return _format_assets(memory_assets, cmd, "", locale, response_mode, client)
|
||||
|
||||
return "Unknown command." if locale == "en" else "Неизвестная команда."
|
||||
|
||||
|
||||
def _format_assets(
|
||||
assets: list[dict[str, Any]], cmd: str, query: str,
|
||||
locale: str, response_mode: str, client: Any,
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Format asset results as text or media payload."""
|
||||
if not assets:
|
||||
return {"en": "No results found.", "ru": "Ничего не найдено."}.get(locale, "No results found.")
|
||||
|
||||
if response_mode == "media":
|
||||
media_items = []
|
||||
for asset in assets:
|
||||
asset_id = asset.get("id", "")
|
||||
filename = asset.get("originalFileName", "")
|
||||
year = asset.get("year", "")
|
||||
caption = f"{filename} ({year})" if year else filename
|
||||
media_items.append({
|
||||
"type": "photo",
|
||||
"asset_id": asset_id,
|
||||
"caption": caption,
|
||||
"thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview",
|
||||
"api_key": client.api_key,
|
||||
})
|
||||
return media_items
|
||||
|
||||
# Text mode
|
||||
header_map = {
|
||||
"search": {"en": f'🔍 Results for "{query}":', "ru": f'🔍 Результаты для "{query}":'},
|
||||
"find": {"en": f'📄 Files matching "{query}":', "ru": f'📄 Файлы по запросу "{query}":'},
|
||||
"person": {"en": f"👤 Photos of {query}:", "ru": f"👤 Фото {query}:"},
|
||||
"place": {"en": f"📍 Photos from {query}:", "ru": f"📍 Фото из {query}:"},
|
||||
"favorites": {"en": "⭐ Favorites:", "ru": "⭐ Избранное:"},
|
||||
"latest": {"en": "📸 Latest:", "ru": "📸 Последние:"},
|
||||
"random": {"en": "🎲 Random:", "ru": "🎲 Случайные:"},
|
||||
"memory": {"en": "📅 On this day:", "ru": "📅 В этот день:"},
|
||||
}
|
||||
header = header_map.get(cmd, {}).get(locale, f"Results ({len(assets)}):")
|
||||
lines = []
|
||||
for a in assets:
|
||||
name = a.get("originalFileName", a.get("id", "?")[:8])
|
||||
year = a.get("year", "")
|
||||
lines.append(f" • {name} ({year})" if year else f" • {name}")
|
||||
return header + "\n" + "\n".join(lines)
|
||||
|
||||
|
||||
async def send_media_group(
|
||||
bot_token: str, chat_id: str, media_items: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Send media items as a Telegram media group (album).
|
||||
|
||||
Falls back to individual sendPhoto calls if sendMediaGroup fails.
|
||||
Telegram allows max 10 items per media group.
|
||||
"""
|
||||
if not media_items:
|
||||
return
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
# Download all thumbnails first
|
||||
downloaded: list[tuple[bytes, str, str]] = [] # (photo_bytes, asset_id, caption)
|
||||
for item in media_items:
|
||||
asset_id = item.get("asset_id", "")
|
||||
caption = item.get("caption", "")
|
||||
thumb_url = item.get("thumbnail_url", "")
|
||||
api_key = item.get("api_key", "")
|
||||
try:
|
||||
async with http.get(thumb_url, headers={"x-api-key": api_key}) as resp:
|
||||
if resp.status != 200:
|
||||
_LOGGER.warning("Failed to download thumbnail for %s: HTTP %d", asset_id, resp.status)
|
||||
continue
|
||||
photo_bytes = await resp.read()
|
||||
downloaded.append((photo_bytes, asset_id, caption))
|
||||
except aiohttp.ClientError:
|
||||
continue
|
||||
|
||||
if not downloaded:
|
||||
return
|
||||
|
||||
# Send in groups of 10 (Telegram limit)
|
||||
for i in range(0, len(downloaded), 10):
|
||||
chunk = downloaded[i:i + 10]
|
||||
|
||||
if len(chunk) == 1:
|
||||
# Single photo — use sendPhoto
|
||||
photo_bytes, asset_id, caption = chunk[0]
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("chat_id", chat_id)
|
||||
data.add_field("photo", photo_bytes, filename=f"{asset_id}.jpg", content_type="image/jpeg")
|
||||
if caption:
|
||||
data.add_field("caption", caption)
|
||||
try:
|
||||
async with http.post(f"{TELEGRAM_API_BASE_URL}{bot_token}/sendPhoto", data=data) as resp:
|
||||
if resp.status != 200:
|
||||
result = await resp.json()
|
||||
_LOGGER.warning("Failed to send photo: %s", result.get("description"))
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to send photo: %s", err)
|
||||
else:
|
||||
# Multiple photos — use sendMediaGroup
|
||||
import json as _json
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("chat_id", chat_id)
|
||||
media_array = []
|
||||
for idx, (photo_bytes, asset_id, caption) in enumerate(chunk):
|
||||
attach_key = f"photo_{idx}"
|
||||
media_obj: dict[str, Any] = {"type": "photo", "media": f"attach://{attach_key}"}
|
||||
if caption:
|
||||
media_obj["caption"] = caption
|
||||
media_array.append(media_obj)
|
||||
data.add_field(attach_key, photo_bytes, filename=f"{asset_id}.jpg", content_type="image/jpeg")
|
||||
data.add_field("media", _json.dumps(media_array))
|
||||
try:
|
||||
async with http.post(f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMediaGroup", data=data) as resp:
|
||||
if resp.status != 200:
|
||||
result = await resp.json()
|
||||
_LOGGER.warning("Failed to send media group: %s", result.get("description"))
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to send media group: %s", err)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
commands = []
|
||||
for cmd in enabled:
|
||||
desc = COMMAND_DESCRIPTIONS.get(cmd, {})
|
||||
commands.append({
|
||||
"command": cmd,
|
||||
"description": desc.get(locale, desc.get("en", cmd)),
|
||||
})
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
url = f"{TELEGRAM_API_BASE_URL}{bot.token}/setMyCommands"
|
||||
payload: dict[str, Any] = {"commands": commands}
|
||||
try:
|
||||
async with http.post(url, json=payload) as resp:
|
||||
result = await resp.json()
|
||||
if result.get("ok"):
|
||||
_LOGGER.info("Registered %d commands for bot @%s", len(commands), bot.bot_username)
|
||||
# Also register for the other locale
|
||||
other_locale = "ru" if locale == "en" else "en"
|
||||
other_commands = [
|
||||
{"command": c, "description": COMMAND_DESCRIPTIONS.get(c, {}).get(other_locale, c)}
|
||||
for c in enabled
|
||||
]
|
||||
async with http.post(url, json={"commands": other_commands, "language_code": other_locale}) as r2:
|
||||
pass
|
||||
return True
|
||||
_LOGGER.warning("Failed to register commands: %s", result.get("description"))
|
||||
return False
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Failed to register commands: %s", err)
|
||||
return False
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Command text parsing — extracts command name, arguments, and optional count."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def parse_command(text: str) -> tuple[str, str, int | None]:
|
||||
"""Parse a command message into (command, args, count).
|
||||
|
||||
Examples:
|
||||
"/search sunset" -> ("search", "sunset", None)
|
||||
"/latest Family 5" -> ("latest", "Family", 5)
|
||||
"/events 10" -> ("events", "", 10)
|
||||
"/help@mybot" -> ("help", "", None)
|
||||
"""
|
||||
text = text.strip()
|
||||
if not text.startswith("/"):
|
||||
return ("", text, None)
|
||||
|
||||
# Strip @botname suffix: /command@botname args
|
||||
parts = text[1:].split(None, 1)
|
||||
cmd = parts[0].split("@")[0].lower()
|
||||
rest = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# Try to extract trailing count
|
||||
count = None
|
||||
rest_parts = rest.rsplit(None, 1)
|
||||
if len(rest_parts) == 2:
|
||||
try:
|
||||
count = int(rest_parts[1])
|
||||
rest = rest_parts[0]
|
||||
except ValueError:
|
||||
pass
|
||||
elif rest_parts and rest_parts[0]:
|
||||
try:
|
||||
count = int(rest_parts[0])
|
||||
rest = ""
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return (cmd, rest.strip(), count)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Command definitions — descriptions, categories, and rate limit grouping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Command descriptions for Telegram menu (EN / RU)
|
||||
COMMAND_DESCRIPTIONS: dict[str, dict[str, str]] = {
|
||||
"status": {"en": "Show tracker status", "ru": "Показать статус трекеров"},
|
||||
"albums": {"en": "List tracked albums", "ru": "Список отслеживаемых альбомов"},
|
||||
"events": {"en": "Show recent events", "ru": "Показать последние события"},
|
||||
"summary": {"en": "Send album summary now", "ru": "Отправить сводку альбомов"},
|
||||
"latest": {"en": "Show latest photos", "ru": "Показать последние фото"},
|
||||
"memory": {"en": "On This Day memories", "ru": "Воспоминания за этот день"},
|
||||
"random": {"en": "Send random photo", "ru": "Отправить случайное фото"},
|
||||
"search": {"en": "Smart search (AI)", "ru": "Умный поиск (AI)"},
|
||||
"find": {"en": "Search by filename", "ru": "Поиск по имени файла"},
|
||||
"person": {"en": "Find photos of person", "ru": "Найти фото человека"},
|
||||
"place": {"en": "Find photos by location", "ru": "Найти фото по месту"},
|
||||
"favorites": {"en": "Show favorites", "ru": "Показать избранное"},
|
||||
"people": {"en": "List detected people", "ru": "Список людей"},
|
||||
"help": {"en": "Show available commands", "ru": "Показать доступные команды"},
|
||||
}
|
||||
|
||||
ALL_COMMANDS = list(COMMAND_DESCRIPTIONS.keys())
|
||||
|
||||
# Map commands to rate limit categories
|
||||
_RATE_CATEGORY: dict[str, str] = {
|
||||
"search": "search", "find": "search", "person": "search",
|
||||
"place": "search", "favorites": "search", "people": "search",
|
||||
}
|
||||
|
||||
|
||||
def get_rate_category(cmd: str) -> str:
|
||||
return _RATE_CATEGORY.get(cmd, "default")
|
||||
|
||||
|
||||
DEFAULT_COMMANDS_CONFIG = {
|
||||
"enabled": ["help", "status", "albums", "events", "latest", "random", "favorites", "summary", "memory"],
|
||||
"locale": "en",
|
||||
"response_mode": "media",
|
||||
"default_count": 5,
|
||||
"rate_limits": {"search": 30, "default": 10},
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Telegram webhook handler for bot commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
|
||||
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TelegramBot
|
||||
from ..services.telegram import save_chat_from_webhook
|
||||
from .handler import handle_command, send_media_group
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/telegram", tags=["telegram-webhook"])
|
||||
|
||||
# Webhook secret — set via NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET env var
|
||||
_webhook_secret: str | None = None
|
||||
|
||||
|
||||
def set_webhook_secret(secret: str | None) -> None:
|
||||
global _webhook_secret
|
||||
_webhook_secret = secret
|
||||
|
||||
|
||||
@router.post("/webhook/{webhook_id}")
|
||||
async def telegram_webhook(
|
||||
webhook_id: str,
|
||||
request: Request,
|
||||
x_telegram_bot_api_secret_token: str | None = Header(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Handle incoming Telegram messages — route commands to handlers."""
|
||||
# Validate webhook secret if configured
|
||||
if _webhook_secret:
|
||||
if x_telegram_bot_api_secret_token != _webhook_secret:
|
||||
raise HTTPException(status_code=403, detail="Invalid webhook secret")
|
||||
|
||||
# Find bot by opaque webhook path ID (not by token — token must not appear in URLs)
|
||||
bot_result = await session.exec(
|
||||
select(TelegramBot).where(TelegramBot.webhook_path_id == webhook_id)
|
||||
)
|
||||
bot = bot_result.first()
|
||||
if not bot:
|
||||
raise HTTPException(status_code=403, detail="Unknown webhook")
|
||||
|
||||
try:
|
||||
update = await request.json()
|
||||
except Exception:
|
||||
return {"ok": True, "error": "invalid_json"}
|
||||
|
||||
message = update.get("message")
|
||||
if not message:
|
||||
return {"ok": True, "skipped": "no_message"}
|
||||
|
||||
chat_info = message.get("chat", {})
|
||||
chat_id = str(chat_info.get("id", ""))
|
||||
text = message.get("text", "")
|
||||
|
||||
if not chat_id or not text:
|
||||
return {"ok": True, "skipped": "empty"}
|
||||
|
||||
# Auto-persist chat from incoming message
|
||||
try:
|
||||
await save_chat_from_webhook(session, bot.id, chat_info)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
_LOGGER.warning("Failed to auto-save chat %s", chat_id, exc_info=True)
|
||||
|
||||
# Handle commands
|
||||
if text.startswith("/"):
|
||||
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)
|
||||
return {"ok": True}
|
||||
|
||||
return {"ok": True, "skipped": "not_a_command"}
|
||||
|
||||
|
||||
async def _send_reply(bot_token: str, chat_id: str, text: str) -> None:
|
||||
"""Send a text reply via Telegram Bot API."""
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMessage"
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
||||
try:
|
||||
async with http_session.post(url, json=payload) as resp:
|
||||
if resp.status != 200:
|
||||
result = await resp.json()
|
||||
_LOGGER.debug("Telegram reply failed: %s", result.get("description"))
|
||||
# Retry without parse_mode if HTML fails
|
||||
if "parse" in str(result.get("description", "")).lower():
|
||||
payload.pop("parse_mode", None)
|
||||
async with http_session.post(url, json=payload) as retry_resp:
|
||||
if retry_resp.status != 200:
|
||||
_LOGGER.warning("Telegram reply failed on retry")
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Failed to send Telegram reply: %s", err)
|
||||
|
||||
|
||||
async def register_webhook(bot_token: str, webhook_url: str, secret: str | None = None) -> dict:
|
||||
"""Register webhook URL with Telegram Bot API."""
|
||||
async with aiohttp.ClientSession() as http:
|
||||
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/setWebhook"
|
||||
payload: dict[str, Any] = {"url": webhook_url}
|
||||
if secret:
|
||||
payload["secret_token"] = secret
|
||||
try:
|
||||
async with http.post(url, json=payload) as resp:
|
||||
result = await resp.json()
|
||||
if result.get("ok"):
|
||||
return {"success": True}
|
||||
return {"success": False, "error": result.get("description")}
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": str(err)}
|
||||
|
||||
|
||||
async def unregister_webhook(bot_token: str) -> dict:
|
||||
"""Remove webhook from Telegram Bot API."""
|
||||
async with aiohttp.ClientSession() as http:
|
||||
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/deleteWebhook"
|
||||
try:
|
||||
async with http.post(url) as resp:
|
||||
result = await resp.json()
|
||||
return {"success": result.get("ok", False)}
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": str(err)}
|
||||
@@ -44,6 +44,30 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
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:
|
||||
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 date_only_format to template_config if missing
|
||||
if not await _has_column("template_config", "date_only_format"):
|
||||
await conn.execute(
|
||||
@@ -51,6 +75,20 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
)
|
||||
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")
|
||||
|
||||
# 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")
|
||||
|
||||
# Add collection_name and shared to tracker_state if missing
|
||||
if not await _has_column("tracker_state", "collection_name"):
|
||||
await conn.execute(
|
||||
@@ -190,7 +228,10 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
|
||||
):
|
||||
target_bot_map[tgt[0]] = bot_id
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(
|
||||
"Failed to match bot token for target %s", tgt[0],
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Create TrackerTarget rows
|
||||
import json
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlmodel import JSON, Column, Field, SQLModel
|
||||
|
||||
@@ -44,6 +45,9 @@ class TelegramBot(SQLModel, table=True):
|
||||
icon: str = Field(default="")
|
||||
bot_username: str = Field(default="")
|
||||
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))
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
@@ -106,6 +110,7 @@ class TrackingConfig(SQLModel, table=True):
|
||||
|
||||
# Memory mode
|
||||
memory_enabled: bool = Field(default=False)
|
||||
memory_source: str = Field(default="albums") # "albums" or "native"
|
||||
memory_times: str = Field(default="09:00")
|
||||
memory_collection_mode: str = Field(default="combined")
|
||||
memory_limit: int = Field(default=10)
|
||||
@@ -221,13 +226,22 @@ class EventLog(SQLModel, table=True):
|
||||
__tablename__ = "event_log"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
tracker_id: int | None = Field(default=None, foreign_key="tracker.id")
|
||||
tracker_id: int | None = Field(default=None, foreign_key="tracker.id", index=True)
|
||||
tracker_name: str = Field(default="")
|
||||
provider_id: int | None = Field(default=None)
|
||||
provider_id: int | None = Field(default=None, index=True)
|
||||
provider_name: str = Field(default="")
|
||||
event_type: str
|
||||
event_type: str = Field(index=True)
|
||||
collection_id: str
|
||||
collection_name: str
|
||||
assets_count: int = Field(default=0)
|
||||
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class AppSetting(SQLModel, table=True):
|
||||
"""Key-value app-level settings (admin-configurable)."""
|
||||
|
||||
__tablename__ = "app_setting"
|
||||
|
||||
key: str = Field(primary_key=True)
|
||||
value: str = Field(default="")
|
||||
|
||||
@@ -24,6 +24,8 @@ from .api.telegram_bots import router as telegram_bots_router
|
||||
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 .commands.webhook import router as webhook_router, set_webhook_secret
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -36,6 +38,12 @@ async def lifespan(app: FastAPI):
|
||||
await migrate_schema(engine)
|
||||
await migrate_tracker_targets(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
|
||||
from .api.app_settings import get_setting as _get_setting
|
||||
async with _AS(engine) as _session:
|
||||
_secret = await _get_setting(_session, "telegram_webhook_secret")
|
||||
set_webhook_secret(_secret or None)
|
||||
from .services.scheduler import start_scheduler
|
||||
await start_scheduler()
|
||||
yield
|
||||
@@ -55,6 +63,8 @@ app.include_router(targets_router)
|
||||
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(webhook_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
"""Business logic services — scheduler, watcher, notifier."""
|
||||
"""Shared service utilities."""
|
||||
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
|
||||
from ..database.models import ServiceProvider
|
||||
|
||||
|
||||
def make_immich_provider(http_session, provider: ServiceProvider) -> ImmichServiceProvider:
|
||||
"""Create an ImmichServiceProvider from a DB provider model."""
|
||||
config = provider.config or {}
|
||||
return ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test notification sender."""
|
||||
"""Notification sender — unified send logic for all paths (dispatch + test)."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -25,41 +26,69 @@ def _get_test_message(locale: str, target_type: str) -> str:
|
||||
return msgs.get(target_type, msgs.get("webhook", "Test"))
|
||||
|
||||
|
||||
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
"""Send a simple test message to a notification target."""
|
||||
async def send_to_target(target: NotificationTarget, message: str) -> dict:
|
||||
"""Send a message to a target, respecting all target config settings.
|
||||
|
||||
This is the SINGLE send path used by dispatch, test, and real-data notifications.
|
||||
"""
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram(target, locale)
|
||||
return await _send_telegram(target, message)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook(target, locale)
|
||||
return await _send_webhook(target, message)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Test notification failed: %s", e)
|
||||
_LOGGER.error("Send failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _test_telegram(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
async def _send_telegram(target: NotificationTarget, message: str) -> dict:
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
|
||||
bot_token = target.config.get("bot_token")
|
||||
chat_id = target.config.get("chat_id")
|
||||
disable_preview = target.config.get("disable_url_preview", False)
|
||||
|
||||
if not bot_token or not chat_id:
|
||||
return {"success": False, "error": "Missing bot_token or chat_id"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = TelegramClient(session, bot_token)
|
||||
return await client.send_notification(
|
||||
return await client.send_message(
|
||||
chat_id=str(chat_id),
|
||||
caption=_get_test_message(locale, "telegram"),
|
||||
text=message,
|
||||
disable_web_page_preview=bool(disable_preview),
|
||||
)
|
||||
|
||||
|
||||
async def _send_webhook(target: NotificationTarget, message: str, event_type: str = "notification") -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
|
||||
url = target.config.get("url")
|
||||
headers = target.config.get("headers", {})
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing url in target config"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({"message": message, "event_type": event_type})
|
||||
|
||||
|
||||
# --- Public API used by routes ---
|
||||
|
||||
|
||||
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
"""Send a simple test message."""
|
||||
message = _get_test_message(locale, target.type)
|
||||
return await send_to_target(target, message)
|
||||
|
||||
|
||||
async def send_test_template_notification(
|
||||
target: NotificationTarget, slot: str, template_str: str
|
||||
) -> dict:
|
||||
"""Render a template slot with sample data and send it to a target."""
|
||||
"""Render a template slot with sample data and send."""
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from ..api.template_configs import _SAMPLE_CONTEXT
|
||||
from .sample_context import _SAMPLE_CONTEXT
|
||||
|
||||
if not template_str:
|
||||
return await send_test_notification(target)
|
||||
@@ -71,53 +100,7 @@ async def send_test_template_notification(
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Template render error: {e}"}
|
||||
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram_with_message(target, message)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook_with_message(target, message)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Test template notification failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _test_telegram_with_message(target: NotificationTarget, message: str) -> dict:
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
bot_token = target.config.get("bot_token")
|
||||
chat_id = target.config.get("chat_id")
|
||||
if not bot_token or not chat_id:
|
||||
return {"success": False, "error": "Missing bot_token or chat_id"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = TelegramClient(session, bot_token)
|
||||
return await client.send_notification(chat_id=str(chat_id), caption=message)
|
||||
|
||||
|
||||
async def _test_webhook_with_message(target: NotificationTarget, message: str) -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
url = target.config.get("url")
|
||||
headers = target.config.get("headers", {})
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing url in target config"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({"message": message, "event_type": "test_template"})
|
||||
|
||||
|
||||
async def _test_webhook(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
|
||||
url = target.config.get("url")
|
||||
headers = target.config.get("headers", {})
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing url in target config"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({
|
||||
"message": _get_test_message(locale, "webhook"),
|
||||
"event_type": "test",
|
||||
})
|
||||
return await send_to_target(target, message)
|
||||
|
||||
|
||||
async def send_real_data_notification(
|
||||
@@ -129,20 +112,19 @@ async def send_real_data_notification(
|
||||
collection_ids: list[str],
|
||||
date_format: str = "%d.%m.%Y, %H:%M UTC",
|
||||
date_only_format: str = "%d.%m.%Y",
|
||||
memory_source: str = "albums",
|
||||
) -> dict:
|
||||
"""Fetch real data from provider, render template, and send notification."""
|
||||
from datetime import datetime, timezone
|
||||
"""Fetch real data from provider, render template, and send."""
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
if not template_str:
|
||||
return {"success": False, "error": f"No template configured for {test_type}"}
|
||||
|
||||
# Fetch real data from provider
|
||||
ctx: dict = {}
|
||||
try:
|
||||
ctx = await _build_real_context(
|
||||
provider_type, provider_config, collection_ids,
|
||||
test_type, date_format, date_only_format,
|
||||
memory_source=memory_source,
|
||||
)
|
||||
except Exception as e:
|
||||
_LOGGER.error("Failed to fetch real data for test: %s", e)
|
||||
@@ -152,7 +134,6 @@ async def send_real_data_notification(
|
||||
ctx["date_format"] = date_format
|
||||
ctx["date_only_format"] = date_only_format
|
||||
|
||||
# Render template
|
||||
try:
|
||||
env = SandboxedEnvironment(autoescape=False)
|
||||
tmpl = env.from_string(template_str)
|
||||
@@ -160,16 +141,7 @@ async def send_real_data_notification(
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Template render error: {e}"}
|
||||
|
||||
# Send
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram_with_message(target, message)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook_with_message(target, message)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Test notification failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
return await send_to_target(target, message)
|
||||
|
||||
|
||||
async def _build_real_context(
|
||||
@@ -179,6 +151,7 @@ async def _build_real_context(
|
||||
test_type: str,
|
||||
date_format: str,
|
||||
date_only_format: str,
|
||||
memory_source: str = "albums",
|
||||
) -> dict:
|
||||
"""Build template context from real provider data."""
|
||||
from datetime import datetime, timezone
|
||||
@@ -200,16 +173,77 @@ async def _build_real_context(
|
||||
if not connected:
|
||||
raise RuntimeError("Failed to connect to Immich")
|
||||
|
||||
# Fetch album data for all tracked collections
|
||||
collections = []
|
||||
all_assets = []
|
||||
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
|
||||
|
||||
# --- Native Immich memories ---
|
||||
if test_type == "memory" and memory_source == "native":
|
||||
memories = await immich.client.get_memories()
|
||||
all_assets: list[dict[str, Any]] = []
|
||||
tracked_ids = set(collection_ids) if collection_ids else None
|
||||
for mem in memories:
|
||||
for raw_asset in mem.get("assets", []):
|
||||
asset_id = raw_asset.get("id", "")
|
||||
# Optional album filtering: keep only assets in tracked albums
|
||||
if tracked_ids:
|
||||
asset_albums = raw_asset.get("albums", [])
|
||||
if not any(a.get("id") in tracked_ids for a in asset_albums):
|
||||
continue
|
||||
exif = raw_asset.get("exifInfo") or {}
|
||||
people_raw = raw_asset.get("people", [])
|
||||
all_assets.append({
|
||||
"id": asset_id,
|
||||
"filename": raw_asset.get("originalFileName", ""),
|
||||
"type": (raw_asset.get("type") or "IMAGE").upper(),
|
||||
"created_at": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")),
|
||||
"owner": "",
|
||||
"description": exif.get("description", "") or raw_asset.get("description", "") or "",
|
||||
"people": [p.get("name", "") for p in people_raw if p.get("name")],
|
||||
"is_favorite": raw_asset.get("isFavorite", False),
|
||||
"rating": exif.get("rating"),
|
||||
"city": exif.get("city", "") or "",
|
||||
"state": exif.get("state", "") or "",
|
||||
"country": exif.get("country", "") or "",
|
||||
"public_url": "",
|
||||
"url": f"{ext_domain.rstrip('/')}/api/assets/{asset_id}/original",
|
||||
"photo_url": f"{ext_domain.rstrip('/')}/api/assets/{asset_id}/thumbnail",
|
||||
"year": mem.get("data", {}).get("year"),
|
||||
})
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
ctx: dict[str, Any] = {
|
||||
"date": now.strftime(date_only_format),
|
||||
"timestamp": now.isoformat(),
|
||||
"service_name": "Immich",
|
||||
"service_type": "immich",
|
||||
"collections": [],
|
||||
"albums": [],
|
||||
"assets": all_assets,
|
||||
"common_date": "",
|
||||
"common_location": "",
|
||||
"collection_name": "", "album_name": "",
|
||||
"public_url": "", "album_url": "",
|
||||
"shared": False, "photo_count": 0, "video_count": 0, "owner": "",
|
||||
}
|
||||
people: set[str] = set()
|
||||
for a in all_assets:
|
||||
people.update(a.get("people", []))
|
||||
ctx["people"] = list(people)
|
||||
ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in all_assets)
|
||||
ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in all_assets)
|
||||
ctx["added_count"] = len(all_assets)
|
||||
ctx["added_assets"] = all_assets
|
||||
ctx["protected_url"] = ""
|
||||
return ctx
|
||||
|
||||
# --- Album-based asset collection (default path) ---
|
||||
collections: list[dict[str, Any]] = []
|
||||
all_assets: list[dict[str, Any]] = []
|
||||
|
||||
for album_id in collection_ids:
|
||||
album = await immich.client.get_album(album_id)
|
||||
if not album:
|
||||
continue
|
||||
|
||||
# Get shared link for public URL
|
||||
shared_links = await immich.client.get_shared_links(album_id)
|
||||
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
|
||||
album_public_url = ""
|
||||
@@ -229,7 +263,6 @@ async def _build_real_context(
|
||||
"owner": album.owner,
|
||||
})
|
||||
|
||||
# Collect assets (limited sample)
|
||||
for asset_id, asset in list(album.assets.items())[:10]:
|
||||
asset_public_url = f"{album_public_url}/photos/{asset_id}" if album_public_url else ""
|
||||
all_assets.append({
|
||||
@@ -250,60 +283,42 @@ async def _build_real_context(
|
||||
"photo_url": f"{ext_domain.rstrip('/')}/api/assets/{asset.id}/thumbnail",
|
||||
})
|
||||
|
||||
# Build context based on test type
|
||||
now = datetime.now(timezone.utc)
|
||||
ctx: dict = {
|
||||
ctx: dict[str, Any] = {
|
||||
"date": now.strftime(date_only_format),
|
||||
"timestamp": now.isoformat(),
|
||||
"service_name": "Immich",
|
||||
"service_type": "immich",
|
||||
"collections": collections,
|
||||
"albums": collections, # alias
|
||||
"albums": collections,
|
||||
"assets": all_assets,
|
||||
"common_date": "",
|
||||
"common_location": "",
|
||||
}
|
||||
|
||||
# Common date/location for assets
|
||||
if len(all_assets) > 1:
|
||||
dates = set()
|
||||
for a in all_assets:
|
||||
ca = a.get("created_at", "")
|
||||
if ca:
|
||||
dates.add(ca[:10])
|
||||
dates = {a.get("created_at", "")[:10] for a in all_assets if a.get("created_at")}
|
||||
if len(dates) == 1:
|
||||
try:
|
||||
ctx["common_date"] = datetime.fromisoformat(dates.pop()).strftime(date_only_format)
|
||||
except (ValueError, TypeError):
|
||||
ctx["common_date"] = ""
|
||||
else:
|
||||
ctx["common_date"] = ""
|
||||
pass
|
||||
|
||||
locations = set()
|
||||
for a in all_assets:
|
||||
city = a.get("city", "")
|
||||
country = a.get("country", "")
|
||||
if city:
|
||||
locations.add(f"{city}, {country}" if country else city)
|
||||
else:
|
||||
locations.add("")
|
||||
locations.add(f"{city}, {country}" if city and country else city or "")
|
||||
if len(locations) == 1 and "" not in locations:
|
||||
ctx["common_location"] = locations.pop()
|
||||
else:
|
||||
ctx["common_location"] = ""
|
||||
else:
|
||||
ctx["common_date"] = ""
|
||||
ctx["common_location"] = ""
|
||||
|
||||
# Add first collection details as top-level for periodic-style templates
|
||||
if collections:
|
||||
first = collections[0]
|
||||
ctx.update({
|
||||
"collection_name": first["name"],
|
||||
"album_name": first["name"],
|
||||
"public_url": first.get("public_url", ""),
|
||||
"album_url": first.get("url", ""),
|
||||
"collection_name": first["name"], "album_name": first["name"],
|
||||
"public_url": first.get("public_url", ""), "album_url": first.get("url", ""),
|
||||
"shared": first.get("shared", False),
|
||||
"photo_count": first.get("photo_count", 0),
|
||||
"video_count": first.get("video_count", 0),
|
||||
"photo_count": first.get("photo_count", 0), "video_count": first.get("video_count", 0),
|
||||
"owner": first.get("owner", ""),
|
||||
})
|
||||
else:
|
||||
@@ -313,8 +328,7 @@ async def _build_real_context(
|
||||
"shared": False, "photo_count": 0, "video_count": 0, "owner": "",
|
||||
})
|
||||
|
||||
# People across all assets
|
||||
people = set()
|
||||
people: set[str] = set()
|
||||
for a in all_assets:
|
||||
people.update(a.get("people", []))
|
||||
ctx["people"] = list(people)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Sample template context for previews and test notifications."""
|
||||
|
||||
# Sample asset matching what build_asset_detail() actually returns
|
||||
_SAMPLE_ASSET = {
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"filename": "IMG_001.jpg",
|
||||
"type": "IMAGE",
|
||||
"created_at": "2026-03-19T10:30:00",
|
||||
"owner": "Alice",
|
||||
"owner_id": "user-uuid-1",
|
||||
"description": "Family picnic",
|
||||
"people": ["Alice", "Bob"],
|
||||
"is_favorite": True,
|
||||
"rating": 5,
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"city": "Paris",
|
||||
"state": "Ile-de-France",
|
||||
"country": "France",
|
||||
"url": "https://immich.example.com/photos/abc123",
|
||||
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"download_url": "https://immich.example.com/api/assets/abc123/original",
|
||||
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
|
||||
}
|
||||
|
||||
_SAMPLE_VIDEO_ASSET = {
|
||||
**_SAMPLE_ASSET,
|
||||
"id": "d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"filename": "VID_002.mp4",
|
||||
"type": "VIDEO",
|
||||
"is_favorite": False,
|
||||
"rating": None,
|
||||
"photo_url": None,
|
||||
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"playback_url": "https://immich.example.com/api/assets/def456/video",
|
||||
}
|
||||
|
||||
_SAMPLE_COLLECTION = {
|
||||
"name": "Family Photos",
|
||||
"url": "https://immich.example.com/share/abc123",
|
||||
"public_url": "https://immich.example.com/share/abc123",
|
||||
"asset_count": 42,
|
||||
"shared": True,
|
||||
}
|
||||
|
||||
# Full context covering ALL possible template variables
|
||||
_SAMPLE_CONTEXT = {
|
||||
# Core event fields (always present)
|
||||
"collection_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
|
||||
"collection_name": "Family Photos",
|
||||
"collection_url": "https://immich.example.com/share/abc123",
|
||||
"event_type": "assets_added",
|
||||
"timestamp": "2026-03-19T10:30:00+00:00",
|
||||
"service_name": "Immich",
|
||||
"service_type": "immich",
|
||||
# Immich aliases (always present alongside collection_*)
|
||||
"album_name": "Family Photos",
|
||||
"album_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
|
||||
"old_album_name": "Old Album",
|
||||
"new_album_name": "New Album",
|
||||
"change_type": "assets_added",
|
||||
"added_count": 3,
|
||||
"removed_count": 1,
|
||||
"added_assets": [_SAMPLE_ASSET, _SAMPLE_VIDEO_ASSET],
|
||||
"removed_assets": ["asset-id-1", "asset-id-2"],
|
||||
"people": ["Alice", "Bob"],
|
||||
"shared": True,
|
||||
"target_type": "telegram",
|
||||
"has_videos": True,
|
||||
"has_photos": True,
|
||||
# Rename fields (always present, empty for non-rename events)
|
||||
"old_name": "Old Album",
|
||||
"new_name": "New Album",
|
||||
"old_shared": False,
|
||||
"new_shared": True,
|
||||
# Public share URLs (may be empty if no shared link exists)
|
||||
"public_url": "https://immich.example.com/share/abc123",
|
||||
"protected_url": "",
|
||||
"album_url": "https://immich.example.com/albums/b2eeeaa4",
|
||||
# Common date/location (set when all assets share the same value)
|
||||
"common_date": "19.03.2026",
|
||||
"common_location": "Paris, France",
|
||||
# Date format strings (from template config)
|
||||
"date_format": "%d.%m.%Y, %H:%M UTC",
|
||||
"date_only_format": "%d.%m.%Y",
|
||||
# Scheduled/periodic variables (for those templates)
|
||||
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"albums": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/abc123/photos/x1y2z3"}],
|
||||
"date": "2026-03-19",
|
||||
"photo_count": 30,
|
||||
"video_count": 5,
|
||||
"owner": "Alice",
|
||||
}
|
||||
@@ -26,6 +26,10 @@ 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()
|
||||
|
||||
|
||||
async def _load_tracker_jobs() -> None:
|
||||
"""Load enabled trackers and schedule polling jobs."""
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Telegram service utilities — chat persistence helpers."""
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..database.models import TelegramChat
|
||||
|
||||
|
||||
async def save_chat_from_webhook(
|
||||
session: AsyncSession, bot_id: int, chat_data: dict
|
||||
) -> None:
|
||||
"""Save or update a chat entry from an incoming webhook message.
|
||||
|
||||
Called by the webhook handler to auto-persist chats.
|
||||
"""
|
||||
chat_id = str(chat_data.get("id", ""))
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
result = await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)
|
||||
existing = result.first()
|
||||
|
||||
title = chat_data.get("title") or (
|
||||
chat_data.get("first_name", "") + (" " + chat_data.get("last_name", "")).strip()
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.title = title
|
||||
existing.username = chat_data.get("username", existing.username)
|
||||
session.add(existing)
|
||||
else:
|
||||
session.add(TelegramChat(
|
||||
bot_id=bot_id,
|
||||
chat_id=chat_id,
|
||||
title=title,
|
||||
chat_type=chat_data.get("type", "private"),
|
||||
username=chat_data.get("username", ""),
|
||||
))
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Telegram long-polling service for bots in polling mode.
|
||||
|
||||
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).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from sqlmodel import select
|
||||
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 ..services.telegram import save_chat_from_webhook
|
||||
from .scheduler import get_scheduler
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Track last update_id per bot to use as offset
|
||||
_last_update_id: dict[int, int] = {}
|
||||
|
||||
|
||||
async def start_bot_polling() -> None:
|
||||
"""Schedule polling jobs for all bots with update_mode == 'polling'."""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(TelegramBot).where(TelegramBot.update_mode == "polling")
|
||||
)
|
||||
bots = result.all()
|
||||
|
||||
for bot in bots:
|
||||
schedule_bot_polling(bot.id)
|
||||
|
||||
|
||||
def schedule_bot_polling(bot_id: int) -> None:
|
||||
"""Add a polling job for a bot (idempotent)."""
|
||||
scheduler = get_scheduler()
|
||||
job_id = f"telegram_poll_{bot_id}"
|
||||
if scheduler.get_job(job_id):
|
||||
return
|
||||
scheduler.add_job(
|
||||
_poll_bot,
|
||||
"interval",
|
||||
seconds=3,
|
||||
id=job_id,
|
||||
args=[bot_id],
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
)
|
||||
_LOGGER.info("Started polling for bot %d", bot_id)
|
||||
|
||||
|
||||
def unschedule_bot_polling(bot_id: int) -> None:
|
||||
"""Remove polling job for a bot."""
|
||||
scheduler = get_scheduler()
|
||||
job_id = f"telegram_poll_{bot_id}"
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
_LOGGER.info("Stopped polling for bot %d", bot_id)
|
||||
|
||||
|
||||
async def _poll_bot(bot_id: int) -> None:
|
||||
"""Fetch updates from Telegram and process them."""
|
||||
engine = get_engine()
|
||||
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
|
||||
|
||||
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
|
||||
try:
|
||||
async with AsyncSession(engine) as save_session:
|
||||
await save_chat_from_webhook(save_session, bot.id, chat_info)
|
||||
await save_session.commit()
|
||||
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)
|
||||
|
||||
|
||||
async def _send_reply(bot_token: str, chat_id: str, text: str) -> None:
|
||||
"""Send a text reply via Telegram Bot API."""
|
||||
async with aiohttp.ClientSession() as http:
|
||||
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMessage"
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
||||
try:
|
||||
async with http.post(url, json=payload) as resp:
|
||||
if resp.status != 200:
|
||||
result = await resp.json()
|
||||
if "parse" in str(result.get("description", "")).lower():
|
||||
payload.pop("parse_mode", None)
|
||||
await http.post(url, json=payload)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Failed to send Telegram reply: %s", err)
|
||||
@@ -12,7 +12,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
from notify_bridge_core.notifications.telegram.cache import TelegramFileCache
|
||||
from notify_bridge_core.storage import JsonFileBackend
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import (
|
||||
@@ -28,6 +29,29 @@ from ..database.models import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Module-level Telegram file caches — shared across dispatches for reuse
|
||||
_url_cache: TelegramFileCache | None = None
|
||||
_asset_cache: TelegramFileCache | None = None
|
||||
|
||||
|
||||
async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFileCache | None]:
|
||||
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR."""
|
||||
global _url_cache, _asset_cache
|
||||
if _url_cache is not None:
|
||||
return _url_cache, _asset_cache
|
||||
import os
|
||||
from pathlib import Path
|
||||
data_dir = os.environ.get("NOTIFY_BRIDGE_DATA_DIR")
|
||||
if not data_dir:
|
||||
return None, None
|
||||
cache_dir = Path(data_dir) / "cache"
|
||||
_url_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_url_cache.json"))
|
||||
_asset_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_asset_cache.json"))
|
||||
await _url_cache.async_load()
|
||||
await _asset_cache.async_load()
|
||||
_LOGGER.info("Initialized Telegram file caches in %s", cache_dir)
|
||||
return _url_cache, _asset_cache
|
||||
|
||||
|
||||
def _in_quiet_hours(start: str | None, end: str | None) -> bool:
|
||||
"""Check if the current UTC time is within the quiet hours window."""
|
||||
@@ -131,6 +155,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
new_state: dict[str, Any] = {}
|
||||
|
||||
if provider_type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
@@ -208,7 +233,8 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
if events and link_data:
|
||||
dispatcher = NotificationDispatcher()
|
||||
url_cache, asset_cache = await _get_telegram_caches()
|
||||
dispatcher = NotificationDispatcher(url_cache=url_cache, asset_cache=asset_cache)
|
||||
for event in events:
|
||||
_LOGGER.info(
|
||||
"Dispatching event %s for %s (added=%d removed=%d)",
|
||||
@@ -239,7 +265,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
config=ld["target_config"],
|
||||
template_slots=slots,
|
||||
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
|
||||
date_only_format=tmpl.date_only_format if tmpl and hasattr(tmpl, "date_only_format") else "%d.%m.%Y",
|
||||
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
|
||||
provider_api_key=provider_config.get("api_key"),
|
||||
provider_internal_url=provider_config.get("url", ""),
|
||||
provider_external_url=provider_config.get("external_domain", ""),
|
||||
|
||||
Reference in New Issue
Block a user