Compare commits

..

11 Commits

Author SHA1 Message Date
alexei.dolgolyov 349e9136a4 chore: release v0.6.5
Release / release (push) Successful in 1m36s
2026-04-28 19:10:49 +03:00
alexei.dolgolyov 04c8e3c8b2 feat(frontend): group command template slots into 4 logical fieldsets
Mirrors the notification-template page's group layout. Command slots
now split by name prefix into Command Responses, Error Messages
(rate_limited/no_results), Command Descriptions (desc_*), and Usage
Examples (usage_*). Language picker, reset-all, and slot filter are
hoisted above the groups so they apply across all fieldsets, and
empty groups are hidden so providers without usage_* don't render
empty headers.

Drops the orphan cmdTemplateConfig.commandResponsesHint i18n key —
hints.commandResponses replaces it.
2026-04-28 19:06:39 +03:00
alexei.dolgolyov 9afd38e50e fix(redesign): contain modal scroll chaining and smooth Telegram chat refresh
- Add overscroll-behavior: contain to all in-modal/popup scroll
  containers (Modal body, EntitySelect, MultiEntitySelect, IconPicker,
  IconGridSelect, SearchPalette, TimezoneSelector) so reaching the
  inner scroll boundary no longer scrolls the page underneath.
- Telegram bot Discover Chats no longer collapses the existing chat
  list into a "Loading…" placeholder. Split chatsLoading (initial)
  from chatsRefreshing (Discover); rows are keyed by chat.id with
  flip+fade animations; the list dims with a sweeping shimmer bar
  while the Discover button shows a spinning icon and "Discovering
  chats…" label. Honors prefers-reduced-motion.
2026-04-28 18:52:20 +03:00
alexei.dolgolyov aa9548d884 chore: release v0.6.4
Release / release (push) Successful in 1m41s
2026-04-27 18:27:09 +03:00
alexei.dolgolyov 72dd611f8c fix(telegram): respect chat_action UI choice, drop phantom indicator
chat_action was stored in two places — the model column and config JSON —
and dispatch_helpers unconditionally overrode the config value with the
column. The frontend only ever wrote the JSON path, so the UI choice
silently had no effect on outgoing chat actions.

Make the column the single source of truth: frontend sends chat_action
top-level, dispatch_helpers reads from the column, and a one-time
backfill migrates existing config values to the column and strips the
legacy key.

Also fix a long-standing race where the keepalive's bare sleep(4) +
finally cancel could fire one last sendChatAction after the response
already arrived, leaving a phantom indicator for ~5s. Replace with a
stop event + wait_for so callers can signal stop cleanly via the new
stop_keepalive helper.
2026-04-27 18:20:50 +03:00
alexei.dolgolyov 0e675c4b38 chore: release v0.6.3
Release / release (push) Successful in 1m15s
2026-04-27 15:42:04 +03:00
alexei.dolgolyov 4307955163 feat(frontend): inject __APP_VERSION__ from package.json at build time
- vite.config.ts: read package.json and expose its version as a
  build-time global via Vite's `define`.
- app.d.ts: add ambient declaration so the layout's brand version
  badge (`v{__APP_VERSION__}`) type-checks.
2026-04-27 15:38:10 +03:00
alexei.dolgolyov b107b01a00 fix(redesign): prevent theme FOUC and sidebar jump on hard reload
- app.html: inline blocking script resolves the theme from localStorage
  (or prefers-color-scheme) and sets data-theme on <html> before first
  paint, eliminating the dark→light transition users saw when the light
  theme was selected.
- +layout.svelte: hydrate sidebar collapsed state and expanded nav groups
  synchronously in their $state initializers instead of inside onMount,
  so the sidebar no longer snaps from expanded→collapsed and groups no
  longer slide open after mount.
- +layout.svelte: keep the global provider-filter row rendered while
  providersCache.fetchedAt === 0, so the row doesn't pop in mid-paint
  and push the nav down once the cache resolves.
2026-04-27 15:38:03 +03:00
alexei.dolgolyov 42af7a6551 feat(trackers): user filters for Gitea, webhook polling cleanup, dashboard navigability
- Gitea: NotificationTracker now exposes sender allowlist / blocklist filters
  via MultiEntitySelect, populated from Gitea /users/search merged with past
  EventLog senders so the picker is useful before the first webhook arrives.
- Webhook providers (gitea, planka, webhook): stop scheduling interval polling
  jobs on tracker create/update/startup; hide the "every Xs" indicator in the
  tracker list since there is no polling.
- Dashboard: 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.
- Command trackers / command configs: auto-reselect the right config when the
  provider type changes (matches notification-tracker behavior).
- Migration: drop legacy batch_duration column from notification_tracker —
  the field is gone from the model but its NOT NULL constraint blocked
  inserts on older DBs.
- Docs: refresh entity-relationships.md with current NotificationTracker
  fields (filters, adaptive_max_skip, default_*_config_id).
2026-04-27 15:24:44 +03:00
alexei.dolgolyov c43dc598a1 chore: release v0.6.2
Release / release (push) Successful in 1m39s
2026-04-27 14:29:44 +03:00
alexei.dolgolyov 1bfec521d8 fix(redesign): EntitySelect for language pickers + portal Timezone picker
- Template editors (notification & command) now use EntitySelect for
  locale switching and default to the configured primary locale
  instead of always 'en' when opening, editing, or cloning a config.
- LocaleSelector's add-flow uses EntitySelect for catalog pick;
  custom BCP-47 codes (e.g. de-CH) keep a small dedicated input.
- 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).
- Removed top padding on the timezone scroll list so sticky region
  group headers no longer leak rows above them.
