Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 349e9136a4 | |||
| 04c8e3c8b2 | |||
| 9afd38e50e | |||
| aa9548d884 | |||
| 72dd611f8c |
+11
-24
@@ -1,45 +1,32 @@
|
|||||||
# v0.6.3 (2026-04-27)
|
# v0.6.5 (2026-04-28)
|
||||||
|
|
||||||
Adds user filters for the Gitea tracker, makes the dashboard navigable, removes leftover webhook polling, and fixes the theme/sidebar flash on hard reload.
|
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
|
### Features
|
||||||
|
|
||||||
- **Gitea — sender filters:** `NotificationTracker` now exposes `sender_allowlist` and `sender_blocklist` via `MultiEntitySelect`. The picker is populated from `Gitea /users/search` merged with past `EventLog` senders, so it is useful even before the first webhook arrives ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
- **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))
|
||||||
- **Dashboard navigability:** stat cards are now `<a>` links that route to providers, trackers, targets, command-trackers, or scroll to the events panel. Provider deck rows highlight the target provider on click ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
|
||||||
- **Command trackers / configs:** auto-reselect the matching config when the provider type changes, matching notification-tracker behaviour ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
|
||||||
- **Webhook providers (gitea, planka, webhook):** stop scheduling interval polling jobs on tracker create/update/startup, and hide the misleading "every Xs" indicator in the tracker list — webhook trackers do not poll ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- **Theme FOUC on hard reload:** an inline blocking script in `app.html` now resolves the theme from `localStorage` (or `prefers-color-scheme`) and sets `data-theme` on `<html>` before first paint, eliminating the dark→light flash users saw when the light theme was selected ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
|
- **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))
|
||||||
- **Sidebar jump on reload:** sidebar collapsed state and expanded nav groups now hydrate synchronously in their `$state` initialisers instead of inside `onMount`, so the sidebar no longer snaps from expanded→collapsed and groups no longer slide open after mount ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
|
- **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))
|
||||||
- **Provider-filter row pop-in:** the global provider-filter row now stays rendered while `providersCache.fetchedAt === 0`, so it no longer pops in mid-paint and pushes the nav down once the cache resolves ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
|
|
||||||
|
|
||||||
## Development / Internal
|
## Development / Internal
|
||||||
|
|
||||||
### Build
|
### i18n
|
||||||
|
|
||||||
- **Build-time app version:** `vite.config.ts` now reads `frontend/package.json` and exposes its version as an `__APP_VERSION__` global via Vite's `define`, with an ambient declaration in `app.d.ts` so the layout's brand version badge type-checks ([4307955](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4307955))
|
- Drop orphan `cmdTemplateConfig.commandResponsesHint` key — `hints.commandResponses` replaces it ([04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c))
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
- **Migration:** drop legacy `batch_duration` column from `notification_tracker` — the field had been removed from the model but its `NOT NULL` constraint still blocked inserts on older DBs ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Refresh `.claude/docs/entity-relationships.md` with current `NotificationTracker` fields (filters, `adaptive_max_skip`, `default_*_config_id`) ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>All Commits</summary>
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
| Hash | Message | Author |
|
||||||
|------|---------|--------|
|
|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|------------------|
|
||||||
| [4307955](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4307955) | feat(frontend): inject `__APP_VERSION__` from package.json at build time | alexei.dolgolyov |
|
| [04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c) | feat(frontend): group command template slots into 4 logical fieldsets | alexei.dolgolyov |
|
||||||
| [b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01) | fix(redesign): prevent theme FOUC and sidebar jump on hard reload | 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 |
|
||||||
| [42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6) | feat(trackers): user filters for Gitea, webhook polling cleanup, dashboard navigability | alexei.dolgolyov |
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.3",
|
"version": "0.6.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -475,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",
|
||||||
@@ -822,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",
|
||||||
@@ -875,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",
|
||||||
|
|||||||
@@ -475,6 +475,7 @@
|
|||||||
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
|
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
|
||||||
"syncCommands": "Синхр. команды",
|
"syncCommands": "Синхр. команды",
|
||||||
"discoverChats": "Обнаружить чаты из Telegram",
|
"discoverChats": "Обнаружить чаты из Telegram",
|
||||||
|
"discoveringChats": "Поиск чатов…",
|
||||||
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
||||||
"chatsDiscovered": "Чаты обнаружены",
|
"chatsDiscovered": "Чаты обнаружены",
|
||||||
"chatDeleted": "Чат удалён",
|
"chatDeleted": "Чат удалён",
|
||||||
@@ -822,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",
|
||||||
@@ -875,7 +880,9 @@
|
|||||||
"noConfigs": "Шаблонов команд пока нет.",
|
"noConfigs": "Шаблонов команд пока нет.",
|
||||||
"confirmDelete": "Удалить этот шаблон команд?",
|
"confirmDelete": "Удалить этот шаблон команд?",
|
||||||
"commandResponses": "Ответы команд",
|
"commandResponses": "Ответы команд",
|
||||||
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
|
"commandErrors": "Сообщения об ошибках",
|
||||||
|
"commandDescriptions": "Описания команд",
|
||||||
|
"commandUsage": "Примеры использования"
|
||||||
},
|
},
|
||||||
"commandConfig": {
|
"commandConfig": {
|
||||||
"titleEmphasis": "конфигурации",
|
"titleEmphasis": "конфигурации",
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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,66 +377,80 @@
|
|||||||
<!-- 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;"}
|
||||||
<!-- Header -->
|
<div class="chat-list-wrap" class:is-refreshing={chatsRefreshing[bot.id]}>
|
||||||
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
{#if chatsRefreshing[bot.id]}
|
||||||
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
<div class="chat-shimmer" aria-hidden="true" transition:fade={{ duration: 180 }}></div>
|
||||||
<span>{t('telegramBot.chatName')}</span>
|
{/if}
|
||||||
<span style="text-align:center">{t('telegramBot.chatType')}</span>
|
<!-- Header -->
|
||||||
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
|
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
||||||
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
|
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
||||||
<span style="text-align:center">{t('telegramBot.cmds')}</span>
|
<span>{t('telegramBot.chatName')}</span>
|
||||||
<span style="text-align:center">{t('telegramBot.chatId')}</span>
|
<span style="text-align:center">{t('telegramBot.chatType')}</span>
|
||||||
<span></span>
|
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
|
||||||
</div>
|
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
|
||||||
<!-- Rows -->
|
<span style="text-align:center">{t('telegramBot.cmds')}</span>
|
||||||
{#each chats[bot.id] as chat}
|
<span style="text-align:center">{t('telegramBot.chatId')}</span>
|
||||||
<div style={gridStyle}
|
<span></span>
|
||||||
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
|
||||||
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); } }}
|
|
||||||
title={t('telegramBot.clickToCopy')}
|
|
||||||
aria-label={t('telegramBot.clickToCopy')}
|
|
||||||
role="button" tabindex="0">
|
|
||||||
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
|
|
||||||
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
|
||||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
|
||||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
|
||||||
<EntitySelect
|
|
||||||
items={LANG_ITEMS}
|
|
||||||
value={chat.language_override || ''}
|
|
||||||
size="sm"
|
|
||||||
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
|
||||||
title={t('telegramBot.commandsToggle')}
|
|
||||||
onclick={() => toggleChatCommands(bot.id, chat)}>
|
|
||||||
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
|
||||||
<div style="justify-self:end" class="flex items-center gap-1">
|
|
||||||
<IconButton icon="mdiSend" title={t('common.test')} size={14}
|
|
||||||
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
|
||||||
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
|
||||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
<!-- Rows -->
|
||||||
|
{#each (chats[bot.id] || []) as chat (chat.id)}
|
||||||
|
<div style={gridStyle}
|
||||||
|
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)}
|
||||||
|
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
|
||||||
|
title={t('telegramBot.clickToCopy')}
|
||||||
|
aria-label={t('telegramBot.clickToCopy')}
|
||||||
|
role="button" tabindex="0">
|
||||||
|
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
|
||||||
|
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||||
|
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
||||||
|
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||||
|
<EntitySelect
|
||||||
|
items={LANG_ITEMS}
|
||||||
|
value={chat.language_override || ''}
|
||||||
|
size="sm"
|
||||||
|
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||||
|
title={t('telegramBot.commandsToggle')}
|
||||||
|
onclick={() => toggleChatCommands(bot.id, chat)}>
|
||||||
|
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||||
|
<div style="justify-self:end" class="flex items-center gap-1">
|
||||||
|
<IconButton icon="mdiSend" title={t('common.test')} size={14}
|
||||||
|
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
||||||
|
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
||||||
|
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||||
|
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/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]}
|
||||||
<MdiIcon name="mdiSync" size={14} />
|
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">
|
||||||
{t('telegramBot.discoverChats')}
|
<span class="discover-icon" class:is-spinning={chatsRefreshing[bot.id]}>
|
||||||
|
<MdiIcon name="mdiSync" size={14} />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,93 +454,98 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
<!-- Language picker -->
|
||||||
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
|
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
|
||||||
|
{t('templateConfig.language')}
|
||||||
<!-- Language picker -->
|
</span>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex-1 max-w-xs">
|
||||||
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
|
<EntitySelect
|
||||||
{t('templateConfig.language')}
|
items={localeItems}
|
||||||
</span>
|
value={activeLocale}
|
||||||
<div class="flex-1 max-w-xs">
|
size="sm"
|
||||||
<EntitySelect
|
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
|
||||||
items={localeItems}
|
/>
|
||||||
value={activeLocale}
|
|
||||||
size="sm"
|
|
||||||
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{#if form.provider_type}
|
|
||||||
<button type="button" onclick={resetAllToDefaults}
|
|
||||||
title={t('templateConfig.resetAllToDefaults')}
|
|
||||||
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
|
|
||||||
<MdiIcon name="mdiRefresh" size={12} />
|
|
||||||
{t('templateConfig.resetAllToDefaults')}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Slot filter -->
|
|
||||||
{#if commandSlots.length > 4}
|
|
||||||
<div class="mb-3">
|
|
||||||
<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)]" />
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if form.provider_type}
|
||||||
|
<button type="button" onclick={resetAllToDefaults}
|
||||||
|
title={t('templateConfig.resetAllToDefaults')}
|
||||||
|
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
|
||||||
|
<MdiIcon name="mdiRefresh" size={12} />
|
||||||
|
{t('templateConfig.resetAllToDefaults')}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<!-- Slot filter -->
|
||||||
{#each filteredCmdSlots as slot}
|
{#if commandSlots.length > 4}
|
||||||
<CollapsibleSlot
|
<div>
|
||||||
label={slot.name}
|
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
|
||||||
description="/{slot.name} — {slot.description}"
|
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
expanded={expandedSlots.has(slot.name)}
|
</div>
|
||||||
status={getSlotStatus(slot.name)}
|
{/if}
|
||||||
ontoggle={() => toggleSlot(slot.name)}
|
|
||||||
>
|
{#each commandSlotGroups.filter(g => g.slots.length > 0) as group}
|
||||||
<div class="flex items-center justify-end gap-2 mb-2">
|
{@const filteredSlots = slotFilter ? group.slots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase())) : group.slots}
|
||||||
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
|
{#if filteredSlots.length > 0}
|
||||||
<button type="button" onclick={() => togglePreview(slot.name)}
|
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||||
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
|
<legend class="text-sm font-medium px-1">
|
||||||
{t('templateConfig.preview')}
|
{t(`cmdTemplateConfig.${group.group}`)}<Hint text={t(`hints.${group.group}`)} />
|
||||||
|
</legend>
|
||||||
|
<div class="space-y-2 mt-2">
|
||||||
|
{#each filteredSlots as slot}
|
||||||
|
<CollapsibleSlot
|
||||||
|
label={slot.name}
|
||||||
|
description="/{slot.name} — {slot.description}"
|
||||||
|
expanded={expandedSlots.has(slot.name)}
|
||||||
|
status={getSlotStatus(slot.name)}
|
||||||
|
ontoggle={() => toggleSlot(slot.name)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-end gap-2 mb-2">
|
||||||
|
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||||
|
<button type="button" onclick={() => togglePreview(slot.name)}
|
||||||
|
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
|
||||||
|
{t('templateConfig.preview')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if getVarsFor(slot.name)}
|
||||||
|
<button type="button" onclick={() => showVarsFor = slot.name}
|
||||||
|
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
|
||||||
|
title={t('templateConfig.resetToDefault')}
|
||||||
|
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||||
|
{t('templateConfig.resetToDefault')}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
|
||||||
{#if getVarsFor(slot.name)}
|
|
||||||
<button type="button" onclick={() => showVarsFor = slot.name}
|
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
|
||||||
{/if}
|
|
||||||
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
|
|
||||||
title={t('templateConfig.resetToDefault')}
|
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
|
||||||
{t('templateConfig.resetToDefault')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
|
|
||||||
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
|
|
||||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<JinjaEditor
|
|
||||||
value={getSlotValue(slot.name)}
|
|
||||||
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
|
||||||
rows={3}
|
|
||||||
errorLine={slotErrorLines[slot.name] || null}
|
|
||||||
variables={getVarsFor(slot.name) || undefined}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if slotErrors[slot.name]}
|
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
|
||||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
<JinjaEditor
|
||||||
|
value={getSlotValue(slot.name)}
|
||||||
|
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
||||||
|
rows={3}
|
||||||
|
errorLine={slotErrorLines[slot.name] || null}
|
||||||
|
variables={getVarsFor(slot.name) || undefined}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
</CollapsibleSlot>
|
{#if slotErrors[slot.name]}
|
||||||
{/each}
|
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||||
</div>
|
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||||
</fieldset>
|
{:else}
|
||||||
|
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</CollapsibleSlot>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</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')}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-core"
|
name = "notify-bridge-core"
|
||||||
version = "0.6.3"
|
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,40 +227,58 @@ 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)
|
||||||
if caption:
|
f.add_field("parse_mode", parse_mode)
|
||||||
form.add_field("caption", caption)
|
if caption:
|
||||||
if reply_to_message_id:
|
f.add_field("caption", caption)
|
||||||
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
if 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}"
|
||||||
try:
|
for attempt in range(1, _TG_429_MAX_ATTEMPTS + 1):
|
||||||
async with self._session.post(telegram_url, data=form) as response:
|
try:
|
||||||
result = await response.json()
|
async with self._session.post(telegram_url, data=_build_form()) as response:
|
||||||
if response.status == 200 and result.get("ok"):
|
result = await response.json()
|
||||||
res = result.get("result", {})
|
if response.status == 200 and result.get("ok"):
|
||||||
file_id = kind.file_id_from_result(res)
|
res = result.get("result", {})
|
||||||
if file_id and cache and cache_key:
|
file_id = kind.file_id_from_result(res)
|
||||||
await cache.async_set(
|
if file_id and cache and cache_key:
|
||||||
cache_key, file_id, kind.cache_type,
|
await cache.async_set(
|
||||||
thumbhash=thumbhash, size=len(data),
|
cache_key, file_id, kind.cache_type,
|
||||||
|
thumbhash=thumbhash, size=len(data),
|
||||||
|
)
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
return {"success": True, "message_id": res.get("message_id")}
|
await asyncio.sleep(wait_s)
|
||||||
|
continue
|
||||||
|
|
||||||
|
_LOGGER.error(
|
||||||
|
"Telegram %s failed: status=%s code=%s desc=%r bytes=%d",
|
||||||
|
kind.api_method, response.status, result.get("error_code"),
|
||||||
|
result.get("description", "Unknown"), len(data),
|
||||||
|
)
|
||||||
|
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Telegram %s failed: status=%s code=%s desc=%r bytes=%d",
|
"Telegram %s transport error (bytes=%d): %s",
|
||||||
kind.api_method, response.status, result.get("error_code"),
|
kind.api_method, len(data), err, exc_info=True,
|
||||||
result.get("description", "Unknown"), len(data),
|
|
||||||
)
|
)
|
||||||
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
|
return {"success": False, "error": str(err)}
|
||||||
except aiohttp.ClientError as err:
|
# All attempts exhausted via 429 — should be unreachable, but keep
|
||||||
_LOGGER.error(
|
# an explicit error path so we never return None.
|
||||||
"Telegram %s transport error (bytes=%d): %s",
|
return {"success": False, "error": "Telegram rate limit: max retries exhausted"}
|
||||||
kind.api_method, len(data), err, exc_info=True,
|
|
||||||
)
|
|
||||||
return {"success": False, "error": str(err)}
|
|
||||||
|
|
||||||
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:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
return asyncio.create_task(action_loop())
|
|
||||||
|
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:
|
||||||
|
pass
|
||||||
|
|
||||||
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,59 +752,86 @@ 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"
|
||||||
|
|
||||||
try:
|
def _build_form() -> FormData:
|
||||||
async with self._session.post(telegram_url, data=form) as response:
|
f = FormData()
|
||||||
result = await response.json()
|
f.add_field("chat_id", chat_id)
|
||||||
if response.status == 200 and result.get("ok"):
|
if reply_to_message_id and chunk_idx == 0:
|
||||||
result_msgs = result.get("result", [])
|
f.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
||||||
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
|
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:
|
||||||
|
async with self._session.post(telegram_url, data=_build_form()) as response:
|
||||||
|
result = await response.json()
|
||||||
|
if response.status == 200 and result.get("ok"):
|
||||||
|
result_msgs = result.get("result", [])
|
||||||
|
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
|
||||||
|
|
||||||
|
# Cache file_ids from response — map by position
|
||||||
|
cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
|
||||||
|
for i, msg in enumerate(result_msgs):
|
||||||
|
if i >= len(media_cache_info):
|
||||||
|
break
|
||||||
|
info = media_cache_info[i]
|
||||||
|
if info is None:
|
||||||
|
continue # was a cache hit, skip
|
||||||
|
ck, mt, th, sz = info
|
||||||
|
file_id = None
|
||||||
|
if msg.get("photo"):
|
||||||
|
file_id = msg["photo"][-1].get("file_id")
|
||||||
|
elif msg.get("video"):
|
||||||
|
file_id = msg["video"].get("file_id")
|
||||||
|
elif msg.get("document"):
|
||||||
|
file_id = msg["document"].get("file_id")
|
||||||
|
if file_id:
|
||||||
|
cache_entries.append((ck, file_id, mt, th, sz))
|
||||||
|
if cache_entries:
|
||||||
|
# All entries in a chunk share the same cache backend
|
||||||
|
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
|
||||||
|
if eff_cache:
|
||||||
|
await eff_cache.async_set_many(cache_entries)
|
||||||
|
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
|
||||||
|
|
||||||
# Cache file_ids from response — map by position
|
|
||||||
cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
|
|
||||||
for i, msg in enumerate(result_msgs):
|
|
||||||
if i >= len(media_cache_info):
|
|
||||||
break
|
|
||||||
info = media_cache_info[i]
|
|
||||||
if info is None:
|
|
||||||
continue # was a cache hit, skip
|
|
||||||
ck, mt, th, sz = info
|
|
||||||
file_id = None
|
|
||||||
if msg.get("photo"):
|
|
||||||
file_id = msg["photo"][-1].get("file_id")
|
|
||||||
elif msg.get("video"):
|
|
||||||
file_id = msg["video"].get("file_id")
|
|
||||||
elif msg.get("document"):
|
|
||||||
file_id = msg["document"].get("file_id")
|
|
||||||
if file_id:
|
|
||||||
cache_entries.append((ck, file_id, mt, th, sz))
|
|
||||||
if cache_entries:
|
|
||||||
# All entries in a chunk share the same cache backend
|
|
||||||
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
|
|
||||||
if eff_cache:
|
|
||||||
await eff_cache.async_set_many(cache_entries)
|
|
||||||
else:
|
|
||||||
_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,
|
||||||
}
|
}
|
||||||
except aiohttp.ClientError as err:
|
break
|
||||||
_LOGGER.error(
|
except aiohttp.ClientError as err:
|
||||||
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
|
_LOGGER.error(
|
||||||
chunk_idx + 1, len(chunks), len(media_json), err,
|
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
|
||||||
exc_info=True,
|
chunk_idx + 1, len(chunks), len(media_json), err,
|
||||||
)
|
exc_info=True,
|
||||||
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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-server"
|
name = "notify-bridge-server"
|
||||||
version = "0.6.3"
|
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 = [
|
||||||
|
|||||||
@@ -1391,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
|
||||||
|
|||||||
@@ -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