Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 349e9136a4 | |||
| 04c8e3c8b2 | |||
| 9afd38e50e | |||
| aa9548d884 | |||
| 72dd611f8c | |||
| 0e675c4b38 | |||
| 4307955163 | |||
| b107b01a00 | |||
| 42af7a6551 |
@@ -1,8 +1,8 @@
|
|||||||
# Entity Relationships
|
# Entity Relationships
|
||||||
|
|
||||||
```
|
```text
|
||||||
ServiceProvider → type: "immich" (inferred capabilities: notifications, commands)
|
ServiceProvider → type: "immich" (inferred capabilities: notifications, commands)
|
||||||
NotificationTracker → provider_id, collection_ids, scan_interval, batch_duration, enabled
|
NotificationTracker → provider_id, collection_ids, scan_interval, adaptive_max_skip, filters, default_tracking_config_id, default_template_config_id, enabled
|
||||||
NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled
|
NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled
|
||||||
TrackingConfig → provider_type, event flags, scheduling rules
|
TrackingConfig → provider_type, event flags, scheduling rules
|
||||||
TemplateConfig → provider_type, Jinja2 template slots per event type
|
TemplateConfig → provider_type, Jinja2 template slots per event type
|
||||||
|
|||||||
+22
-8
@@ -1,18 +1,32 @@
|
|||||||
# v0.6.2 (2026-04-27)
|
# v0.6.5 (2026-04-28)
|
||||||
|
|
||||||
Polishing pass on locale and timezone pickers in the redesigned UI: editors and selectors now use the same `EntitySelect` palette pattern, and the timezone dropdown is portalled to escape Card clipping.
|
UI polish across the redesign: command-template editing now groups slots into four labelled fieldsets that mirror the notification-template page, modal/popup scrolling no longer drags the page underneath, and Telegram's Discover Chats keeps the existing list visible with a smooth shimmer instead of blanking it to "Loading…".
|
||||||
|
|
||||||
## User-facing changes
|
## User-facing changes
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Command template slots grouped into 4 fieldsets:** the command-template configs page now mirrors the notification-template layout, splitting slots by name prefix into Command Responses, Error Messages (`rate_limited` / `no_results`), Command Descriptions (`desc_*`), and Usage Examples (`usage_*`). The language picker, reset-all button, and slot filter are hoisted above the groups so they apply across all fieldsets, and empty groups are hidden so providers without `usage_*` slots don't render an empty header ([04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c))
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Template editors (notification & command) now use `EntitySelect` for locale switching and default to the configured **primary locale** when opening, editing, or cloning a config (previously always defaulted to `en`) ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
|
- **Modal scroll chaining contained:** scrolling past the inner boundary of a modal or popup no longer scrolls the page underneath. `overscroll-behavior: contain` was added to every in-modal/popup scroll container — Modal body, `EntitySelect`, `MultiEntitySelect`, `IconPicker`, `IconGridSelect`, `SearchPalette`, and `TimezoneSelector` ([9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e))
|
||||||
- `LocaleSelector` add-flow now uses `EntitySelect` for catalog pick; custom BCP-47 codes (e.g. `de-CH`) keep a small dedicated input ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
|
- **Smoother Telegram Discover Chats refresh:** Discover Chats no longer collapses the existing chat list into a "Loading…" placeholder. The initial-load state (`chatsLoading`) is now split from the refresh state (`chatsRefreshing`); rows are keyed by `chat.id` with flip+fade animations, the list dims with a sweeping shimmer while the Discover button shows a spinning icon and a "Discovering chats…" label. Honors `prefers-reduced-motion` ([9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e))
|
||||||
- `TimezoneSelector` dropdown was being clipped by Card's `overflow: hidden` and `backdrop-filter`; portalled to `<body>` with an overlay backdrop and styled as a centered modal palette (same pattern as `EntitySelect`) ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
|
|
||||||
- Removed top padding on the timezone scroll list so sticky region group headers no longer leak rows above them ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
|
|
||||||
|
|
||||||
## Development / Internal
|
## Development / Internal
|
||||||
|
|
||||||
### Refactoring
|
### i18n
|
||||||
|
|
||||||
- Extracted shared locale catalog to `frontend/src/lib/locales.ts` for reuse across selectors ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
|
- Drop orphan `cmdTemplateConfig.commandResponsesHint` key — `hints.commandResponses` replaces it ([04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
|
| Hash | Message | Author |
|
||||||
|
|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|------------------|
|
||||||
|
| [04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c) | feat(frontend): group command template slots into 4 logical fieldsets | alexei.dolgolyov |
|
||||||
|
| [9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e) | fix(redesign): contain modal scroll chaining and smooth Telegram chat refresh | alexei.dolgolyov |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.2",
|
"version": "0.6.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
// Ambient type declarations for SvelteKit + project-level build-time globals.
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
/** App version, injected from frontend/package.json at build time. */
|
||||||
|
const __APP_VERSION__: string;
|
||||||
|
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -5,6 +5,23 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Notify Bridge</title>
|
<title>Notify Bridge</title>
|
||||||
|
<script>
|
||||||
|
// Resolve theme before first paint to avoid dark→light FOUC on hard reload.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('theme');
|
||||||
|
var resolved =
|
||||||
|
saved === 'light' || saved === 'dark'
|
||||||
|
? saved
|
||||||
|
: window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
|
} catch (_) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -304,6 +304,7 @@
|
|||||||
/* List */
|
/* List */
|
||||||
.ep-list {
|
.ep-list {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
padding: 0.35rem;
|
padding: 0.35rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
:global([data-theme="light"]) .icon-grid-popup { --igs-solid-bg: #fafafe; }
|
:global([data-theme="light"]) .icon-grid-popup { --igs-solid-bg: #fafafe; }
|
||||||
|
|||||||
@@ -188,6 +188,7 @@
|
|||||||
max-height: 14rem;
|
max-height: 14rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|||||||
@@ -192,6 +192,7 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 0 1.5rem 1.5rem;
|
padding: 0 1.5rem 1.5rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close {
|
.modal-close {
|
||||||
|
|||||||
@@ -307,6 +307,7 @@
|
|||||||
|
|
||||||
.mes-list {
|
.mes-list {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,6 +342,7 @@
|
|||||||
.sp-results {
|
.sp-results {
|
||||||
max-height: 52vh;
|
max-height: 52vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
padding: 0.35rem;
|
padding: 0.35rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -530,6 +530,7 @@
|
|||||||
|
|
||||||
.tz-list {
|
.tz-list {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
/* No top padding — the sticky group head is at top:0 of the
|
/* No top padding — the sticky group head is at top:0 of the
|
||||||
scroll container, so any padding-top would let scrolling
|
scroll container, so any padding-top would let scrolling
|
||||||
items leak into the gap above the sticky header. */
|
items leak into the gap above the sticky header. */
|
||||||
|
|||||||
@@ -246,6 +246,9 @@
|
|||||||
"selectAlbums": "Select albums...",
|
"selectAlbums": "Select albums...",
|
||||||
"repositories": "Repositories",
|
"repositories": "Repositories",
|
||||||
"selectRepositories": "Select repositories...",
|
"selectRepositories": "Select repositories...",
|
||||||
|
"userAllowlist": "Only from users",
|
||||||
|
"userBlocklist": "Exclude users",
|
||||||
|
"selectUsers": "Pick users...",
|
||||||
"boards": "Boards",
|
"boards": "Boards",
|
||||||
"selectBoards": "Select boards...",
|
"selectBoards": "Select boards...",
|
||||||
"upsDevices": "UPS Devices",
|
"upsDevices": "UPS Devices",
|
||||||
@@ -472,6 +475,7 @@
|
|||||||
"noCommandsForProvider": "This provider type does not support bot commands.",
|
"noCommandsForProvider": "This provider type does not support bot commands.",
|
||||||
"syncCommands": "Sync Commands",
|
"syncCommands": "Sync Commands",
|
||||||
"discoverChats": "Discover chats from Telegram",
|
"discoverChats": "Discover chats from Telegram",
|
||||||
|
"discoveringChats": "Discovering chats…",
|
||||||
"clickToCopy": "Click to copy chat ID",
|
"clickToCopy": "Click to copy chat ID",
|
||||||
"chatsDiscovered": "Chats discovered",
|
"chatsDiscovered": "Chats discovered",
|
||||||
"chatDeleted": "Chat removed",
|
"chatDeleted": "Chat removed",
|
||||||
@@ -819,7 +823,11 @@
|
|||||||
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
|
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
|
||||||
"responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.",
|
"responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.",
|
||||||
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
|
"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."
|
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit.",
|
||||||
|
"commandResponses": "Reply templates for each /command. Use {variables} to inject dynamic data.",
|
||||||
|
"commandErrors": "Fallback messages shown when a command can't run (rate-limited) or returns nothing.",
|
||||||
|
"commandDescriptions": "Short menu blurbs Telegram shows next to each /command in the chat command picker.",
|
||||||
|
"commandUsage": "Example invocations rendered inside /help to show users how to call each command."
|
||||||
},
|
},
|
||||||
"matrixBot": {
|
"matrixBot": {
|
||||||
"titleEmphasis": "matrix",
|
"titleEmphasis": "matrix",
|
||||||
@@ -872,7 +880,9 @@
|
|||||||
"noConfigs": "No command template configs yet.",
|
"noConfigs": "No command template configs yet.",
|
||||||
"confirmDelete": "Delete this command template config?",
|
"confirmDelete": "Delete this command template config?",
|
||||||
"commandResponses": "Command Responses",
|
"commandResponses": "Command Responses",
|
||||||
"commandResponsesHint": "Leave a slot empty to use the default hardcoded response."
|
"commandErrors": "Error Messages",
|
||||||
|
"commandDescriptions": "Command Descriptions",
|
||||||
|
"commandUsage": "Usage Examples"
|
||||||
},
|
},
|
||||||
"commandConfig": {
|
"commandConfig": {
|
||||||
"titleEmphasis": "configs",
|
"titleEmphasis": "configs",
|
||||||
|
|||||||
@@ -246,6 +246,9 @@
|
|||||||
"selectAlbums": "Выберите альбомы...",
|
"selectAlbums": "Выберите альбомы...",
|
||||||
"repositories": "Репозитории",
|
"repositories": "Репозитории",
|
||||||
"selectRepositories": "Выберите репозитории...",
|
"selectRepositories": "Выберите репозитории...",
|
||||||
|
"userAllowlist": "Только от пользователей",
|
||||||
|
"userBlocklist": "Исключить пользователей",
|
||||||
|
"selectUsers": "Выберите пользователей...",
|
||||||
"boards": "Доски",
|
"boards": "Доски",
|
||||||
"selectBoards": "Выберите доски...",
|
"selectBoards": "Выберите доски...",
|
||||||
"upsDevices": "ИБП устройства",
|
"upsDevices": "ИБП устройства",
|
||||||
@@ -472,6 +475,7 @@
|
|||||||
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
|
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
|
||||||
"syncCommands": "Синхр. команды",
|
"syncCommands": "Синхр. команды",
|
||||||
"discoverChats": "Обнаружить чаты из Telegram",
|
"discoverChats": "Обнаружить чаты из Telegram",
|
||||||
|
"discoveringChats": "Поиск чатов…",
|
||||||
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
||||||
"chatsDiscovered": "Чаты обнаружены",
|
"chatsDiscovered": "Чаты обнаружены",
|
||||||
"chatDeleted": "Чат удалён",
|
"chatDeleted": "Чат удалён",
|
||||||
@@ -819,7 +823,11 @@
|
|||||||
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
|
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
|
||||||
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
|
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
|
||||||
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
||||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений.",
|
||||||
|
"commandResponses": "Шаблоны ответов на каждую /команду. Используйте {переменные} для динамических данных.",
|
||||||
|
"commandErrors": "Резервные сообщения, когда команда не может выполниться (превышен лимит) или ничего не возвращает.",
|
||||||
|
"commandDescriptions": "Короткие подписи в меню команд Telegram, которые показываются рядом с каждой /командой.",
|
||||||
|
"commandUsage": "Примеры вызовов, отображаемые в /help, чтобы показать пользователям как вызывать каждую команду."
|
||||||
},
|
},
|
||||||
"matrixBot": {
|
"matrixBot": {
|
||||||
"titleEmphasis": "matrix",
|
"titleEmphasis": "matrix",
|
||||||
@@ -872,7 +880,9 @@
|
|||||||
"noConfigs": "Шаблонов команд пока нет.",
|
"noConfigs": "Шаблонов команд пока нет.",
|
||||||
"confirmDelete": "Удалить этот шаблон команд?",
|
"confirmDelete": "Удалить этот шаблон команд?",
|
||||||
"commandResponses": "Ответы команд",
|
"commandResponses": "Ответы команд",
|
||||||
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
|
"commandErrors": "Сообщения об ошибках",
|
||||||
|
"commandDescriptions": "Описания команд",
|
||||||
|
"commandUsage": "Примеры использования"
|
||||||
},
|
},
|
||||||
"commandConfig": {
|
"commandConfig": {
|
||||||
"titleEmphasis": "конфигурации",
|
"titleEmphasis": "конфигурации",
|
||||||
|
|||||||
@@ -56,5 +56,20 @@ export const giteaDescriptor: ProviderDescriptor = {
|
|||||||
desc: () => '',
|
desc: () => '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
userFilters: [
|
||||||
|
{
|
||||||
|
key: 'senders',
|
||||||
|
label: 'notificationTracker.userAllowlist',
|
||||||
|
placeholder: 'notificationTracker.selectUsers',
|
||||||
|
icon: 'mdiAccountCheck',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'exclude_senders',
|
||||||
|
label: 'notificationTracker.userBlocklist',
|
||||||
|
placeholder: 'notificationTracker.selectUsers',
|
||||||
|
icon: 'mdiAccountOff',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
webhookUrlPattern: '/api/webhooks/gitea/{token}',
|
webhookUrlPattern: '/api/webhooks/gitea/{token}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -120,6 +120,25 @@ export interface CollectionMeta {
|
|||||||
desc: (col: any) => string;
|
desc: (col: any) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares a filter that picks user identities from the provider's known
|
||||||
|
* senders. Rendered as a MultiEntitySelect populated from the provider's
|
||||||
|
* `/users` endpoint. The picked values are stored as `string[]` under
|
||||||
|
* `tracker.filters[key]`.
|
||||||
|
*/
|
||||||
|
export interface UserFilterMeta {
|
||||||
|
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
|
||||||
|
key: string;
|
||||||
|
/** i18n key for the label rendered above the picker. */
|
||||||
|
label: string;
|
||||||
|
/** i18n key for the picker placeholder. */
|
||||||
|
placeholder: string;
|
||||||
|
/** MDI icon shown on chips and dropdown rows. */
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main descriptor ──────────────────────────────────────────────────
|
// ── Main descriptor ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ProviderDescriptor {
|
export interface ProviderDescriptor {
|
||||||
@@ -153,6 +172,8 @@ export interface ProviderDescriptor {
|
|||||||
// ── Collections / Trackers ──
|
// ── Collections / Trackers ──
|
||||||
/** Null means this provider has no collections (e.g. scheduler). */
|
/** Null means this provider has no collections (e.g. scheduler). */
|
||||||
collectionMeta: CollectionMeta | null;
|
collectionMeta: CollectionMeta | null;
|
||||||
|
/** Sender allowlist / blocklist pickers shown on the tracker form. */
|
||||||
|
userFilters?: UserFilterMeta[];
|
||||||
/** Whether this provider is webhook-based (hides scan_interval). */
|
/** Whether this provider is webhook-based (hides scan_interval). */
|
||||||
webhookBased?: boolean;
|
webhookBased?: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export interface NotificationTarget {
|
|||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
|
chat_action?: string | null;
|
||||||
chat_name?: string;
|
chat_name?: string;
|
||||||
receiver_count: number;
|
receiver_count: number;
|
||||||
receivers: TargetReceiver[];
|
receivers: TargetReceiver[];
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
||||||
let _syncingFilter = false;
|
let _syncingFilter = false;
|
||||||
|
|
||||||
|
// Reserve the provider-filter row from first paint until the cache resolves.
|
||||||
|
// Without this, the row appears mid-paint and pushes nav items down on every
|
||||||
|
// hard reload — the most visible "jump" the user reported.
|
||||||
|
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
|
||||||
|
|
||||||
// Sync filter value → store
|
// Sync filter value → store
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const v = providerFilterValue;
|
const v = providerFilterValue;
|
||||||
@@ -78,7 +83,24 @@
|
|||||||
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
let collapsed = $state(false);
|
// Read persisted UI state synchronously so first paint already matches the
|
||||||
|
// user's last session — otherwise the sidebar visibly snaps from expanded
|
||||||
|
// to collapsed (and groups slide open) right after mount.
|
||||||
|
function readPersistedCollapsed(): boolean {
|
||||||
|
if (typeof localStorage === 'undefined') return false;
|
||||||
|
return localStorage.getItem('sidebar_collapsed') === 'true';
|
||||||
|
}
|
||||||
|
function readPersistedExpandedGroups(): Record<string, boolean> {
|
||||||
|
if (typeof localStorage === 'undefined') return {};
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('nav_expanded');
|
||||||
|
return saved ? JSON.parse(saved) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let collapsed = $state(readPersistedCollapsed());
|
||||||
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
|
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
|
||||||
|
|
||||||
// Nav counts — computed reactively from caches + global provider filter
|
// Nav counts — computed reactively from caches + global provider filter
|
||||||
@@ -216,7 +238,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Track which groups are expanded (persisted in localStorage)
|
// Track which groups are expanded (persisted in localStorage)
|
||||||
let expandedGroups = $state<Record<string, boolean>>({});
|
let expandedGroups = $state<Record<string, boolean>>(readPersistedExpandedGroups());
|
||||||
|
|
||||||
function toggleGroup(key: string) {
|
function toggleGroup(key: string) {
|
||||||
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
|
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
|
||||||
@@ -262,13 +284,8 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
initTheme();
|
initTheme();
|
||||||
if (typeof localStorage !== 'undefined') {
|
// `collapsed` and `expandedGroups` are now hydrated synchronously in
|
||||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
// their $state initializers above to avoid a post-mount layout snap.
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem('nav_expanded');
|
|
||||||
if (saved) expandedGroups = JSON.parse(saved);
|
|
||||||
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
|
|
||||||
}
|
|
||||||
await loadUser();
|
await loadUser();
|
||||||
if (!auth.user && !isAuthPage) {
|
if (!auth.user && !isAuthPage) {
|
||||||
redirecting = true;
|
redirecting = true;
|
||||||
@@ -384,7 +401,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
Notify Bridge
|
Notify Bridge
|
||||||
</h1>
|
</h1>
|
||||||
<p class="brand-version font-mono">v0.5.2</p>
|
<p class="brand-version font-mono">v{__APP_VERSION__}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -398,8 +415,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global provider filter -->
|
<!-- Global provider filter — kept rendered during the initial cache
|
||||||
{#if allProviders.length >= 1}
|
fetch (fetchedAt === 0) so the row doesn't pop in mid-paint and
|
||||||
|
push the nav down. Hides only once we confirm zero providers. -->
|
||||||
|
{#if showProviderFilter}
|
||||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<button onclick={() => {
|
<button onclick={() => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { api, parseDate } from '$lib/api';
|
import { api, parseDate } from '$lib/api';
|
||||||
|
import { requestHighlight } from '$lib/highlight';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import {
|
import {
|
||||||
providersCache,
|
providersCache,
|
||||||
@@ -320,6 +322,19 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function scrollToEvents(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = document.getElementById('events-section');
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotoProvider(e: MouseEvent, providerId: number) {
|
||||||
|
e.preventDefault();
|
||||||
|
requestHighlight(providerId);
|
||||||
|
goto('/providers');
|
||||||
|
}
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
const diff = Date.now() - parseDate(dateStr).getTime();
|
const diff = Date.now() - parseDate(dateStr).getTime();
|
||||||
const mins = Math.floor(diff / 60000);
|
const mins = Math.floor(diff / 60000);
|
||||||
@@ -424,8 +439,8 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ==================== STATS ==================== -->
|
<!-- ==================== STATS ==================== -->
|
||||||
{#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string}, idx: number)}
|
{#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string; href: string; onclick?: (e: MouseEvent) => void}, idx: number)}
|
||||||
<div class="stat-card" style="--accent: {card.accent}">
|
<a class="stat-card" style="--accent: {card.accent}" href={card.href} onclick={card.onclick}>
|
||||||
<div class="stat-card-inner">
|
<div class="stat-card-inner">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="stat-icon" style="color: {card.accent};">
|
<div class="stat-icon" style="color: {card.accent};">
|
||||||
@@ -439,7 +454,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet statCards()}
|
{#snippet statCards()}
|
||||||
@@ -452,6 +467,7 @@
|
|||||||
value: 0,
|
value: 0,
|
||||||
literalValue: globalProviderFilter.provider.name,
|
literalValue: globalProviderFilter.provider.name,
|
||||||
accent: STAT_ACCENTS[0],
|
accent: STAT_ACCENTS[0],
|
||||||
|
href: '/providers',
|
||||||
}, 0)}
|
}, 0)}
|
||||||
{:else}
|
{:else}
|
||||||
{@render statCardSnippet({
|
{@render statCardSnippet({
|
||||||
@@ -459,6 +475,7 @@
|
|||||||
label: 'dashboard.providers',
|
label: 'dashboard.providers',
|
||||||
value: filteredProviderCount,
|
value: filteredProviderCount,
|
||||||
accent: STAT_ACCENTS[0],
|
accent: STAT_ACCENTS[0],
|
||||||
|
href: '/providers',
|
||||||
}, 0)}
|
}, 0)}
|
||||||
{/if}
|
{/if}
|
||||||
{@render statCardSnippet({
|
{@render statCardSnippet({
|
||||||
@@ -467,12 +484,14 @@
|
|||||||
value: displayActive,
|
value: displayActive,
|
||||||
suffix: ` / ${displayTotal}`,
|
suffix: ` / ${displayTotal}`,
|
||||||
accent: STAT_ACCENTS[1],
|
accent: STAT_ACCENTS[1],
|
||||||
|
href: '/notification-trackers',
|
||||||
}, 1)}
|
}, 1)}
|
||||||
{@render statCardSnippet({
|
{@render statCardSnippet({
|
||||||
icon: 'mdiTarget',
|
icon: 'mdiTarget',
|
||||||
label: 'dashboard.targets',
|
label: 'dashboard.targets',
|
||||||
value: displayTargets,
|
value: displayTargets,
|
||||||
accent: STAT_ACCENTS[2],
|
accent: STAT_ACCENTS[2],
|
||||||
|
href: '/targets',
|
||||||
}, 2)}
|
}, 2)}
|
||||||
{#if status?.command_trackers !== undefined}
|
{#if status?.command_trackers !== undefined}
|
||||||
{@render statCardSnippet({
|
{@render statCardSnippet({
|
||||||
@@ -480,6 +499,7 @@
|
|||||||
label: 'nav.commandTrackers',
|
label: 'nav.commandTrackers',
|
||||||
value: displayCommandTrackers,
|
value: displayCommandTrackers,
|
||||||
accent: STAT_ACCENTS[3],
|
accent: STAT_ACCENTS[3],
|
||||||
|
href: '/command-trackers',
|
||||||
}, 3)}
|
}, 3)}
|
||||||
{:else}
|
{:else}
|
||||||
{@render statCardSnippet({
|
{@render statCardSnippet({
|
||||||
@@ -487,6 +507,8 @@
|
|||||||
label: 'dashboard.eventsTotal',
|
label: 'dashboard.eventsTotal',
|
||||||
value: heroSummary?.throughput ?? 0,
|
value: heroSummary?.throughput ?? 0,
|
||||||
accent: STAT_ACCENTS[3],
|
accent: STAT_ACCENTS[3],
|
||||||
|
href: '#events-section',
|
||||||
|
onclick: scrollToEvents,
|
||||||
}, 3)}
|
}, 3)}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -496,7 +518,7 @@
|
|||||||
<!-- ==================== TWO COL: stream + provider deck ==================== -->
|
<!-- ==================== TWO COL: stream + provider deck ==================== -->
|
||||||
<div class="two-col" class:two-col--single={!!globalProviderFilter.id}>
|
<div class="two-col" class:two-col--single={!!globalProviderFilter.id}>
|
||||||
<!-- Signal stream -->
|
<!-- Signal stream -->
|
||||||
<section class="panel">
|
<section class="panel" id="events-section">
|
||||||
<header class="panel-head">
|
<header class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="panel-title">{t('dashboard.streamTitle')} <em>{t('dashboard.streamEmphasis')}</em></h2>
|
<h2 class="panel-title">{t('dashboard.streamTitle')} <em>{t('dashboard.streamEmphasis')}</em></h2>
|
||||||
@@ -646,14 +668,14 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="provider-deck">
|
<div class="provider-deck">
|
||||||
{#each providerDeck as p}
|
{#each providerDeck as p}
|
||||||
<a href="/providers" class="provider-row" style="--accent: {STAT_ACCENTS[p.id % STAT_ACCENTS.length]}">
|
<a href="/providers" class="provider-row" style="--accent: {STAT_ACCENTS[p.id % STAT_ACCENTS.length]}" onclick={(e) => gotoProvider(e, p.id)}>
|
||||||
<div class="provider-icon">
|
<div class="provider-icon">
|
||||||
<MdiIcon name={p.icon} size={20} />
|
<MdiIcon name={p.icon} size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="provider-name truncate">
|
<div class="provider-name">
|
||||||
{#if p.armedCount > 0}<span class="aurora-pulse"></span>{:else}<span class="aurora-pulse idle"></span>{/if}
|
{#if p.armedCount > 0}<span class="aurora-pulse"></span>{:else}<span class="aurora-pulse idle"></span>{/if}
|
||||||
{p.name}
|
<span class="truncate min-w-0">{p.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="provider-sub font-mono">
|
<div class="provider-sub font-mono">
|
||||||
{p.descriptor?.defaultName ?? p.type} · {p.trackerCount} {t('dashboard.trackersShort')}
|
{p.descriptor?.defaultName ?? p.type} · {p.trackerCount} {t('dashboard.trackersShort')}
|
||||||
@@ -909,6 +931,7 @@
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
.stat-card {
|
.stat-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: block;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
background: var(--color-glass);
|
background: var(--color-glass);
|
||||||
backdrop-filter: blur(28px) saturate(160%);
|
backdrop-filter: blur(28px) saturate(160%);
|
||||||
@@ -918,7 +941,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 0.25s cubic-bezier(.4,.4,0,1);
|
transition: transform 0.25s cubic-bezier(.4,.4,0,1);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
.stat-card:hover { text-decoration: none; }
|
||||||
.stat-card::before {
|
.stat-card::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1282,6 +1308,7 @@
|
|||||||
.provider-meter {
|
.provider-meter {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
|
padding: 4px 4px 4px 0;
|
||||||
}
|
}
|
||||||
.provider-num {
|
.provider-num {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -1295,7 +1322,6 @@
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--color-glass-strong);
|
background: var(--color-glass-strong);
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.provider-bar-fill {
|
.provider-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { slide } from 'svelte/transition';
|
import { slide, fade } from 'svelte/transition';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
@@ -35,6 +36,10 @@
|
|||||||
// Per-bot expandable sections
|
// Per-bot expandable sections
|
||||||
let chats = $state<Record<number, TelegramChat[]>>({});
|
let chats = $state<Record<number, TelegramChat[]>>({});
|
||||||
let chatsLoading = $state<Record<number, boolean>>({});
|
let chatsLoading = $state<Record<number, boolean>>({});
|
||||||
|
// Distinct from chatsLoading: refresh keeps the existing list visible
|
||||||
|
// instead of swapping it for a placeholder, avoiding the disorienting
|
||||||
|
// "everything disappears" flash during Discover.
|
||||||
|
let chatsRefreshing = $state<Record<number, boolean>>({});
|
||||||
let expandedSection = $state<Record<number, string>>({});
|
let expandedSection = $state<Record<number, string>>({});
|
||||||
|
|
||||||
// Webhook status per bot
|
// Webhook status per bot
|
||||||
@@ -98,12 +103,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function discoverChats(botId: number) {
|
async function discoverChats(botId: number) {
|
||||||
chatsLoading = { ...chatsLoading, [botId]: true };
|
if (chatsRefreshing[botId]) return;
|
||||||
|
chatsRefreshing = { ...chatsRefreshing, [botId]: true };
|
||||||
try {
|
try {
|
||||||
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
||||||
snackSuccess(t('telegramBot.chatsDiscovered'));
|
snackSuccess(t('telegramBot.chatsDiscovered'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: any) { snackError(err.message); }
|
||||||
chatsLoading = { ...chatsLoading, [botId]: false };
|
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteChat(botId: number, chatDbId: number) {
|
async function deleteChat(botId: number, chatDbId: number) {
|
||||||
@@ -371,12 +377,16 @@
|
|||||||
<!-- Chats section -->
|
<!-- Chats section -->
|
||||||
{#if expandedSection[bot.id] === 'chats'}
|
{#if expandedSection[bot.id] === 'chats'}
|
||||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
|
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
|
||||||
{#if chatsLoading[bot.id]}
|
{#if chatsLoading[bot.id] && !chats[bot.id]}
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||||
{:else if (chats[bot.id] || []).length === 0}
|
{:else if (chats[bot.id] || []).length === 0 && !chatsRefreshing[bot.id]}
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
|
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
|
||||||
|
<div class="chat-list-wrap" class:is-refreshing={chatsRefreshing[bot.id]}>
|
||||||
|
{#if chatsRefreshing[bot.id]}
|
||||||
|
<div class="chat-shimmer" aria-hidden="true" transition:fade={{ duration: 180 }}></div>
|
||||||
|
{/if}
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
||||||
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
||||||
@@ -389,9 +399,12 @@
|
|||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Rows -->
|
<!-- Rows -->
|
||||||
{#each chats[bot.id] as chat}
|
{#each (chats[bot.id] || []) as chat (chat.id)}
|
||||||
<div style={gridStyle}
|
<div style={gridStyle}
|
||||||
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
class="chat-row text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
||||||
|
animate:flip={{ duration: 280 }}
|
||||||
|
in:fade={{ duration: 220, delay: 60 }}
|
||||||
|
out:fade={{ duration: 140 }}
|
||||||
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
||||||
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
|
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
|
||||||
title={t('telegramBot.clickToCopy')}
|
title={t('telegramBot.clickToCopy')}
|
||||||
@@ -426,11 +439,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if chatsRefreshing[bot.id] && (chats[bot.id] || []).length === 0}
|
||||||
|
<p class="text-xs text-[var(--color-muted-foreground)] py-2 px-2">{t('telegramBot.discoveringChats')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button onclick={() => discoverChats(bot.id)}
|
<button onclick={() => discoverChats(bot.id)}
|
||||||
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
|
disabled={chatsRefreshing[bot.id]}
|
||||||
|
class="discover-btn text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1 disabled:opacity-70 disabled:cursor-default disabled:no-underline">
|
||||||
|
<span class="discover-icon" class:is-spinning={chatsRefreshing[bot.id]}>
|
||||||
<MdiIcon name="mdiSync" size={14} />
|
<MdiIcon name="mdiSync" size={14} />
|
||||||
{t('telegramBot.discoverChats')}
|
</span>
|
||||||
|
{chatsRefreshing[bot.id] ? t('telegramBot.discoveringChats') : t('telegramBot.discoverChats')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -553,3 +573,72 @@
|
|||||||
|
|
||||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Chat list — smooth refresh state.
|
||||||
|
The list stays mounted during Discover; we only dim it slightly
|
||||||
|
and run a thin shimmer bar across the top so the user sees
|
||||||
|
"refreshing" instead of "everything vanished and came back". */
|
||||||
|
.chat-list-wrap {
|
||||||
|
position: relative;
|
||||||
|
transition: opacity 0.25s ease, filter 0.25s ease;
|
||||||
|
}
|
||||||
|
.chat-list-wrap.is-refreshing {
|
||||||
|
opacity: 0.78;
|
||||||
|
filter: saturate(0.9);
|
||||||
|
}
|
||||||
|
.chat-list-wrap.is-refreshing .chat-row {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.chat-shimmer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.chat-shimmer::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
color-mix(in srgb, var(--color-primary) 70%, transparent) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: chat-shimmer-sweep 1.15s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes chat-shimmer-sweep {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.discover-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.discover-icon.is-spinning {
|
||||||
|
animation: discover-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes discover-spin {
|
||||||
|
to { transform: rotate(-360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.chat-shimmer::after,
|
||||||
|
.discover-icon.is-spinning {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.chat-list-wrap {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,27 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-pick the command-template config when the provider type changes.
|
||||||
|
// The previously-selected id may belong to a different provider type and
|
||||||
|
// would no longer appear in the filtered EntitySelect, leaving it empty.
|
||||||
|
let _prevProviderType = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && form.provider_type && form.provider_type !== _prevProviderType) {
|
||||||
|
_prevProviderType = form.provider_type;
|
||||||
|
if (editing === null) {
|
||||||
|
const currentTpl = cmdTemplateConfigs.find(
|
||||||
|
(c) => c.id === form.command_template_config_id,
|
||||||
|
);
|
||||||
|
if (!currentTpl || currentTpl.provider_type !== form.provider_type) {
|
||||||
|
const first = cmdTemplateConfigs.find(
|
||||||
|
(c) => c.provider_type === form.provider_type,
|
||||||
|
);
|
||||||
|
form.command_template_config_id = first?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
function editConfig(cfg: CommandConfig) {
|
function editConfig(cfg: CommandConfig) {
|
||||||
form = {
|
form = {
|
||||||
name: cfg.name,
|
name: cfg.name,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
||||||
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
|
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
|
||||||
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
|
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
|
||||||
import { getLocaleMeta } from '$lib/locales';
|
import { getLocaleMeta } from '$lib/locales';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
@@ -126,11 +127,40 @@
|
|||||||
let commandSlots = $derived<SlotDef[]>(
|
let commandSlots = $derived<SlotDef[]>(
|
||||||
allCapabilities[form.provider_type]?.command_slots || []
|
allCapabilities[form.provider_type]?.command_slots || []
|
||||||
);
|
);
|
||||||
let filteredCmdSlots = $derived(
|
|
||||||
slotFilter
|
const ERROR_SLOTS = new Set(['rate_limited', 'no_results']);
|
||||||
? commandSlots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase()))
|
|
||||||
: commandSlots
|
/**
|
||||||
);
|
* Group command slots by purpose so the form mirrors how notification
|
||||||
|
* templates are split (event vs scheduled vs settings).
|
||||||
|
*
|
||||||
|
* commandResponses — primary reply templates (/start, /help, /status, data slots)
|
||||||
|
* commandErrors — fallback messages (rate_limited, no_results)
|
||||||
|
* commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker
|
||||||
|
* commandUsage — usage_* slots: invocation examples shown by /help
|
||||||
|
*/
|
||||||
|
let commandSlotGroups = $derived([
|
||||||
|
{
|
||||||
|
group: 'commandResponses',
|
||||||
|
slots: commandSlots.filter(s =>
|
||||||
|
!s.name.startsWith('desc_') &&
|
||||||
|
!s.name.startsWith('usage_') &&
|
||||||
|
!ERROR_SLOTS.has(s.name)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'commandErrors',
|
||||||
|
slots: commandSlots.filter(s => ERROR_SLOTS.has(s.name)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'commandDescriptions',
|
||||||
|
slots: commandSlots.filter(s => s.name.startsWith('desc_')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'commandUsage',
|
||||||
|
slots: commandSlots.filter(s => s.name.startsWith('usage_')),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
/** Get slot template for current locale, with fallback. */
|
/** Get slot template for current locale, with fallback. */
|
||||||
function getSlotValue(slotName: string): string {
|
function getSlotValue(slotName: string): string {
|
||||||
@@ -424,10 +454,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
|
||||||
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
|
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
|
|
||||||
|
|
||||||
<!-- Language picker -->
|
<!-- Language picker -->
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
|
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
|
||||||
@@ -453,14 +479,21 @@
|
|||||||
|
|
||||||
<!-- Slot filter -->
|
<!-- Slot filter -->
|
||||||
{#if commandSlots.length > 4}
|
{#if commandSlots.length > 4}
|
||||||
<div class="mb-3">
|
<div>
|
||||||
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
|
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
|
||||||
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-2">
|
{#each commandSlotGroups.filter(g => g.slots.length > 0) as group}
|
||||||
{#each filteredCmdSlots as slot}
|
{@const filteredSlots = slotFilter ? group.slots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase())) : group.slots}
|
||||||
|
{#if filteredSlots.length > 0}
|
||||||
|
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||||
|
<legend class="text-sm font-medium px-1">
|
||||||
|
{t(`cmdTemplateConfig.${group.group}`)}<Hint text={t(`hints.${group.group}`)} />
|
||||||
|
</legend>
|
||||||
|
<div class="space-y-2 mt-2">
|
||||||
|
{#each filteredSlots as slot}
|
||||||
<CollapsibleSlot
|
<CollapsibleSlot
|
||||||
label={slot.name}
|
label={slot.name}
|
||||||
description="/{slot.name} — {slot.description}"
|
description="/{slot.name} — {slot.description}"
|
||||||
@@ -511,6 +544,8 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||||
{editing ? t('common.save') : t('common.create')}
|
{editing ? t('common.save') : t('common.create')}
|
||||||
|
|||||||
@@ -113,6 +113,26 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-pick the command config when the provider changes. The previously
|
||||||
|
// selected id may belong to a different provider type and would no longer
|
||||||
|
// appear in the filtered EntitySelect, leaving the selector empty.
|
||||||
|
let _prevProviderId = $state(0);
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||||
|
_prevProviderId = form.provider_id;
|
||||||
|
if (editing === null) {
|
||||||
|
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||||
|
if (ptype) {
|
||||||
|
const currentCfg = commandConfigs.find(c => c.id === form.command_config_id);
|
||||||
|
if (!currentCfg || currentCfg.provider_type !== ptype) {
|
||||||
|
const first = commandConfigs.find(c => c.provider_type === ptype);
|
||||||
|
form.command_config_id = first?.id ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
function editTracker(trk: any) {
|
function editTracker(trk: any) {
|
||||||
form = {
|
form = {
|
||||||
name: trk.name,
|
name: trk.name,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
let trackingConfigs = $derived(trackingConfigsCache.items);
|
let trackingConfigs = $derived(trackingConfigsCache.items);
|
||||||
let templateConfigs = $derived(templateConfigsCache.items);
|
let templateConfigs = $derived(templateConfigsCache.items);
|
||||||
let collections = $state<Record<string, any>[]>([]);
|
let collections = $state<Record<string, any>[]>([]);
|
||||||
|
let users = $state<{ id: string; name: string }[]>([]);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let collectionFilter = $state('');
|
let collectionFilter = $state('');
|
||||||
@@ -167,22 +168,38 @@
|
|||||||
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
if (!form.provider_id) { users = []; return; }
|
||||||
|
// Skip the fetch when the descriptor has no user filters — saves a
|
||||||
|
// pointless round-trip for providers like Immich/Scheduler.
|
||||||
|
const desc = getDescriptor(selectedProviderType);
|
||||||
|
if (!desc?.userFilters || desc.userFilters.length === 0) { users = []; return; }
|
||||||
|
try { users = await api(`/providers/${form.provider_id}/users`); }
|
||||||
|
catch (e) { console.warn('Failed to load users:', e); users = []; }
|
||||||
|
}
|
||||||
|
|
||||||
let _prevProviderId = $state(0);
|
let _prevProviderId = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||||
_prevProviderId = form.provider_id;
|
_prevProviderId = form.provider_id;
|
||||||
loadCollections();
|
loadCollections();
|
||||||
// Auto-select first available tracking/template config for this provider when creating
|
loadUsers();
|
||||||
|
// Re-pick tracking/template configs for the new provider type. The
|
||||||
|
// previously-selected ids may belong to a different provider type
|
||||||
|
// and therefore no longer appear in the filtered EntitySelect list,
|
||||||
|
// which would render the selector as empty.
|
||||||
if (editing === null) {
|
if (editing === null) {
|
||||||
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||||
if (ptype) {
|
if (ptype) {
|
||||||
if (!form.default_tracking_config_id) {
|
const currentTc = trackingConfigs.find(c => c.id === form.default_tracking_config_id);
|
||||||
|
if (!currentTc || currentTc.provider_type !== ptype) {
|
||||||
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
||||||
if (first) form.default_tracking_config_id = first.id;
|
form.default_tracking_config_id = first?.id ?? 0;
|
||||||
}
|
}
|
||||||
if (!form.default_template_config_id) {
|
const currentTpl = templateConfigs.find(c => c.id === form.default_template_config_id);
|
||||||
|
if (!currentTpl || currentTpl.provider_type !== ptype) {
|
||||||
const first = templateConfigs.find(c => c.provider_type === ptype);
|
const first = templateConfigs.find(c => c.provider_type === ptype);
|
||||||
if (first) form.default_template_config_id = first.id;
|
form.default_template_config_id = first?.id ?? 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +210,7 @@
|
|||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
// Auto-select first provider if any
|
// Auto-select first provider if any
|
||||||
if (providers.length > 0) form.provider_id = providers[0].id;
|
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||||
editing = null; showForm = true; collections = []; previousCollectionIds = [];
|
editing = null; showForm = true; collections = []; users = []; previousCollectionIds = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function edit(trk: Tracker) {
|
async function edit(trk: Tracker) {
|
||||||
@@ -208,7 +225,9 @@
|
|||||||
};
|
};
|
||||||
previousCollectionIds = [...(trk.collection_ids || [])];
|
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||||
editing = trk.id; showForm = true;
|
editing = trk.id; showForm = true;
|
||||||
if (form.provider_id) await loadCollections();
|
if (form.provider_id) {
|
||||||
|
await Promise.all([loadCollections(), loadUsers()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(e: SubmitEvent) {
|
async function save(e: SubmitEvent) {
|
||||||
@@ -460,6 +479,7 @@
|
|||||||
bind:form
|
bind:form
|
||||||
{providerItems}
|
{providerItems}
|
||||||
{collections}
|
{collections}
|
||||||
|
{users}
|
||||||
bind:collectionFilter
|
bind:collectionFilter
|
||||||
trackingConfigItems={trackingConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiCog' }))}
|
trackingConfigItems={trackingConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiCog' }))}
|
||||||
templateConfigItems={templateConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiFileDocumentEdit' }))}
|
templateConfigItems={templateConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiFileDocumentEdit' }))}
|
||||||
@@ -499,6 +519,7 @@
|
|||||||
{:else if !showForm}
|
{:else if !showForm}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="space-y-3 stagger-children">
|
||||||
{#each notificationTrackers as tracker (tracker.id)}
|
{#each notificationTrackers as tracker (tracker.id)}
|
||||||
|
{@const trkDesc = getDescriptor(getProviderType(tracker))}
|
||||||
<Card hover entityId={tracker.id}>
|
<Card hover entityId={tracker.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -511,7 +532,9 @@
|
|||||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
||||||
|
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
||||||
|
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
};
|
};
|
||||||
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
||||||
collections: any[];
|
collections: any[];
|
||||||
|
users?: { id: string; name: string }[];
|
||||||
collectionFilter?: string;
|
collectionFilter?: string;
|
||||||
trackingConfigItems?: { value: number; label: string; icon: string }[];
|
trackingConfigItems?: { value: number; label: string; icon: string }[];
|
||||||
templateConfigItems?: { value: number; label: string; icon: string }[];
|
templateConfigItems?: { value: number; label: string; icon: string }[];
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
form = $bindable(),
|
form = $bindable(),
|
||||||
providerItems,
|
providerItems,
|
||||||
collections,
|
collections,
|
||||||
|
users = [],
|
||||||
collectionFilter = $bindable(),
|
collectionFilter = $bindable(),
|
||||||
trackingConfigItems = [],
|
trackingConfigItems = [],
|
||||||
templateConfigItems = [],
|
templateConfigItems = [],
|
||||||
@@ -116,6 +118,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
||||||
|
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
||||||
|
{#each descriptor.userFilters as uf (uf.key)}
|
||||||
|
<div>
|
||||||
|
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
||||||
|
<MultiEntitySelect
|
||||||
|
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
||||||
|
values={form.filters[uf.key] || []}
|
||||||
|
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
|
||||||
|
placeholder={t(uf.placeholder)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isScheduler}
|
{#if isScheduler}
|
||||||
<!-- Schedule type -->
|
<!-- Schedule type -->
|
||||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||||
|
|||||||
@@ -229,7 +229,7 @@
|
|||||||
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
|
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
|
||||||
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
|
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
|
||||||
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
|
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
|
||||||
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing',
|
ai_captions: c.ai_captions ?? false, chat_action: tgt.chat_action ?? c.chat_action ?? 'typing',
|
||||||
// discord/slack
|
// discord/slack
|
||||||
username: c.username || '',
|
username: c.username || '',
|
||||||
// ntfy
|
// ntfy
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
|
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
|
||||||
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
||||||
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
||||||
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined,
|
ai_captions: form.ai_captions,
|
||||||
};
|
};
|
||||||
} else if (formType === 'webhook') {
|
} else if (formType === 'webhook') {
|
||||||
config = { ai_captions: form.ai_captions };
|
config = { ai_captions: form.ai_captions };
|
||||||
@@ -284,10 +284,12 @@
|
|||||||
config = { child_target_ids: form.child_target_ids };
|
config = { child_target_ids: form.child_target_ids };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body: Record<string, any> = { name: form.name, icon: form.icon, config };
|
||||||
|
if (formType === 'telegram') body.chat_action = form.chat_action || null;
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
} else {
|
} else {
|
||||||
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, icon: form.icon, config }) });
|
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, ...body }) });
|
||||||
}
|
}
|
||||||
showForm = false;
|
showForm = false;
|
||||||
editing = null;
|
editing = null;
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5175,
|
port: 5175,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-core"
|
name = "notify-bridge-core"
|
||||||
version = "0.6.2"
|
version = "0.6.5"
|
||||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -6,12 +6,46 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import FormData
|
from aiohttp import FormData
|
||||||
|
|
||||||
|
# Telegram 429 / flood-control retry settings. Telegram returns
|
||||||
|
# ``parameters.retry_after`` for rate limits; we honor it up to a cap so a
|
||||||
|
# pathological value can't park the dispatcher for minutes.
|
||||||
|
_TG_429_MAX_ATTEMPTS = 4
|
||||||
|
_TG_429_MAX_WAIT_S = 60
|
||||||
|
_RETRY_AFTER_RE = re.compile(r"retry after (\d+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_retry_after(result: dict[str, Any]) -> int | None:
|
||||||
|
"""Return the retry_after seconds from a Telegram error response.
|
||||||
|
|
||||||
|
Prefers the structured ``parameters.retry_after`` field; falls back to
|
||||||
|
parsing the human-readable description (``"Too Many Requests: retry
|
||||||
|
after N"``) which Telegram has been known to return without the
|
||||||
|
structured field on some endpoints.
|
||||||
|
"""
|
||||||
|
params = result.get("parameters") or {}
|
||||||
|
ra = params.get("retry_after")
|
||||||
|
if isinstance(ra, (int, float)) and ra > 0:
|
||||||
|
return int(ra)
|
||||||
|
desc = str(result.get("description", ""))
|
||||||
|
m = _RETRY_AFTER_RE.search(desc)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return int(m.group(1))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rate_limited(status: int, result: dict[str, Any]) -> bool:
|
||||||
|
return status == 429 or result.get("error_code") == 429
|
||||||
|
|
||||||
from .cache import TelegramFileCache
|
from .cache import TelegramFileCache
|
||||||
from .media import (
|
from .media import (
|
||||||
TELEGRAM_API_BASE_URL,
|
TELEGRAM_API_BASE_URL,
|
||||||
@@ -193,18 +227,21 @@ class TelegramClient:
|
|||||||
thumbhash: str | None,
|
thumbhash: str | None,
|
||||||
) -> NotificationResult:
|
) -> NotificationResult:
|
||||||
"""Multipart-upload ``data`` to Telegram and cache the returned file_id."""
|
"""Multipart-upload ``data`` to Telegram and cache the returned file_id."""
|
||||||
form = FormData()
|
def _build_form() -> FormData:
|
||||||
form.add_field("chat_id", chat_id)
|
f = FormData()
|
||||||
form.add_field(kind.form_field, data, filename=filename, content_type=content_type)
|
f.add_field("chat_id", chat_id)
|
||||||
form.add_field("parse_mode", parse_mode)
|
f.add_field(kind.form_field, data, filename=filename, content_type=content_type)
|
||||||
|
f.add_field("parse_mode", parse_mode)
|
||||||
if caption:
|
if caption:
|
||||||
form.add_field("caption", caption)
|
f.add_field("caption", caption)
|
||||||
if reply_to_message_id:
|
if reply_to_message_id:
|
||||||
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
f.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
||||||
|
return f
|
||||||
|
|
||||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
|
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
|
||||||
|
for attempt in range(1, _TG_429_MAX_ATTEMPTS + 1):
|
||||||
try:
|
try:
|
||||||
async with self._session.post(telegram_url, data=form) as response:
|
async with self._session.post(telegram_url, data=_build_form()) as response:
|
||||||
result = await response.json()
|
result = await response.json()
|
||||||
if response.status == 200 and result.get("ok"):
|
if response.status == 200 and result.get("ok"):
|
||||||
res = result.get("result", {})
|
res = result.get("result", {})
|
||||||
@@ -215,6 +252,18 @@ class TelegramClient:
|
|||||||
thumbhash=thumbhash, size=len(data),
|
thumbhash=thumbhash, size=len(data),
|
||||||
)
|
)
|
||||||
return {"success": True, "message_id": res.get("message_id")}
|
return {"success": True, "message_id": res.get("message_id")}
|
||||||
|
|
||||||
|
if _is_rate_limited(response.status, result) and attempt < _TG_429_MAX_ATTEMPTS:
|
||||||
|
retry_after = _extract_retry_after(result) or 1
|
||||||
|
wait_s = min(retry_after + 1, _TG_429_MAX_WAIT_S)
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Telegram %s 429 (retry_after=%ds, attempt %d/%d) bytes=%d — sleeping %ds",
|
||||||
|
kind.api_method, retry_after, attempt, _TG_429_MAX_ATTEMPTS,
|
||||||
|
len(data), wait_s,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait_s)
|
||||||
|
continue
|
||||||
|
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Telegram %s failed: status=%s code=%s desc=%r bytes=%d",
|
"Telegram %s failed: status=%s code=%s desc=%r bytes=%d",
|
||||||
kind.api_method, response.status, result.get("error_code"),
|
kind.api_method, response.status, result.get("error_code"),
|
||||||
@@ -227,6 +276,9 @@ class TelegramClient:
|
|||||||
kind.api_method, len(data), err, exc_info=True,
|
kind.api_method, len(data), err, exc_info=True,
|
||||||
)
|
)
|
||||||
return {"success": False, "error": str(err)}
|
return {"success": False, "error": str(err)}
|
||||||
|
# All attempts exhausted via 429 — should be unreachable, but keep
|
||||||
|
# an explicit error path so we never return None.
|
||||||
|
return {"success": False, "error": "Telegram rate limit: max retries exhausted"}
|
||||||
|
|
||||||
async def send_notification(
|
async def send_notification(
|
||||||
self,
|
self,
|
||||||
@@ -299,12 +351,7 @@ class TelegramClient:
|
|||||||
send_large_photos_as_documents,
|
send_large_photos_as_documents,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if typing_task:
|
await self.stop_keepalive(typing_task)
|
||||||
typing_task.cancel()
|
|
||||||
try:
|
|
||||||
await typing_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
@@ -368,20 +415,53 @@ class TelegramClient:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def start_chat_action_keepalive(self, chat_id: str, action: str = "typing") -> asyncio.Task:
|
def start_chat_action_keepalive(self, chat_id: str, action: str = "typing") -> asyncio.Task:
|
||||||
"""Repeatedly post ``action`` every 4s until the returned task is cancelled.
|
"""Repeatedly post ``action`` every 4s until stopped.
|
||||||
|
|
||||||
Telegram chat actions expire after ~5s, so callers that want the hint
|
Telegram chat actions expire after ~5s, so callers that want the hint
|
||||||
to persist through longer work (fetching assets, multi-chunk uploads)
|
to persist through longer work (fetching assets, multi-chunk uploads)
|
||||||
need a keep-alive. Cancel the task in a ``finally`` to stop it.
|
need a keep-alive.
|
||||||
|
|
||||||
|
The returned task carries an attached ``stop_event`` (``asyncio.Event``).
|
||||||
|
Stop cleanly via :meth:`stop_keepalive` — setting the event before
|
||||||
|
cancellation prevents the loop from firing one last ``sendChatAction``
|
||||||
|
after the caller's final user-visible message, which would otherwise
|
||||||
|
leave a phantom indicator hanging for ~5s.
|
||||||
"""
|
"""
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
|
||||||
async def action_loop() -> None:
|
async def action_loop() -> None:
|
||||||
try:
|
try:
|
||||||
while True:
|
while not stop_event.is_set():
|
||||||
await self.send_chat_action(chat_id, action)
|
await self.send_chat_action(chat_id, action)
|
||||||
await asyncio.sleep(4)
|
try:
|
||||||
|
await asyncio.wait_for(stop_event.wait(), timeout=4)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass # 4s elapsed, refresh the action
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
task: asyncio.Task = asyncio.create_task(action_loop())
|
||||||
|
task.stop_event = stop_event # type: ignore[attr-defined]
|
||||||
|
return task
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def stop_keepalive(task: asyncio.Task | None) -> None:
|
||||||
|
"""Stop a keepalive task started by :meth:`start_chat_action_keepalive`.
|
||||||
|
|
||||||
|
Sets the attached stop event before cancelling so the loop won't
|
||||||
|
fire another ``sendChatAction`` after the caller's final message
|
||||||
|
landed at Telegram.
|
||||||
|
"""
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
stop_event = getattr(task, "stop_event", None)
|
||||||
|
if stop_event is not None:
|
||||||
|
stop_event.set()
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
return asyncio.create_task(action_loop())
|
|
||||||
|
|
||||||
async def _send_photo(
|
async def _send_photo(
|
||||||
self, chat_id: str, url: str | None, caption: str | None = None,
|
self, chat_id: str, url: str | None, caption: str | None = None,
|
||||||
@@ -526,12 +606,10 @@ class TelegramClient:
|
|||||||
all_message_ids.append(result.get("message_id"))
|
all_message_ids.append(result.get("message_id"))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Multi-item: download all, build form, send media group
|
# Multi-item: download all, build form, send media group.
|
||||||
form = FormData()
|
# Attachments are recorded separately so we can rebuild FormData on
|
||||||
form.add_field("chat_id", chat_id)
|
# 429 retry — aiohttp.FormData is single-use after a request.
|
||||||
if reply_to_message_id and chunk_idx == 0:
|
attachments: list[tuple[str, bytes, str, str]] = [] # (name, data, filename, content_type)
|
||||||
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
|
||||||
|
|
||||||
media_json = []
|
media_json = []
|
||||||
upload_idx = 0
|
upload_idx = 0
|
||||||
# Track cache info per media_json entry (in order) so we can map
|
# Track cache info per media_json entry (in order) so we can map
|
||||||
@@ -646,7 +724,7 @@ class TelegramClient:
|
|||||||
attach_name = f"file{upload_idx}"
|
attach_name = f"file{upload_idx}"
|
||||||
ct = item.get("content_type") or ("image/jpeg" if media_type == "photo" else "video/mp4")
|
ct = item.get("content_type") or ("image/jpeg" if media_type == "photo" else "video/mp4")
|
||||||
ext = "jpg" if media_type == "photo" else "mp4"
|
ext = "jpg" if media_type == "photo" else "mp4"
|
||||||
form.add_field(attach_name, data, filename=f"media_{idx}.{ext}", content_type=ct)
|
attachments.append((attach_name, data, f"media_{idx}.{ext}", ct))
|
||||||
mij = {"type": media_type, "media": f"attach://{attach_name}"}
|
mij = {"type": media_type, "media": f"attach://{attach_name}"}
|
||||||
upload_idx += 1
|
upload_idx += 1
|
||||||
# Record cache key so we can store file_id from response
|
# Record cache key so we can store file_id from response
|
||||||
@@ -674,11 +752,22 @@ class TelegramClient:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
form.add_field("media", json.dumps(media_json))
|
|
||||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendMediaGroup"
|
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendMediaGroup"
|
||||||
|
|
||||||
|
def _build_form() -> FormData:
|
||||||
|
f = FormData()
|
||||||
|
f.add_field("chat_id", chat_id)
|
||||||
|
if reply_to_message_id and chunk_idx == 0:
|
||||||
|
f.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
||||||
|
for name, payload, filename, ct in attachments:
|
||||||
|
f.add_field(name, payload, filename=filename, content_type=ct)
|
||||||
|
f.add_field("media", json.dumps(media_json))
|
||||||
|
return f
|
||||||
|
|
||||||
|
chunk_failed_result: dict[str, Any] | None = None
|
||||||
|
for attempt in range(1, _TG_429_MAX_ATTEMPTS + 1):
|
||||||
try:
|
try:
|
||||||
async with self._session.post(telegram_url, data=form) as response:
|
async with self._session.post(telegram_url, data=_build_form()) as response:
|
||||||
result = await response.json()
|
result = await response.json()
|
||||||
if response.status == 200 and result.get("ok"):
|
if response.status == 200 and result.get("ok"):
|
||||||
result_msgs = result.get("result", [])
|
result_msgs = result.get("result", [])
|
||||||
@@ -707,19 +796,32 @@ class TelegramClient:
|
|||||||
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
|
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
|
||||||
if eff_cache:
|
if eff_cache:
|
||||||
await eff_cache.async_set_many(cache_entries)
|
await eff_cache.async_set_many(cache_entries)
|
||||||
else:
|
break # chunk succeeded
|
||||||
|
|
||||||
|
if _is_rate_limited(response.status, result) and attempt < _TG_429_MAX_ATTEMPTS:
|
||||||
|
retry_after = _extract_retry_after(result) or 1
|
||||||
|
wait_s = min(retry_after + 1, _TG_429_MAX_WAIT_S)
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Telegram sendMediaGroup 429 (retry_after=%ds, attempt %d/%d) chunk=%d/%d items=%d — sleeping %ds",
|
||||||
|
retry_after, attempt, _TG_429_MAX_ATTEMPTS,
|
||||||
|
chunk_idx + 1, len(chunks), len(media_json), wait_s,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait_s)
|
||||||
|
continue
|
||||||
|
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Telegram sendMediaGroup failed: status=%s code=%s desc=%r chunk=%d/%d items=%d",
|
"Telegram sendMediaGroup failed: status=%s code=%s desc=%r chunk=%d/%d items=%d",
|
||||||
response.status, result.get("error_code"),
|
response.status, result.get("error_code"),
|
||||||
result.get("description", "Unknown"),
|
result.get("description", "Unknown"),
|
||||||
chunk_idx + 1, len(chunks), len(media_json),
|
chunk_idx + 1, len(chunks), len(media_json),
|
||||||
)
|
)
|
||||||
return {
|
chunk_failed_result = {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": result.get("description", "Unknown"),
|
"error": result.get("description", "Unknown"),
|
||||||
"error_code": result.get("error_code"),
|
"error_code": result.get("error_code"),
|
||||||
"failed_at_chunk": chunk_idx + 1,
|
"failed_at_chunk": chunk_idx + 1,
|
||||||
}
|
}
|
||||||
|
break
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
|
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
|
||||||
@@ -728,6 +830,9 @@ class TelegramClient:
|
|||||||
)
|
)
|
||||||
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
|
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
|
||||||
|
|
||||||
|
if chunk_failed_result is not None:
|
||||||
|
return chunk_failed_result
|
||||||
|
|
||||||
# Distinguish "posted something" from "posted nothing" so the caller
|
# Distinguish "posted something" from "posted nothing" so the caller
|
||||||
# can surface an ERROR when a command produced a caption reply but no
|
# can surface an ERROR when a command produced a caption reply but no
|
||||||
# media ever reached Telegram.
|
# media ever reached Telegram.
|
||||||
|
|||||||
@@ -150,6 +150,40 @@ class GiteaClient:
|
|||||||
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
|
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def get_users(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||||
|
"""List users known to the Gitea instance via /users/search.
|
||||||
|
|
||||||
|
``/users/search`` with an empty ``q`` returns all users the
|
||||||
|
authenticated token can see, paginated. We cap at ``limit`` to avoid
|
||||||
|
unbounded memory on large instances; the picker only needs enough to
|
||||||
|
cover senders that may appear in webhook payloads.
|
||||||
|
"""
|
||||||
|
users: list[dict[str, Any]] = []
|
||||||
|
page = 1
|
||||||
|
per_page = min(50, limit)
|
||||||
|
while len(users) < limit:
|
||||||
|
try:
|
||||||
|
async with self._session.get(
|
||||||
|
f"{self._url}/api/v1/users/search",
|
||||||
|
headers=self._headers,
|
||||||
|
params={"page": str(page), "limit": str(per_page)},
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
_LOGGER.warning("Failed to fetch users: HTTP %s", response.status)
|
||||||
|
break
|
||||||
|
body = await response.json()
|
||||||
|
items = body.get("data", []) if isinstance(body, dict) else body
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
users.extend(items)
|
||||||
|
if len(items) < per_page:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.warning("Failed to fetch users: %s", err)
|
||||||
|
break
|
||||||
|
return users[:limit]
|
||||||
|
|
||||||
|
|
||||||
class GiteaApiError(Exception):
|
class GiteaApiError(Exception):
|
||||||
"""Raised when a Gitea API call fails."""
|
"""Raised when a Gitea API call fails."""
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-server"
|
name = "notify-bridge-server"
|
||||||
version = "0.6.2"
|
version = "0.6.5"
|
||||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import aiohttp
|
|||||||
|
|
||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import ServiceProvider, User
|
from ..database.models import EventLog, ServiceProvider, User
|
||||||
from ..services import (
|
from ..services import (
|
||||||
make_immich_provider, make_gitea_provider, make_planka_provider,
|
make_immich_provider, make_gitea_provider, make_planka_provider,
|
||||||
make_nut_provider, make_google_photos_provider, list_provider_collections,
|
make_nut_provider, make_google_photos_provider, list_provider_collections,
|
||||||
@@ -398,6 +398,62 @@ async def list_collections(
|
|||||||
return await list_provider_collections(provider)
|
return await list_provider_collections(provider)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider_id}/users")
|
||||||
|
async def list_provider_users(
|
||||||
|
provider_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Return user identities for sender allowlist/blocklist pickers.
|
||||||
|
|
||||||
|
Two sources are merged so the picker is useful both before and after the
|
||||||
|
first webhook arrives:
|
||||||
|
|
||||||
|
- **Provider API** (primary): Gitea's ``/users/search`` returns instance
|
||||||
|
users the api_token can see. Skipped when no api_token is set.
|
||||||
|
- **Past senders** (fallback): distinct ``sender`` values from
|
||||||
|
``EventLog.details`` for this provider, so pre-existing trackers stay
|
||||||
|
filterable even if the API call fails or is unconfigured.
|
||||||
|
"""
|
||||||
|
provider = await _get_user_provider(session, provider_id, user.id)
|
||||||
|
|
||||||
|
users_by_id: dict[str, str] = {}
|
||||||
|
|
||||||
|
# 1. Try the provider API.
|
||||||
|
if provider.type == "gitea" and (provider.config or {}).get("api_token"):
|
||||||
|
from notify_bridge_core.providers.gitea.client import GiteaClient
|
||||||
|
http_session = await get_http_session()
|
||||||
|
client = GiteaClient(
|
||||||
|
http_session,
|
||||||
|
provider.config.get("url", ""),
|
||||||
|
provider.config.get("api_token", ""),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
for u in await client.get_users():
|
||||||
|
login = u.get("login", "")
|
||||||
|
if isinstance(login, str) and login:
|
||||||
|
users_by_id[login] = u.get("full_name") or login
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.warning("Failed to fetch Gitea users via API", exc_info=True)
|
||||||
|
|
||||||
|
# 2. Merge in past senders (covers users not visible to the API token, or
|
||||||
|
# cases where the API call fails).
|
||||||
|
result = await session.exec(
|
||||||
|
select(EventLog.details).where(EventLog.provider_id == provider.id)
|
||||||
|
)
|
||||||
|
for details in result.all():
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
continue
|
||||||
|
sender = details.get("sender", "")
|
||||||
|
if isinstance(sender, str) and sender and sender not in users_by_id:
|
||||||
|
users_by_id[sender] = sender
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"id": login, "name": name}
|
||||||
|
for login, name in sorted(users_by_id.items(), key=lambda kv: kv[0].lower())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{provider_id}/albums/{album_id}/shared-links")
|
@router.get("/{provider_id}/albums/{album_id}/shared-links")
|
||||||
async def get_album_shared_links(
|
async def get_album_shared_links(
|
||||||
provider_id: int,
|
provider_id: int,
|
||||||
|
|||||||
@@ -197,6 +197,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
|||||||
)
|
)
|
||||||
logger.info("Added filters column to %s table", tracker_table)
|
logger.info("Added filters column to %s table", tracker_table)
|
||||||
|
|
||||||
|
# Drop legacy batch_duration column from notification_tracker.
|
||||||
|
# The field was removed from the SQLModel class but the column still
|
||||||
|
# exists as NOT NULL in older DBs, so INSERTs from the new code fail
|
||||||
|
# with "NOT NULL constraint failed: notification_tracker.batch_duration".
|
||||||
|
if await _has_table(conn, tracker_table):
|
||||||
|
if await _has_column(conn, tracker_table, "batch_duration"):
|
||||||
|
_assert_ident(tracker_table, "table")
|
||||||
|
await conn.execute(
|
||||||
|
text(f"ALTER TABLE {tracker_table} DROP COLUMN batch_duration")
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Dropped legacy batch_duration column from %s table",
|
||||||
|
tracker_table,
|
||||||
|
)
|
||||||
|
|
||||||
# Add Gitea tracking flags to tracking_config if missing
|
# Add Gitea tracking flags to tracking_config if missing
|
||||||
if await _has_table(conn, "tracking_config"):
|
if await _has_table(conn, "tracking_config"):
|
||||||
gitea_flags = [
|
gitea_flags = [
|
||||||
@@ -1376,6 +1391,40 @@ async def migrate_performance_indexes(engine: AsyncEngine) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_chat_action_to_column(engine: AsyncEngine) -> None:
|
||||||
|
"""Move ``chat_action`` from ``config`` JSON to the dedicated column.
|
||||||
|
|
||||||
|
Earlier versions of the frontend stored ``chat_action`` inside
|
||||||
|
``notification_target.config``; the dedicated ``chat_action`` column
|
||||||
|
was rarely set or held a stale default. The dispatcher's resolver
|
||||||
|
overrode the config value with the (stale) column, so a user's UI
|
||||||
|
choice silently had no effect on outgoing chat actions.
|
||||||
|
|
||||||
|
This backfill takes the config value as authoritative (it's what the
|
||||||
|
UI was writing) and copies it to the column, then strips it from
|
||||||
|
config so the column becomes the single source of truth. Idempotent:
|
||||||
|
a second run finds nothing to migrate.
|
||||||
|
"""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
if not await _has_table(conn, "notification_target"):
|
||||||
|
return
|
||||||
|
if not await _has_column(conn, "notification_target", "chat_action"):
|
||||||
|
return
|
||||||
|
# Copy config["chat_action"] → column where present.
|
||||||
|
await conn.execute(text(
|
||||||
|
"UPDATE notification_target "
|
||||||
|
"SET chat_action = json_extract(config, '$.chat_action') "
|
||||||
|
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
|
||||||
|
))
|
||||||
|
# Strip the legacy key so the column is unambiguous going forward.
|
||||||
|
await conn.execute(text(
|
||||||
|
"UPDATE notification_target "
|
||||||
|
"SET config = json_remove(config, '$.chat_action') "
|
||||||
|
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
|
||||||
|
))
|
||||||
|
logger.info("Migrated chat_action from config JSON to column where present")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Schema version tracking — lightweight alternative to Alembic while the
|
# Schema version tracking — lightweight alternative to Alembic while the
|
||||||
# hand-rolled idempotent migrations remain the source of truth. Gives
|
# hand-rolled idempotent migrations remain the source of truth. Gives
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ async def lifespan(app: FastAPI):
|
|||||||
migrate_notification_slot_locale,
|
migrate_notification_slot_locale,
|
||||||
migrate_user_token_version,
|
migrate_user_token_version,
|
||||||
migrate_performance_indexes,
|
migrate_performance_indexes,
|
||||||
|
migrate_chat_action_to_column,
|
||||||
migrate_schema_version,
|
migrate_schema_version,
|
||||||
)
|
)
|
||||||
from .database.snapshot import snapshot_and_prune
|
from .database.snapshot import snapshot_and_prune
|
||||||
@@ -98,6 +99,7 @@ async def lifespan(app: FastAPI):
|
|||||||
await migrate_notification_slot_locale(engine)
|
await migrate_notification_slot_locale(engine)
|
||||||
await migrate_user_token_version(engine)
|
await migrate_user_token_version(engine)
|
||||||
await migrate_performance_indexes(engine)
|
await migrate_performance_indexes(engine)
|
||||||
|
await migrate_chat_action_to_column(engine)
|
||||||
await migrate_schema_version(engine)
|
await migrate_schema_version(engine)
|
||||||
from .database.seeds import seed_all
|
from .database.seeds import seed_all
|
||||||
await seed_all()
|
await seed_all()
|
||||||
|
|||||||
@@ -326,7 +326,11 @@ async def _resolve_target(
|
|||||||
receivers.append(build_receiver(target.type, dict(r.config), locale))
|
receivers.append(build_receiver(target.type, dict(r.config), locale))
|
||||||
|
|
||||||
target_config = dict(target.config)
|
target_config = dict(target.config)
|
||||||
# Inject chat_action for Telegram targets
|
# chat_action lives on the model column — single source of truth.
|
||||||
|
# Strip any legacy/stale value from config so an old config-stored value
|
||||||
|
# can't shadow the user's UI choice. When the column is unset, leave the
|
||||||
|
# key absent so the dispatcher's "typing" fallback applies.
|
||||||
|
target_config.pop("chat_action", None)
|
||||||
if hasattr(target, 'chat_action') and target.chat_action:
|
if hasattr(target, 'chat_action') and target.chat_action:
|
||||||
target_config["chat_action"] = target.chat_action
|
target_config["chat_action"] = target.chat_action
|
||||||
# Inject bot credentials for bot-backed target types
|
# Inject bot credentials for bot-backed target types
|
||||||
|
|||||||
@@ -378,6 +378,8 @@ async def _load_tracker_jobs() -> None:
|
|||||||
|
|
||||||
tz = await _load_app_timezone()
|
tz = await _load_app_timezone()
|
||||||
|
|
||||||
|
from notify_bridge_core.providers.capabilities import get_capabilities
|
||||||
|
|
||||||
for tracker in trackers:
|
for tracker in trackers:
|
||||||
job_id = f"tracker_{tracker.id}"
|
job_id = f"tracker_{tracker.id}"
|
||||||
if scheduler.get_job(job_id):
|
if scheduler.get_job(job_id):
|
||||||
@@ -386,6 +388,18 @@ async def _load_tracker_jobs() -> None:
|
|||||||
ptype = provider_types.get(tracker.provider_id, "")
|
ptype = provider_types.get(tracker.provider_id, "")
|
||||||
filters = tracker.filters or {}
|
filters = tracker.filters or {}
|
||||||
|
|
||||||
|
# Webhook-based providers receive events via inbound HTTP — there is
|
||||||
|
# nothing to poll. Scheduling an interval job for them just wakes up
|
||||||
|
# check_tracker every scan_interval seconds to immediately return,
|
||||||
|
# wasting CPU and DB queries for no work.
|
||||||
|
caps = get_capabilities(ptype) if ptype else None
|
||||||
|
if caps and caps.webhook_based:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Skipping interval scheduling for webhook tracker %d (%s, type=%s)",
|
||||||
|
tracker.id, tracker.name, ptype,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Scheduler providers can use cron triggers
|
# Scheduler providers can use cron triggers
|
||||||
if ptype == "scheduler" and filters.get("schedule_type") == "cron":
|
if ptype == "scheduler" and filters.get("schedule_type") == "cron":
|
||||||
cron_expr = filters.get("cron_expression", "")
|
cron_expr = filters.get("cron_expression", "")
|
||||||
@@ -450,6 +464,29 @@ def _add_cron_job(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_webhook_tracker(tracker_id: int) -> bool:
|
||||||
|
"""Return True iff the tracker's provider type is webhook-based.
|
||||||
|
|
||||||
|
Looks up provider type once via the capabilities registry. Used by
|
||||||
|
``schedule_tracker`` to short-circuit interval scheduling.
|
||||||
|
"""
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
from notify_bridge_core.providers.capabilities import get_capabilities
|
||||||
|
from ..database.engine import get_engine
|
||||||
|
from ..database.models import NotificationTracker, ServiceProvider as ServiceProviderModel
|
||||||
|
|
||||||
|
async with AsyncSession(get_engine()) as session:
|
||||||
|
tracker = await session.get(NotificationTracker, tracker_id)
|
||||||
|
if tracker is None:
|
||||||
|
return False
|
||||||
|
provider = await session.get(ServiceProviderModel, tracker.provider_id)
|
||||||
|
if provider is None:
|
||||||
|
return False
|
||||||
|
caps = get_capabilities(provider.type)
|
||||||
|
return bool(caps and caps.webhook_based)
|
||||||
|
|
||||||
|
|
||||||
async def schedule_tracker(
|
async def schedule_tracker(
|
||||||
tracker_id: int,
|
tracker_id: int,
|
||||||
interval: int,
|
interval: int,
|
||||||
@@ -461,6 +498,10 @@ async def schedule_tracker(
|
|||||||
``adaptive_max_skip`` mirrors the DB column and is registered with the
|
``adaptive_max_skip`` mirrors the DB column and is registered with the
|
||||||
adaptive module-state so tick-time skip decisions don't re-query the DB.
|
adaptive module-state so tick-time skip decisions don't re-query the DB.
|
||||||
Pass ``None`` or ``0`` to disable back-off for the tracker.
|
Pass ``None`` or ``0`` to disable back-off for the tracker.
|
||||||
|
|
||||||
|
Webhook-based providers receive events via inbound HTTP and have nothing
|
||||||
|
to poll, so this no-ops for them — preventing scan_interval from creating
|
||||||
|
useless wakeups via the API create/update path.
|
||||||
"""
|
"""
|
||||||
scheduler = get_scheduler()
|
scheduler = get_scheduler()
|
||||||
job_id = f"tracker_{tracker_id}"
|
job_id = f"tracker_{tracker_id}"
|
||||||
@@ -474,6 +515,13 @@ async def schedule_tracker(
|
|||||||
if scheduler.get_job(job_id):
|
if scheduler.get_job(job_id):
|
||||||
scheduler.remove_job(job_id)
|
scheduler.remove_job(job_id)
|
||||||
|
|
||||||
|
# Webhook-based providers don't poll — skip job creation entirely.
|
||||||
|
if await _is_webhook_tracker(tracker_id):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Skipping interval scheduling for webhook tracker %d", tracker_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if cron_expression:
|
if cron_expression:
|
||||||
try:
|
try:
|
||||||
tz = await _load_app_timezone()
|
tz = await _load_app_timezone()
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ this module just guarantees every caller gets a properly-wired client.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
import contextlib
|
||||||
from typing import Any, AsyncIterator, Callable
|
from typing import Any, AsyncIterator, Callable
|
||||||
|
|
||||||
@@ -144,6 +143,4 @@ async def telegram_chat_action(
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
task.cancel()
|
await client.stop_keepalive(task)
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
|
||||||
await task
|
|
||||||
|
|||||||
Reference in New Issue
Block a user