- Extracted shared locale catalog to lib/locales.ts.
2026-04-27 14:18:58 +03:00
40 changed files with 1306 additions and 669 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# Entity Relationships
```
```text
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
TrackingConfig → provider_type, event flags, scheduling rules
TemplateConfig → provider_type, Jinja2 template slots per event type
+26 -3
View File
@@ -1,9 +1,32 @@
# v0.6.1 (2026-04-25)
# v0.6.5 (2026-04-28)
Small visual follow-up to the Aurora redesign: the **Active Wires pipe** on the dashboard now reads more prominently.
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
### 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
- Make Active Wires pipe visually prominent ([cc8d961](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cc8d961))
- **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))
- **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))
## Development / Internal
### i18n
- 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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.6.1",
"version": "0.6.5",
"type": "module",
"scripts": {
"dev": "vite dev",
+16
View File
@@ -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 {};
+17
View File
@@ -5,6 +5,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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%
</head>
<body data-sveltekit-preload-data="hover">
@@ -304,6 +304,7 @@
/* List */
.ep-list {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.35rem;
position: relative;
@@ -195,6 +195,7 @@
padding: 0.5rem;
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
:global([data-theme="light"]) .icon-grid-popup { --igs-solid-bg: #fafafe; }
@@ -188,6 +188,7 @@
max-height: 14rem;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: thin;
position: relative;
z-index: 1;
+103 -279
View File
@@ -1,48 +1,10 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import { LOCALE_CATALOG, getLocaleMeta, type LocaleMeta } from '$lib/locales';
import EntitySelect, { type EntityItem } from './EntitySelect.svelte';
interface LocaleMeta {
code: string;
name: string; // English name
native: string; // Native script
rtl?: boolean;
}
const CATALOG: LocaleMeta[] = [
{ code: 'en', name: 'English', native: 'English' },
{ code: 'ru', name: 'Russian', native: 'Русский' },
{ code: 'de', name: 'German', native: 'Deutsch' },
{ code: 'fr', name: 'French', native: 'Français' },
{ code: 'es', name: 'Spanish', native: 'Español' },
{ code: 'it', name: 'Italian', native: 'Italiano' },
{ code: 'pt', name: 'Portuguese', native: 'Português' },
{ code: 'pl', name: 'Polish', native: 'Polski' },
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
{ code: 'da', name: 'Danish', native: 'Dansk' },
{ code: 'cs', name: 'Czech', native: 'Čeština' },
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
{ code: 'ro', name: 'Romanian', native: 'Română' },
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
{ code: 'sr', name: 'Serbian', native: 'Српски' },
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
{ code: 'zh', name: 'Chinese', native: '中文' },
{ code: 'ja', name: 'Japanese', native: '日本語' },
{ code: 'ko', name: 'Korean', native: '한국어' },
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
{ code: 'th', name: 'Thai', native: 'ไทย' },
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
];
const CATALOG: LocaleMeta[] = LOCALE_CATALOG;
// Locales that ship with default notification & command templates.
const SHIPPED = new Set(['en', 'ru']);
@@ -76,11 +38,7 @@
}
function meta(code: string): LocaleMeta {
return CATALOG.find(l => l.code === code) ?? {
code,
name: code.toUpperCase(),
native: code.toUpperCase(),
};
return getLocaleMeta(code);
}
function remove(code: string) {
@@ -109,79 +67,48 @@
// --- Add flow ----------------------------------------------------------
let addOpen = $state(false);
let addQuery = $state('');
let addInputEl = $state<HTMLInputElement | null>(null);
let highlightIdx = $state(0);
// Valid BCP 47-ish: 23 letter primary, optional '-' subtag(s) 2-8 chars.
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
const selectedSet = $derived(new Set(codes));
const suggestions = $derived.by(() => {
const q = addQuery.trim().toLowerCase();
const available = CATALOG.filter(l => !selectedSet.has(l.code));
if (!q) return available;
return available.filter(l =>
l.code.includes(q)
|| l.name.toLowerCase().includes(q)
|| l.native.toLowerCase().includes(q),
);
});
/**
* Catalog languages not yet selected, surfaced through EntitySelect.
* Native name is the label so the user sees their own script; the
* English name + code lives in the description for searchability.
*/
const addItems = $derived<EntityItem[]>(
CATALOG
.filter(l => !selectedSet.has(l.code))
.map(l => ({
value: l.code,
label: l.native,
desc: `${l.name} · ${l.code.toUpperCase()}`,
})),
);
const canAddCustom = $derived.by(() => {
const q = addQuery.trim().toLowerCase();
if (!q) return false;
if (!CUSTOM_RE.test(q)) return false;
if (selectedSet.has(q)) return false;
// Skip "custom" entry when it matches an existing catalog entry exactly.
if (CATALOG.some(l => l.code === q)) return false;
let customCode = $state('');
const customCodeValid = $derived.by(() => {
const c = customCode.trim().toLowerCase();
if (!c || !CUSTOM_RE.test(c)) return false;
if (selectedSet.has(c)) return false;
if (CATALOG.some(l => l.code === c)) return false;
return true;
});
function openAdd() {
addOpen = true;
addQuery = '';
highlightIdx = 0;
requestAnimationFrame(() => addInputEl?.focus());
}
function closeAdd() {
addOpen = false;
addQuery = '';
}
function addCode(code: string) {
const c = code.trim().toLowerCase();
function addCode(code: string | number | null) {
if (code === null) return;
const c = String(code).trim().toLowerCase();
if (!c) return;
commit([...codes, c]);
addQuery = '';
highlightIdx = 0;
requestAnimationFrame(() => addInputEl?.focus());
}
function onAddKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { closeAdd(); return; }
const total = suggestions.length + (canAddCustom ? 1 : 0);
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, Math.max(0, total - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
if (highlightIdx < suggestions.length) {
addCode(suggestions[highlightIdx].code);
} else if (canAddCustom) {
addCode(addQuery);
}
}
function addCustom() {
if (!customCodeValid) return;
addCode(customCode);
customCode = '';
}
$effect(() => { addQuery; highlightIdx = 0; });
// --- Drag & drop -------------------------------------------------------
let dragCode = $state<string | null>(null);
@@ -329,77 +256,39 @@
</ul>
{/if}
<!-- Add zone -->
<div class="ls-add" class:ls-add-open={addOpen}>
{#if !addOpen}
<button type="button" class="ls-add-trigger" onclick={openAdd}>
<MdiIcon name="mdiPlus" size={14} />
<span>{t('locales.add')}</span>
</button>
{:else}
<div class="ls-add-panel">
<div class="ls-add-input-row">
<MdiIcon name="mdiMagnify" size={14} />
<input
bind:this={addInputEl}
bind:value={addQuery}
onkeydown={onAddKeydown}
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)}
placeholder={t('locales.searchPlaceholder')}
class="ls-add-input"
autocomplete="off"
spellcheck="false"
type="text"
/>
<button type="button" class="ls-icon-btn" onclick={closeAdd} aria-label={t('common.cancel')}>
<MdiIcon name="mdiClose" size={14} />
</button>
</div>
<div class="ls-add-list" role="listbox">
{#each suggestions as s, i (s.code)}
<button
type="button"
role="option"
aria-selected={i === highlightIdx}
class="ls-sugg"
class:ls-sugg-hl={i === highlightIdx}
onmouseenter={() => highlightIdx = i}
onmousedown={(e) => { e.preventDefault(); addCode(s.code); }}
>
<span class="ls-sugg-native" dir={s.rtl ? 'rtl' : 'ltr'} lang={s.code}>{s.native}</span>
<span class="ls-sugg-name">{s.name}</span>
<span class="ls-sugg-code">{s.code}</span>
{#if SHIPPED.has(s.code)}
<span class="ls-sugg-shipped" title={t('locales.shippedHint')}>
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
</span>
{/if}
</button>
{/each}
{#if canAddCustom}
<button
type="button"
role="option"
aria-selected={highlightIdx === suggestions.length}
class="ls-sugg ls-sugg-custom"
class:ls-sugg-hl={highlightIdx === suggestions.length}
onmouseenter={() => highlightIdx = suggestions.length}
onmousedown={(e) => { e.preventDefault(); addCode(addQuery); }}
>
<MdiIcon name="mdiPlusCircleOutline" size={14} />
<span class="ls-sugg-custom-label">{t('locales.addCustom')}</span>
<span class="ls-sugg-code">{addQuery.trim().toLowerCase()}</span>
</button>
{/if}
{#if suggestions.length === 0 && !canAddCustom}
<div class="ls-sugg-empty">{t('locales.noSuggestions')}</div>
{/if}
</div>
<!-- Add zone — EntitySelect for catalog languages, separate input for custom BCP-47 codes -->
<div class="ls-add">
<div class="ls-add-row">
<div class="ls-add-picker">
<EntitySelect
items={addItems}
value={null}
placeholder={t('locales.add')}
size="sm"
onselect={addCode}
/>
</div>
{/if}
<div class="ls-add-custom">
<input
type="text"
bind:value={customCode}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
placeholder={t('locales.customPlaceholder')}
class="ls-add-custom-input"
autocomplete="off"
spellcheck="false"
/>
<button
type="button"
class="ls-add-custom-btn"
disabled={!customCodeValid}
onclick={addCustom}
title={t('locales.addCustom')}
>
<MdiIcon name="mdiPlus" size={14} />
</button>
</div>
</div>
</div>
<p class="ls-hint">
@@ -630,125 +519,60 @@
.ls-add {
margin-top: 0.125rem;
}
.ls-add-trigger {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border: 1px dashed var(--color-border);
border-radius: 0.375rem;
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.ls-add-trigger:hover {
border-color: var(--color-primary);
border-style: solid;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
}
.ls-add-panel {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-background);
overflow: hidden;
animation: ls-pop 0.15s ease-out;
}
@keyframes ls-pop {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
.ls-add-input-row {
.ls-add-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground);
flex-wrap: wrap;
}
.ls-add-input {
.ls-add-picker {
flex: 1;
min-width: 12rem;
}
.ls-add-custom {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.15rem 0.15rem 0.55rem;
border: 1px dashed var(--color-border);
border-radius: 0.5rem;
background: transparent;
}
.ls-add-custom-input {
width: 6rem;
border: none;
outline: none;
background: transparent;
font-size: 0.8rem;
color: var(--color-foreground);
padding: 0.125rem 0;
min-width: 0;
}
.ls-add-list {
max-height: 14rem;
overflow-y: auto;
scrollbar-width: thin;
}
.ls-sugg {
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.375rem 0.625rem;
border: none;
background: transparent;
color: var(--color-foreground);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.ls-sugg.ls-sugg-hl {
background: var(--color-muted);
}
.ls-sugg-native {
font-size: 0.9rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ls-sugg-name {
font-size: 0.7rem;
color: var(--color-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
.ls-sugg-code {
font-family: var(--font-mono);
font-size: 0.7rem;
padding: 0.05rem 0.375rem;
border-radius: 0.25rem;
background: var(--color-muted);
font-size: 0.75rem;
color: var(--color-foreground);
padding: 0.25rem 0;
}
.ls-add-custom-input::placeholder {
color: var(--color-muted-foreground);
opacity: 0.7;
}
.ls-sugg.ls-sugg-hl .ls-sugg-code {
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
}
.ls-sugg-shipped {
.ls-add-custom-btn {
display: inline-flex;
align-items: center;
color: var(--color-primary);
opacity: 0.85;
}
.ls-sugg-custom {
border-top: 1px dashed var(--color-border);
color: var(--color-primary);
}
.ls-sugg-custom-label {
font-size: 0.75rem;
font-weight: 500;
}
.ls-sugg-empty {
padding: 0.75rem;
font-size: 0.75rem;
text-align: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
background: transparent;
border-radius: 0.25rem;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.ls-add-custom-btn:hover:not(:disabled) {
background: var(--color-muted);
color: var(--color-primary);
}
.ls-add-custom-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ---- Hint --------------------------------------------------------- */
+1
View File
@@ -192,6 +192,7 @@
z-index: 1;
padding: 0 1.5rem 1.5rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.modal-close {
@@ -307,6 +307,7 @@
.mes-list {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.25rem 0;
}
@@ -342,6 +342,7 @@
.sp-results {
max-height: 52vh;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.35rem;
position: relative;
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import { portal } from '$lib/portal';
let {
value = $bindable<string>('UTC'),
@@ -172,18 +173,12 @@
$effect(() => { query; highlightIdx = 0; });
// Close on outside click
function onDocClick(e: MouseEvent) {
if (!open) return;
const target = e.target as Node;
if (panelEl && !panelEl.contains(target)) closePicker();
}
onMount(() => {
document.addEventListener('mousedown', onDocClick);
});
onDestroy(() => {
document.removeEventListener('mousedown', onDocClick);
});
/**
* The panel is portalled to <body> to escape Card's overflow:hidden +
* backdrop-filter (which would otherwise clip and stacking-trap the
* dropdown). Outside-click is detected via the dedicated overlay div
* rather than a document listener, so we don't need a global handler.
*/
</script>
<div class="tz-root">
@@ -217,83 +212,87 @@
</button>
{#if open}
<div class="tz-panel" bind:this={panelEl} role="listbox">
<!-- Search -->
<div class="tz-search-row">
<MdiIcon name="mdiMagnify" size={14} />
<input
bind:this={inputEl}
bind:value={query}
onkeydown={onKeydown}
placeholder={t('timezone.searchPlaceholder')}
class="tz-search"
autocomplete="off"
spellcheck="false"
type="text"
/>
<kbd class="tz-kbd">ESC</kbd>
</div>
<div use:portal class="tz-portal-root">
<div class="tz-overlay" onclick={closePicker} role="presentation"></div>
<!-- Quick picks -->
{#if !query}
<div class="tz-quick">
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === detectedTz}
onclick={() => selectTz(detectedTz)}
>
<MdiIcon name="mdiCrosshairsGps" size={12} />
<span class="tz-quick-label">{t('timezone.detect')}</span>
<span class="tz-quick-val">{detectedTz}</span>
</button>
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
onclick={() => selectTz('UTC')}
>
<MdiIcon name="mdiEarth" size={12} />
<span class="tz-quick-label">{t('timezone.utc')}</span>
<span class="tz-quick-val">UTC+00</span>
</button>
<div class="tz-panel" bind:this={panelEl} role="listbox">
<!-- Search -->
<div class="tz-search-row">
<MdiIcon name="mdiMagnify" size={14} />
<input
bind:this={inputEl}
bind:value={query}
onkeydown={onKeydown}
placeholder={t('timezone.searchPlaceholder')}
class="tz-search"
autocomplete="off"
spellcheck="false"
type="text"
/>
<kbd class="tz-kbd">ESC</kbd>
</div>
{/if}
<!-- Grouped list -->
<div class="tz-list">
{#if filtered.length === 0}
<div class="tz-empty">{t('timezone.noMatches')}</div>
{:else}
{#each groups as g (g.region)}
<div class="tz-group">
<div class="tz-group-head">
<span class="tz-group-name">{g.region}</span>
<span class="tz-group-count">{g.items.length}</span>
</div>
{#each g.items as tz (tz)}
{@const parts = splitTz(tz)}
{@const idx = flat.indexOf(tz)}
{@const hl = idx === highlightIdx}
{@const sel = tz === value}
<button
type="button"
role="option"
aria-selected={sel}
class="tz-opt"
class:tz-opt-hl={hl}
class:tz-opt-sel={sel}
onmouseenter={() => (highlightIdx = idx)}
onclick={() => selectTz(tz)}
>
<span class="tz-opt-city">{parts.city}</span>
<span class="tz-opt-iana">{tz}</span>
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
</button>
{/each}
</div>
{/each}
<!-- Quick picks -->
{#if !query}
<div class="tz-quick">
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === detectedTz}
onclick={() => selectTz(detectedTz)}
>
<MdiIcon name="mdiCrosshairsGps" size={12} />
<span class="tz-quick-label">{t('timezone.detect')}</span>
<span class="tz-quick-val">{detectedTz}</span>
</button>
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
onclick={() => selectTz('UTC')}
>
<MdiIcon name="mdiEarth" size={12} />
<span class="tz-quick-label">{t('timezone.utc')}</span>
<span class="tz-quick-val">UTC+00</span>
</button>
</div>
{/if}
<!-- Grouped list -->
<div class="tz-list">
{#if filtered.length === 0}
<div class="tz-empty">{t('timezone.noMatches')}</div>
{:else}
{#each groups as g (g.region)}
<div class="tz-group">
<div class="tz-group-head">
<span class="tz-group-name">{g.region}</span>
<span class="tz-group-count">{g.items.length}</span>
</div>
{#each g.items as tz (tz)}
{@const parts = splitTz(tz)}
{@const idx = flat.indexOf(tz)}
{@const hl = idx === highlightIdx}
{@const sel = tz === value}
<button
type="button"
role="option"
aria-selected={sel}
class="tz-opt"
class:tz-opt-hl={hl}
class:tz-opt-sel={sel}
onmouseenter={() => (highlightIdx = idx)}
onclick={() => selectTz(tz)}
>
<span class="tz-opt-city">{parts.city}</span>
<span class="tz-opt-iana">{tz}</span>
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
</button>
{/each}
</div>
{/each}
{/if}
</div>
</div>
</div>
{/if}
@@ -408,35 +407,66 @@
align-items: center;
}
/* ---- Panel -------------------------------------------------------- */
.tz-panel {
/* ---- Portal + overlay (escapes Card's overflow:hidden / backdrop-filter) ---- */
.tz-portal-root {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
}
.tz-overlay {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 20;
background: var(--color-card, var(--color-background));
border: 1px solid var(--color-border);
border-radius: 0.625rem;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
inset: 0;
pointer-events: auto;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
}
/* ---- Panel (centered modal palette) -------------------------------- */
.tz-panel {
pointer-events: auto;
position: absolute;
top: min(20vh, 120px);
left: 50%;
transform: translateX(-50%);
z-index: 1;
width: min(540px, 92vw);
max-height: min(60vh, 30rem);
background: var(--tz-solid-bg);
border: 1px solid var(--color-rule-strong, var(--color-border));
border-radius: 16px;
box-shadow: var(--shadow-card, 0 18px 40px rgba(0, 0, 0, 0.35)),
0 24px 48px -16px rgba(0, 0, 0, 0.55);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 26rem;
animation: tz-pop 0.15s ease-out;
--tz-solid-bg: #131520;
}
:global([data-theme="light"]) .tz-panel { --tz-solid-bg: #fafafe; }
.tz-panel::after {
content: '';
position: absolute; inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight, transparent), transparent 30%);
opacity: 0.4;
}
@keyframes tz-pop {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
from { opacity: 0; transform: translate(-50%, -3px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
.tz-search-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground);
position: relative;
z-index: 1;
}
.tz-search {
flex: 1;
@@ -464,6 +494,8 @@
padding: 0.5rem 0.625rem;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
position: relative;
z-index: 1;
}
.tz-quick-btn {
display: inline-flex;
@@ -498,8 +530,14 @@
.tz-list {
overflow-y: auto;
padding: 0.25rem 0;
overscroll-behavior: contain;
/* No top padding — the sticky group head is at top:0 of the
scroll container, so any padding-top would let scrolling
items leak into the gap above the sticky header. */
padding: 0 0 0.25rem;
scrollbar-width: thin;
position: relative;
z-index: 1;
}
.tz-empty {
padding: 1rem;
@@ -523,7 +561,7 @@
color: var(--color-muted-foreground);
position: sticky;
top: 0;
background: var(--color-card, var(--color-background));
background: var(--tz-solid-bg);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
z-index: 1;
}
+14 -2
View File
@@ -246,6 +246,9 @@
"selectAlbums": "Select albums...",
"repositories": "Repositories",
"selectRepositories": "Select repositories...",
"userAllowlist": "Only from users",
"userBlocklist": "Exclude users",
"selectUsers": "Pick users...",
"boards": "Boards",
"selectBoards": "Select boards...",
"upsDevices": "UPS Devices",
@@ -472,6 +475,7 @@
"noCommandsForProvider": "This provider type does not support bot commands.",
"syncCommands": "Sync Commands",
"discoverChats": "Discover chats from Telegram",
"discoveringChats": "Discovering chats…",
"clickToCopy": "Click to copy chat ID",
"chatsDiscovered": "Chats discovered",
"chatDeleted": "Chat removed",
@@ -627,6 +631,7 @@
"countLabel": "templates",
"title": "Template Configs",
"description": "Define how notification messages are formatted",
"language": "Language",
"providerType": "Service Provider Type",
"newConfig": "New Config",
"name": "Name",
@@ -818,7 +823,11 @@
"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.",
"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": {
"titleEmphasis": "matrix",
@@ -871,7 +880,9 @@
"noConfigs": "No command template configs yet.",
"confirmDelete": "Delete this command template config?",
"commandResponses": "Command Responses",
"commandResponsesHint": "Leave a slot empty to use the default hardcoded response."
"commandErrors": "Error Messages",
"commandDescriptions": "Command Descriptions",
"commandUsage": "Usage Examples"
},
"commandConfig": {
"titleEmphasis": "configs",
@@ -940,6 +951,7 @@
"empty": "No languages selected. Add one below to start authoring templates.",
"add": "Add language",
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
"customPlaceholder": "or de-CH",
"addCustom": "Add custom code",
"noSuggestions": "No matches. Type a valid locale code (23 letters).",
"primary": "Primary",
+14 -2
View File
@@ -246,6 +246,9 @@
"selectAlbums": "Выберите альбомы...",
"repositories": "Репозитории",
"selectRepositories": "Выберите репозитории...",
"userAllowlist": "Только от пользователей",
"userBlocklist": "Исключить пользователей",
"selectUsers": "Выберите пользователей...",
"boards": "Доски",
"selectBoards": "Выберите доски...",
"upsDevices": "ИБП устройства",
@@ -472,6 +475,7 @@
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
"syncCommands": "Синхр. команды",
"discoverChats": "Обнаружить чаты из Telegram",
"discoveringChats": "Поиск чатов…",
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
"chatsDiscovered": "Чаты обнаружены",
"chatDeleted": "Чат удалён",
@@ -627,6 +631,7 @@
"countLabel": "шаблонов",
"title": "Конфигурации шаблонов",
"description": "Определите формат уведомлений",
"language": "Язык",
"providerType": "Тип сервис-провайдера",
"newConfig": "Новая конфигурация",
"name": "Название",
@@ -818,7 +823,11 @@
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений.",
"commandResponses": "Шаблоны ответов на каждую /команду. Используйте {переменные} для динамических данных.",
"commandErrors": "Резервные сообщения, когда команда не может выполниться (превышен лимит) или ничего не возвращает.",
"commandDescriptions": "Короткие подписи в меню команд Telegram, которые показываются рядом с каждой /командой.",
"commandUsage": "Примеры вызовов, отображаемые в /help, чтобы показать пользователям как вызывать каждую команду."
},
"matrixBot": {
"titleEmphasis": "matrix",
@@ -871,7 +880,9 @@
"noConfigs": "Шаблонов команд пока нет.",
"confirmDelete": "Удалить этот шаблон команд?",
"commandResponses": "Ответы команд",
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
"commandErrors": "Сообщения об ошибках",
"commandDescriptions": "Описания команд",
"commandUsage": "Примеры использования"
},
"commandConfig": {
"titleEmphasis": "конфигурации",
@@ -940,6 +951,7 @@
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
"customPlaceholder": "или de-CH",
"addCustom": "Добавить свой код",
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
"primary": "Основной",
+55
View File
@@ -0,0 +1,55 @@
/**
* Shared locale catalog used by LocaleSelector (settings) and the
* template editors (notification & command). Single source of truth so
* native names and metadata stay consistent across pickers.
*/
export interface LocaleMeta {
code: string;
name: string; // English name
native: string; // Native script
rtl?: boolean;
}
export const LOCALE_CATALOG: LocaleMeta[] = [
{ code: 'en', name: 'English', native: 'English' },
{ code: 'ru', name: 'Russian', native: 'Русский' },
{ code: 'de', name: 'German', native: 'Deutsch' },
{ code: 'fr', name: 'French', native: 'Français' },
{ code: 'es', name: 'Spanish', native: 'Español' },
{ code: 'it', name: 'Italian', native: 'Italiano' },
{ code: 'pt', name: 'Portuguese', native: 'Português' },
{ code: 'pl', name: 'Polish', native: 'Polski' },
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
{ code: 'da', name: 'Danish', native: 'Dansk' },
{ code: 'cs', name: 'Czech', native: 'Čeština' },
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
{ code: 'ro', name: 'Romanian', native: 'Română' },
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
{ code: 'sr', name: 'Serbian', native: 'Српски' },
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
{ code: 'zh', name: 'Chinese', native: '中文' },
{ code: 'ja', name: 'Japanese', native: '日本語' },
{ code: 'ko', name: 'Korean', native: '한국어' },
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
{ code: 'th', name: 'Thai', native: 'ไทย' },
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
];
export function getLocaleMeta(code: string): LocaleMeta {
return LOCALE_CATALOG.find(l => l.code === code) ?? {
code,
name: code.toUpperCase(),
native: code.toUpperCase(),
};
}
+15
View File
@@ -56,5 +56,20 @@ export const giteaDescriptor: ProviderDescriptor = {
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}',
};
+21
View File
@@ -120,6 +120,25 @@ export interface CollectionMeta {
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 ──────────────────────────────────────────────────
export interface ProviderDescriptor {
@@ -153,6 +172,8 @@ export interface ProviderDescriptor {
// ── Collections / Trackers ──
/** Null means this provider has no collections (e.g. scheduler). */
collectionMeta: CollectionMeta | null;
/** Sender allowlist / blocklist pickers shown on the tracker form. */
userFilters?: UserFilterMeta[];
/** Whether this provider is webhook-based (hides scan_interval). */
webhookBased?: boolean;
+1
View File
@@ -106,6 +106,7 @@ export interface NotificationTarget {
name: string;
icon: string;
config: Record<string, any>;
chat_action?: string | null;
chat_name?: string;
receiver_count: number;
receivers: TargetReceiver[];
+31 -12
View File
@@ -38,6 +38,11 @@
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
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
$effect(() => {
const v = providerFilterValue;
@@ -78,7 +83,24 @@
} 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));
// Nav counts — computed reactively from caches + global provider filter
@@ -216,7 +238,7 @@
};
// 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) {
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
@@ -262,13 +284,8 @@
onMount(async () => {
initTheme();
if (typeof localStorage !== 'undefined') {
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
try {
const saved = localStorage.getItem('nav_expanded');
if (saved) expandedGroups = JSON.parse(saved);
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
}
// `collapsed` and `expandedGroups` are now hydrated synchronously in
// their $state initializers above to avoid a post-mount layout snap.
await loadUser();
if (!auth.user && !isAuthPage) {
redirecting = true;
@@ -384,7 +401,7 @@
{/if}
Notify Bridge
</h1>
<p class="brand-version font-mono">v0.5.2</p>
<p class="brand-version font-mono">v{__APP_VERSION__}</p>
</div>
</div>
{:else}
@@ -398,8 +415,10 @@
</button>
</div>
<!-- Global provider filter -->
{#if allProviders.length >= 1}
<!-- Global provider filter — kept rendered during the initial cache
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);">
{#if collapsed}
<button onclick={() => {
+34 -8
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { goto } from '$app/navigation';
import { api, parseDate } from '$lib/api';
import { requestHighlight } from '$lib/highlight';
import { t } from '$lib/i18n';
import {
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 {
const diff = Date.now() - parseDate(dateStr).getTime();
const mins = Math.floor(diff / 60000);
@@ -424,8 +439,8 @@
</section>
<!-- ==================== STATS ==================== -->
{#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string}, idx: number)}
<div class="stat-card" style="--accent: {card.accent}">
{#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)}
<a class="stat-card" style="--accent: {card.accent}" href={card.href} onclick={card.onclick}>
<div class="stat-card-inner">
<div class="flex items-center gap-3">
<div class="stat-icon" style="color: {card.accent};">
@@ -439,7 +454,7 @@
</div>
</div>
</div>
</div>
</a>
{/snippet}
{#snippet statCards()}
@@ -452,6 +467,7 @@
value: 0,
literalValue: globalProviderFilter.provider.name,
accent: STAT_ACCENTS[0],
href: '/providers',
}, 0)}
{:else}
{@render statCardSnippet({
@@ -459,6 +475,7 @@
label: 'dashboard.providers',
value: filteredProviderCount,
accent: STAT_ACCENTS[0],
href: '/providers',
}, 0)}
{/if}
{@render statCardSnippet({
@@ -467,12 +484,14 @@
value: displayActive,
suffix: ` / ${displayTotal}`,
accent: STAT_ACCENTS[1],
href: '/notification-trackers',
}, 1)}
{@render statCardSnippet({
icon: 'mdiTarget',
label: 'dashboard.targets',
value: displayTargets,
accent: STAT_ACCENTS[2],
href: '/targets',
}, 2)}
{#if status?.command_trackers !== undefined}
{@render statCardSnippet({
@@ -480,6 +499,7 @@
label: 'nav.commandTrackers',
value: displayCommandTrackers,
accent: STAT_ACCENTS[3],
href: '/command-trackers',
}, 3)}
{:else}
{@render statCardSnippet({
@@ -487,6 +507,8 @@
label: 'dashboard.eventsTotal',
value: heroSummary?.throughput ?? 0,
accent: STAT_ACCENTS[3],
href: '#events-section',
onclick: scrollToEvents,
}, 3)}
{/if}
</div>
@@ -496,7 +518,7 @@
<!-- ==================== TWO COL: stream + provider deck ==================== -->
<div class="two-col" class:two-col--single={!!globalProviderFilter.id}>
<!-- Signal stream -->
<section class="panel">
<section class="panel" id="events-section">
<header class="panel-head">
<div>
<h2 class="panel-title">{t('dashboard.streamTitle')} <em>{t('dashboard.streamEmphasis')}</em></h2>
@@ -646,14 +668,14 @@
{:else}
<div class="provider-deck">
{#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">
<MdiIcon name={p.icon} size={20} />
</div>
<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}
{p.name}
<span class="truncate min-w-0">{p.name}</span>
</div>
<div class="provider-sub font-mono">
{p.descriptor?.defaultName ?? p.type} · {p.trackerCount} {t('dashboard.trackersShort')}
@@ -909,6 +931,7 @@
============================================================ */
.stat-card {
position: relative;
display: block;
border-radius: 22px;
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
@@ -918,7 +941,10 @@
overflow: hidden;
transition: transform 0.25s cubic-bezier(.4,.4,0,1);
cursor: pointer;
text-decoration: none;
color: inherit;
}
.stat-card:hover { text-decoration: none; }
.stat-card::before {
content: '';
position: absolute;
@@ -1282,6 +1308,7 @@
.provider-meter {
text-align: right;
min-width: 80px;
padding: 4px 4px 4px 0;
}
.provider-num {
font-size: 1rem;
@@ -1295,7 +1322,6 @@
height: 4px;
border-radius: 2px;
background: var(--color-glass-strong);
overflow: hidden;
}
.provider-bar-fill {
height: 100%;
+145 -56
View File
@@ -1,5 +1,6 @@
<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 BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
@@ -35,6 +36,10 @@
// Per-bot expandable sections
let chats = $state<Record<number, TelegramChat[]>>({});
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>>({});
// Webhook status per bot
@@ -98,12 +103,13 @@
}
async function discoverChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
if (chatsRefreshing[botId]) return;
chatsRefreshing = { ...chatsRefreshing, [botId]: true };
try {
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
chatsLoading = { ...chatsLoading, [botId]: false };
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
}
async function deleteChat(botId: number, chatDbId: number) {
@@ -371,66 +377,80 @@
<!-- Chats section -->
{#if expandedSection[bot.id] === 'chats'}
<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>
{: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>
{:else}
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
<!-- Header -->
<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)]">
<span>{t('telegramBot.chatName')}</span>
<span style="text-align:center">{t('telegramBot.chatType')}</span>
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
<span style="text-align:center">{t('telegramBot.cmds')}</span>
<span style="text-align:center">{t('telegramBot.chatId')}</span>
<span></span>
</div>
<!-- Rows -->
{#each chats[bot.id] as chat}
<div style={gridStyle}
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 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 -->
<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)]">
<span>{t('telegramBot.chatName')}</span>
<span style="text-align:center">{t('telegramBot.chatType')}</span>
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
<span style="text-align:center">{t('telegramBot.cmds')}</span>
<span style="text-align:center">{t('telegramBot.chatId')}</span>
<span></span>
</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}
<button onclick={() => discoverChats(bot.id)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
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} />
</span>
{chatsRefreshing[bot.id] ? t('telegramBot.discoveringChats') : t('telegramBot.discoverChats')}
</button>
</div>
{/if}
@@ -553,3 +573,72 @@
<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;
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) {
form = {
name: cfg.name,
@@ -20,6 +20,9 @@
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.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 { getLocaleMeta } from '$lib/locales';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
@@ -41,6 +44,7 @@
}
let LOCALES = $derived(supportedLocalesCache.items);
let primaryLocale = $derived(LOCALES[0] || 'en');
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
let filterText = $state('');
@@ -73,7 +77,18 @@
});
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
let activeLocale = $state<string>('en');
let activeLocale = $state<string>('');
const localeItems = $derived<EntityItem[]>(LOCALES.map((code, i) => {
const m = getLocaleMeta(code);
return {
value: code,
label: m.native,
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
};
}));
$effect(() => {
if (!activeLocale && LOCALES.length > 0) activeLocale = primaryLocale;
});
let expandedSlots = $state<Set<string>>(new Set());
let slotFilter = $state('');
let showPreviewFor = $state<Set<string>>(new Set());
@@ -112,11 +127,40 @@
let commandSlots = $derived<SlotDef[]>(
allCapabilities[form.provider_type]?.command_slots || []
);
let filteredCmdSlots = $derived(
slotFilter
? commandSlots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase()))
: commandSlots
);
const ERROR_SLOTS = new Set(['rate_limited', 'no_results']);
/**
* 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. */
function getSlotValue(slotName: string): string {
@@ -215,7 +259,7 @@
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
editing = null;
showForm = true;
activeLocale = 'en';
activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
@@ -238,7 +282,7 @@
};
editing = c.id;
showForm = true;
activeLocale = 'en';
activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
@@ -332,7 +376,7 @@
};
editing = null;
showForm = true;
activeLocale = 'en';
activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
@@ -410,89 +454,98 @@
</div>
{/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>
<!-- Locale tabs -->
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
{loc.toUpperCase()}
</button>
{/each}
{#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)]" />
<!-- Language picker -->
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
{t('templateConfig.language')}
</span>
<div class="flex-1 max-w-xs">
<EntitySelect
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>
<div class="space-y-2">
{#each filteredCmdSlots 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')}
<!-- Slot filter -->
{#if commandSlots.length > 4}
<div>
<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>
{/if}
{#each commandSlotGroups.filter(g => g.slots.length > 0) as group}
{@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
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>
{/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>
{: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 slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
{#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>
{: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}
</CollapsibleSlot>
{/each}
</div>
</fieldset>
{#if slotErrors[slot.name]}
{#if slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
{: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">
{editing ? t('common.save') : t('common.create')}
@@ -113,6 +113,26 @@
editing = null;
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) {
form = {
name: trk.name,
@@ -46,6 +46,7 @@
let trackingConfigs = $derived(trackingConfigsCache.items);
let templateConfigs = $derived(templateConfigsCache.items);
let collections = $state<Record<string, any>[]>([]);
let users = $state<{ id: string; name: string }[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
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 = []; }
}
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);
$effect(() => {
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
_prevProviderId = form.provider_id;
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) {
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
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);
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);
if (first) form.default_template_config_id = first.id;
form.default_template_config_id = first?.id ?? 0;
}
}
}
@@ -193,7 +210,7 @@
form = defaultForm();
// Auto-select first provider if any
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) {
@@ -208,7 +225,9 @@
};
previousCollectionIds = [...(trk.collection_ids || [])];
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) {
@@ -460,6 +479,7 @@
bind:form
{providerItems}
{collections}
{users}
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' }))}
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}
<div class="space-y-3 stagger-children">
{#each notificationTrackers as tracker (tracker.id)}
{@const trkDesc = getDescriptor(getProviderType(tracker))}
<Card hover entityId={tracker.id}>
<div class="flex items-center justify-between">
<div>
@@ -511,7 +532,9 @@
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
</div>
<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>
</div>
<div class="flex items-center gap-1 flex-wrap justify-end">
@@ -23,6 +23,7 @@
};
providerItems: { value: number; label: string; icon: string; desc: string }[];
collections: any[];
users?: { id: string; name: string }[];
collectionFilter?: string;
trackingConfigItems?: { value: number; label: string; icon: string }[];
templateConfigItems?: { value: number; label: string; icon: string }[];
@@ -40,6 +41,7 @@
form = $bindable(),
providerItems,
collections,
users = [],
collectionFilter = $bindable(),
trackingConfigItems = [],
templateConfigItems = [],
@@ -116,6 +118,21 @@
</div>
{/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}
<!-- Schedule type -->
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
+6 -4
View File
@@ -229,7 +229,7 @@
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing',
ai_captions: c.ai_captions ?? false, chat_action: tgt.chat_action ?? c.chat_action ?? 'typing',
// discord/slack
username: c.username || '',
// ntfy
@@ -268,7 +268,7 @@
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined,
ai_captions: form.ai_captions,
};
} else if (formType === 'webhook') {
config = { ai_captions: form.ai_captions };
@@ -284,10 +284,12 @@
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) {
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 {
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;
editing = null;
@@ -21,6 +21,8 @@
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
import { getLocaleMeta } from '$lib/locales';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
@@ -71,7 +73,24 @@
let showPreviewFor = $state<Set<string>>(new Set());
let LOCALES = $derived(supportedLocalesCache.items);
let activeLocale = $state<string>('en');
let primaryLocale = $derived(LOCALES[0] || 'en');
let activeLocale = $state<string>('');
const localeItems = $derived<EntityItem[]>(LOCALES.map((code, i) => {
const m = getLocaleMeta(code);
return {
value: code,
label: m.native,
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
};
}));
/**
* Promote primary to be the active locale once the supported-locales
* cache loads (covers initial mount before openNew/edit ran). Without
* this, opening a form before fetch resolves would stay on '' / 'en'.
*/
$effect(() => {
if (!activeLocale && LOCALES.length > 0) activeLocale = primaryLocale;
});
function toggleSlot(key: string) {
const next = new Set(expandedSlots);
@@ -272,7 +291,7 @@
function openNew() {
form = defaultForm();
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
refreshDateFormatPreview();
}
function edit(c: TemplateConfig) {
@@ -285,7 +304,7 @@
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = c.id; showForm = true; activeLocale = 'en';
editing = c.id; showForm = true; activeLocale = primaryLocale;
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
setTimeout(() => refreshAllPreviews(), 100);
@@ -372,7 +391,7 @@
};
editing = null;
showForm = true;
activeLocale = 'en';
activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
@@ -447,15 +466,19 @@
<IconGridSelect items={previewTargetTypeItems()} bind:value={previewTargetType} columns={2} />
</div>
<!-- Locale tabs -->
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
{loc.toUpperCase()}
</button>
{/each}
<!-- Language picker -->
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
{t('templateConfig.language')}
</span>
<div class="flex-1 max-w-xs">
<EntitySelect
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')}
+9
View File
@@ -1,9 +1,18 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/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({
plugins: [tailwindcss(), sveltekit()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
server: {
port: 5175,
proxy: {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.6.1"
version = "0.6.5"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -6,12 +6,46 @@ import asyncio
import json
import logging
import mimetypes
import re
from dataclasses import dataclass
from typing import Any, Callable
import aiohttp
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 .media import (
TELEGRAM_API_BASE_URL,
@@ -193,40 +227,58 @@ class TelegramClient:
thumbhash: str | None,
) -> NotificationResult:
"""Multipart-upload ``data`` to Telegram and cache the returned file_id."""
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field(kind.form_field, data, filename=filename, content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
def _build_form() -> FormData:
f = FormData()
f.add_field("chat_id", chat_id)
f.add_field(kind.form_field, data, filename=filename, content_type=content_type)
f.add_field("parse_mode", parse_mode)
if caption:
f.add_field("caption", caption)
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}"
try:
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
res = result.get("result", {})
file_id = kind.file_id_from_result(res)
if file_id and cache and cache_key:
await cache.async_set(
cache_key, file_id, kind.cache_type,
thumbhash=thumbhash, size=len(data),
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"):
res = result.get("result", {})
file_id = kind.file_id_from_result(res)
if file_id and cache and cache_key:
await cache.async_set(
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(
"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),
"Telegram %s transport error (bytes=%d): %s",
kind.api_method, len(data), err, exc_info=True,
)
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
_LOGGER.error(
"Telegram %s transport error (bytes=%d): %s",
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(
self,
@@ -299,12 +351,7 @@ class TelegramClient:
send_large_photos_as_documents,
)
finally:
if typing_task:
typing_task.cancel()
try:
await typing_task
except asyncio.CancelledError:
pass
await self.stop_keepalive(typing_task)
async def send_message(
self,
@@ -368,20 +415,53 @@ class TelegramClient:
return False
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
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:
try:
while True:
while not stop_event.is_set():
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
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(
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"))
continue
# Multi-item: download all, build form, send media group
form = FormData()
form.add_field("chat_id", chat_id)
if reply_to_message_id and chunk_idx == 0:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
# Multi-item: download all, build form, send media group.
# Attachments are recorded separately so we can rebuild FormData on
# 429 retry — aiohttp.FormData is single-use after a request.
attachments: list[tuple[str, bytes, str, str]] = [] # (name, data, filename, content_type)
media_json = []
upload_idx = 0
# 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}"
ct = item.get("content_type") or ("image/jpeg" if media_type == "photo" else "video/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}"}
upload_idx += 1
# Record cache key so we can store file_id from response
@@ -674,59 +752,86 @@ class TelegramClient:
)
continue
form.add_field("media", json.dumps(media_json))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendMediaGroup"
try:
async with self._session.post(telegram_url, data=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)
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:
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(
"Telegram sendMediaGroup failed: status=%s code=%s desc=%r chunk=%d/%d items=%d",
response.status, result.get("error_code"),
result.get("description", "Unknown"),
chunk_idx + 1, len(chunks), len(media_json),
)
return {
chunk_failed_result = {
"success": False,
"error": result.get("description", "Unknown"),
"error_code": result.get("error_code"),
"failed_at_chunk": chunk_idx + 1,
}
except aiohttp.ClientError as err:
_LOGGER.error(
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
chunk_idx + 1, len(chunks), len(media_json), err,
exc_info=True,
)
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
break
except aiohttp.ClientError as err:
_LOGGER.error(
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
chunk_idx + 1, len(chunks), len(media_json), err,
exc_info=True,
)
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
# can surface an ERROR when a command produced a caption reply but no
@@ -150,6 +150,40 @@ class GiteaClient:
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
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):
"""Raised when a Gitea API call fails."""
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.6.1"
version = "0.6.5"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -12,7 +12,7 @@ import aiohttp
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import ServiceProvider, User
from ..database.models import EventLog, ServiceProvider, User
from ..services import (
make_immich_provider, make_gitea_provider, make_planka_provider,
make_nut_provider, make_google_photos_provider, list_provider_collections,
@@ -398,6 +398,62 @@ async def list_collections(
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")
async def get_album_shared_links(
provider_id: int,
@@ -197,6 +197,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
)
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
if await _has_table(conn, "tracking_config"):
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
# hand-rolled idempotent migrations remain the source of truth. Gives
@@ -75,6 +75,7 @@ async def lifespan(app: FastAPI):
migrate_notification_slot_locale,
migrate_user_token_version,
migrate_performance_indexes,
migrate_chat_action_to_column,
migrate_schema_version,
)
from .database.snapshot import snapshot_and_prune
@@ -98,6 +99,7 @@ async def lifespan(app: FastAPI):
await migrate_notification_slot_locale(engine)
await migrate_user_token_version(engine)
await migrate_performance_indexes(engine)
await migrate_chat_action_to_column(engine)
await migrate_schema_version(engine)
from .database.seeds import seed_all
await seed_all()
@@ -326,7 +326,11 @@ async def _resolve_target(
receivers.append(build_receiver(target.type, dict(r.config), locale))
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:
target_config["chat_action"] = target.chat_action
# Inject bot credentials for bot-backed target types
@@ -378,6 +378,8 @@ async def _load_tracker_jobs() -> None:
tz = await _load_app_timezone()
from notify_bridge_core.providers.capabilities import get_capabilities
for tracker in trackers:
job_id = f"tracker_{tracker.id}"
if scheduler.get_job(job_id):
@@ -386,6 +388,18 @@ async def _load_tracker_jobs() -> None:
ptype = provider_types.get(tracker.provider_id, "")
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
if ptype == "scheduler" and filters.get("schedule_type") == "cron":
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(
tracker_id: int,
interval: int,
@@ -461,6 +498,10 @@ async def schedule_tracker(
``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.
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()
job_id = f"tracker_{tracker_id}"
@@ -474,6 +515,13 @@ async def schedule_tracker(
if scheduler.get_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:
try:
tz = await _load_app_timezone()
@@ -19,7 +19,6 @@ this module just guarantees every caller gets a properly-wired client.
from __future__ import annotations
import asyncio
import contextlib
from typing import Any, AsyncIterator, Callable
@@ -144,6 +143,4 @@ async def telegram_chat_action(
try:
yield
finally:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await client.stop_keepalive(task)