Compare commits

..

16 Commits

Author SHA1 Message Date
alexei.dolgolyov d02616069d chore: release v0.2.8
Release / release (push) Successful in 1m58s
2026-04-22 18:02:09 +03:00
alexei.dolgolyov 7dae68fd93 fix(commands): match notification cache-key format so writes share one namespace
common._format_assets was passing cache_key=<bare asset UUID>, but the
notification dispatcher writes keys as <host>:<uuid> (derived from the
URL by extract_asset_id_from_url). Result: the two paths populated
different keys for the same asset, so neither could hit the other's
cached file_id and the WebUI stats only ever reflected the notification
side.

Drop the explicit cache_key — TelegramClient derives <host>:<uuid> from
the URL, identical to the notification path, so one file_id cached by
any dispatch or /random / /latest reply is reused by every later send.
2026-04-22 17:00:07 +03:00
alexei.dolgolyov e6481605ca chore: release v0.2.7
Release / release (push) Successful in 4m23s
2026-04-22 16:47:25 +03:00
alexei.dolgolyov 6de9a1289e fix(telegram): unify send routine across notifications and commands
- Route cache_key values that look like asset UUIDs through asset_cache
  in TelegramClient._get_cache_and_key. Single-asset sends previously
  stored file_ids in url_cache while the media-group path stored them
  in asset_cache, so repeat sends never hit.
- Extract build_asset_media_urls so the notification dispatcher
  (asset_to_media) and the bot command handlers (common._format_assets)
  share one rule for /video/playback vs thumbnail URLs.
- Add services/telegram_send.py as the single factory for constructing
  a TelegramClient. It always wires the shared aiohttp session and both
  file caches, so commands now reuse file_ids populated by notification
  dispatches (and vice versa) instead of re-uploading the same bytes.
- send_reply / send_media_group in commands/handler.py now delegate to
  the factory rather than constructing their own uncached clients.
2026-04-22 16:45:31 +03:00
alexei.dolgolyov 325eabd751 chore: release v0.2.6
Release / release (push) Successful in 1m19s
2026-04-22 16:29:24 +03:00
alexei.dolgolyov fab6169cf9 fix(commands): enrich search assets, surface variables for all command slots
- UI: command-template-configs now resolves slot variables against the
  active provider first (varsRef[provider_type][slot]) before falling back
  to shared entries, so provider-specific slots like /search, /status,
  /repos, /issues, /boards show the Variables button and autocomplete.
- Backend: /search, /find, /person, /place now normalize raw Immich API
  responses through build_asset_dict, extracting city/country from
  exifInfo and mapping isFavorite -> is_favorite so templates render
  location and favorite indicators.
- Telegram: extract build_telegram_asset_entry into a shared helper so
  the notification dispatcher and command media groups agree on video
  typing and /video/playback URLs; videos no longer render as still
  thumbnails in /latest /random /favorites media mode.
- Commands: send_media_group now reuses the same Telegram file_id caches
  as the notification dispatcher, avoiding re-upload churn for repeated
  commands.
2026-04-22 16:28:26 +03:00
alexei.dolgolyov 85311684d9 fix(settings): don't clobber webhook secret with its mask on save
GET /settings returns the Telegram webhook secret masked as "***<last4>".
The frontend binds that masked value into its state, and any Save ships it
back — the PUT handler then persisted the mask as the new secret, silently
invalidating HMAC for every webhook-mode bot. The next GET re-masks the
mask to itself, so the UI showed no corruption.

Treat incoming values that begin with "***" as "unchanged" for the
webhook-secret field. Empty strings still pass through (explicit clear).
2026-04-22 16:10:34 +03:00
alexei.dolgolyov d7daadadc2 chore: release v0.2.5
Release / release (push) Successful in 1m9s
2026-04-22 15:56:20 +03:00
alexei.dolgolyov e04ad16ca6 docs: add hotfix note to v0.2.4 release notes 2026-04-22 15:51:28 +03:00
alexei.dolgolyov d7d0a5d921 fix(settings): accept numeric values in update payload
Svelte bind:value on <input type="number"> coerces to a JS number, so the
frontend sends {telegram_cache_ttl_hours: 0} after v0.2.4. Pydantic v2
won't auto-coerce int -> str, which produced a 422 on every save that
touched a numeric setting.

- Widen numeric fields to int | str | None in SettingsUpdate.
- Normalize to str before persisting (DB column is text).
2026-04-22 15:44:40 +03:00
alexei.dolgolyov 93df538819 chore: release v0.2.4
Release / release (push) Successful in 1m18s
2026-04-22 15:36:08 +03:00
alexei.dolgolyov 2be608ba95 feat(cache): thumbhash-validated asset cache + settings UX overhaul
Cache engine:
- TelegramFileCache: configurable max_entries (LRU cap applies in both TTL
  and thumbhash modes), ttl_seconds<=0 disables TTL, stats() method.
- Dispatcher builds an asset.id -> thumbhash resolver from event.added_assets
  (Immich populates thumbhash in extra) and passes it to TelegramClient, so
  asset-cache entries invalidate on visual change rather than age.
- Watcher wires app settings into cache init: URL cache = TTL + LRU cap,
  asset cache = thumbhash + LRU cap. Adds soft-reset (in-memory only) used
  when cache params change.

Settings:
- New key telegram_asset_cache_max_entries (default 5000).
- telegram_cache_ttl_hours default bumped 48 -> 720 (30d); now URL-only.
- PUT /settings resets in-memory caches when cache keys change (files kept).
- New endpoints: GET/POST /settings/telegram-cache/stats and /clear.

Settings page:
- Cache stats card (count + size + oldest/newest per bucket) with a hint
  explaining that the size is cumulative uploaded-to-Telegram bytes.
- Clear-cache button behind a confirm modal.
- New TimezoneSelector + LocaleSelector components replace raw inputs.
- max-entries input, TTL range updated (0..8760, 0 = disabled).

Mobile nav:
- "More" panel now mirrors the full sidebar tree (groups + subnodes) so
  every destination is reachable on mobile; previously flat hand-picked list.
- Nav height uses env(safe-area-inset-bottom); panel bottom + z-index fixed
  so content can't visually overlay the bottom bar.

A11y / DOM warnings:
- Password-change form has a hidden username field for password-manager
  association; autocomplete hints on all three password inputs.
- Telegram webhook secret wrapped in a no-op form + autocomplete=off.

Bug fix:
- update_settings used any(await ... for ...) which raised TypeError at
  runtime (async generator not an iterator); replaced with explicit loop.
2026-04-22 15:09:59 +03:00
alexei.dolgolyov 5028f15f4f chore: release v0.2.3
Release / release (push) Successful in 1m17s
2026-04-22 03:30:45 +03:00
alexei.dolgolyov 5a232f18b8 feat(commands): drop tracker counts from /status
trackers_active / trackers_total are per-provider aggregates — once the
rest of /status is scoped to the chat's album set (total_albums and
last_event both filtered by the derived scope), leaving tracker counts
in would leak info about trackers this chat has no visibility into.

- _cmd_status no longer emits trackers_active / trackers_total.
- Immich default status templates (en, ru) just show Albums + Last event.
- Variable catalog updated so the template editor stops suggesting the
  removed vars for the Immich /status slot.
2026-04-22 03:28:05 +03:00
alexei.dolgolyov 3b76a09759 feat(commands): per-chat album scope derived from notification routing
The "per-chat album scope" feature stored on CommandTrackerListener was
really per-bot: listener_id = bot.id, and every chat that bot served
shared the same scope.  Commands like /albums, /random, /status,
/events leaked the full provider catalog into chats that were never
wired up to receive notifications from those trackers.

New model: the album scope for /commands in a given chat is derived
from the notification-routing graph.  For a (provider, bot, chat_id)
triple we walk TargetReceiver (chat_id match, enabled) →
NotificationTarget (telegram or broadcast parent) →
NotificationTrackerTarget → NotificationTracker (provider match) and
union their collection_ids.  That's the natural "what does this chat
get notifications about" set, and it becomes the command scope.

- New helper: command_utils.resolve_chat_album_scope(provider_id,
  bot_id, chat_id) -> set[str].  Empty set is the default for chats
  with no routing — commands return nothing rather than leaking the
  provider's catalog.
- Dispatcher computes the scope per (tracker, bot, chat) and threads
  it through handler.handle(..., allowed_album_ids=...).  Explicit
  CommandTrackerListener.allowed_album_ids override, when set, still
  wins verbatim (kept as an escape hatch for users who want a divergent
  scope for a whole bot).
- /status, /albums, /events, and all /_cmd_immich-routed commands
  (/random, /search, /find, /latest, /memory, /summary, /favorites,
  /place, /person) now intersect with the resolved scope.
- UI scope modal relabeled: it's an explicit *override for this bot*,
  not a per-chat setting.  Default is "derive from notification
  routing", which matches what users already configured elsewhere.

Also:
- /search, /find, /person, /place — _enrich_assets return value was
  discarded, dropping public_url enrichment.  Assign the return value.
- search_smart / search_metadata — consolidated into _search_items
  helper that logs non-200 responses and transport errors instead of
  silently returning [].  Makes "always no results" bugs actually
  diagnosable.  Also accepts the alternate {"assets": [...]} flat-list
  shape from older Immich versions.
- Immich search error bodies go through _redact_body so credentials
  echoed by authenticating proxies don't land in server logs.
2026-04-22 03:20:51 +03:00
alexei.dolgolyov 4ff3876e49 fix(commands): /albums honors per-chat scope, disable link previews
- /albums ignored CommandTrackerListener.allowed_album_ids and listed
  every album tracked by the provider — scoped chats saw neighbours'
  albums.  Thread the listener through _cmd_albums and apply the same
  intersect filter the media commands already use in _cmd_immich.
- Command text replies are listings (albums, events, people, ...) that
  embed multiple links; Telegram's default behavior of rendering a
  preview for the first URL is never useful here and ignored the
  "Disable link previews" toggle operators set on their target.  Always
  pass disable_web_page_preview=True from send_reply.
2026-04-22 03:03:09 +03:00
34 changed files with 2446 additions and 241 deletions
+7 -16
View File
@@ -1,27 +1,18 @@
## v0.2.2 (2026-04-22)
# v0.2.8 (2026-04-22)
Patch release — homelab usability fixes on top of v0.2.1. The SSRF hardening
introduced in v0.2.1 blocks outbound requests to RFC1918 / link-local hosts,
which breaks tracking of Immich / Gitea / etc. running on the same LAN.
This release makes the workaround discoverable and enables it by default
in the shipped `docker-compose.yml`.
Follow-up fix to v0.2.7's Telegram-send unification. The shared factory was
in place, but commands and notifications were still writing into different
cache-key namespaces — so in practice the caches never actually shared entries.
### Bug Fixes
## Bug Fixes
- **Default `NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1` in `docker-compose.yml`** — the shipped compose is intended for homelab use. The flag is now hardcoded in the `environment:` block (not a `${...}` substitution) so it works correctly with Portainer's per-stack env panel, which only does compose-file substitution and not runtime container env. Operators running on a public-facing host can drop the line. ([4e23d2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4e23d2b))
### Documentation
- **Surface `NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS` hint in SSRF rejection errors** — the `UnsafeURLError` raised by `ImmichClient` now tells operators how to allow LAN targets, instead of leaving them to dig through source. ([58cba88](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/58cba88))
- **Commands and notifications now share one `file_id` cache namespace** — `common._format_assets` was passing `cache_key=<bare asset UUID>`, while the notification dispatcher writes keys as `<host>:<uuid>` (derived from the URL by `extract_asset_id_from_url`). The two paths populated different keys for the same asset, so neither could hit the other's cached `file_id` and the Settings → cache-stats card only ever reflected the notification side. Dropped the explicit `cache_key``TelegramClient` now derives `<host>:<uuid>` from the URL on both paths, so one `file_id` cached by any dispatch or `/random` / `/latest` reply is reused by every later send. ([7dae68f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7dae68f))
---
<details>
<summary>All Commits</summary>
- [4e23d2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4e23d2b) — chore(compose): hardcode NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 in compose _(alexei.dolgolyov)_
- [f7d51b2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f7d51b2) — Revert "chore(compose): default NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 for homelab" _(alexei.dolgolyov)_
- [3bb0585](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3bb0585) — chore(compose): default NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 for homelab _(alexei.dolgolyov)_
- [58cba88](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/58cba88) — docs(immich-ssrf): surface NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS hint in error _(alexei.dolgolyov)_
- [7dae68f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7dae68f) — fix(commands): match notification cache-key format so writes share one namespace *(alexei.dolgolyov)*
</details>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.2.2",
"version": "0.2.8",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -0,0 +1,764 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
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' },
];
// Locales that ship with default notification & command templates.
const SHIPPED = new Set(['en', 'ru']);
let {
value = $bindable<string>(''),
}: {
value: string;
} = $props();
// Parse the comma-separated backend string into an ordered array of codes.
const codes = $derived.by<string[]>(() => {
if (!value) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const raw of value.split(',')) {
const c = raw.trim().toLowerCase();
if (!c || seen.has(c)) continue;
seen.add(c);
out.push(c);
}
return out;
});
function commit(next: string[]) {
// De-dupe (preserve order) and serialise back to the backend format.
const seen = new Set<string>();
const clean = next.map(c => c.trim().toLowerCase())
.filter(c => c && !seen.has(c) && (seen.add(c), true));
value = clean.join(',');
}
function meta(code: string): LocaleMeta {
return CATALOG.find(l => l.code === code) ?? {
code,
name: code.toUpperCase(),
native: code.toUpperCase(),
};
}
function remove(code: string) {
commit(codes.filter(c => c !== code));
}
function makePrimary(code: string) {
commit([code, ...codes.filter(c => c !== code)]);
}
function moveUp(code: string) {
const i = codes.indexOf(code);
if (i <= 0) return;
const next = [...codes];
[next[i - 1], next[i]] = [next[i], next[i - 1]];
commit(next);
}
function moveDown(code: string) {
const i = codes.indexOf(code);
if (i < 0 || i >= codes.length - 1) return;
const next = [...codes];
[next[i], next[i + 1]] = [next[i + 1], next[i]];
commit(next);
}
// --- 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),
);
});
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;
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();
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);
}
}
}
$effect(() => { addQuery; highlightIdx = 0; });
// --- Drag & drop -------------------------------------------------------
let dragCode = $state<string | null>(null);
let dragOverCode = $state<string | null>(null);
function onDragStart(e: DragEvent, code: string) {
dragCode = code;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', code);
}
}
function onDragOver(e: DragEvent, code: string) {
if (!dragCode || dragCode === code) return;
e.preventDefault();
dragOverCode = code;
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
}
function onDrop(e: DragEvent, code: string) {
e.preventDefault();
if (!dragCode || dragCode === code) return;
const from = codes.indexOf(dragCode);
const to = codes.indexOf(code);
if (from < 0 || to < 0) return;
const next = [...codes];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
commit(next);
dragCode = null;
dragOverCode = null;
}
function onDragEnd() {
dragCode = null;
dragOverCode = null;
}
</script>
<div class="ls-root">
{#if codes.length === 0}
<div class="ls-empty">
<div class="ls-empty-glyph" aria-hidden="true">A あ Я</div>
<p class="ls-empty-text">{t('locales.empty')}</p>
</div>
{:else}
<ul class="ls-list" role="list">
{#each codes as code, i (code)}
{@const m = meta(code)}
{@const isPrimary = i === 0}
{@const isShipped = SHIPPED.has(code)}
<li
class="ls-row"
class:ls-row-primary={isPrimary}
class:ls-row-dragover={dragOverCode === code}
class:ls-row-dragging={dragCode === code}
draggable="true"
ondragstart={(e) => onDragStart(e, code)}
ondragover={(e) => onDragOver(e, code)}
ondrop={(e) => onDrop(e, code)}
ondragend={onDragEnd}
>
<span class="ls-rail" aria-hidden="true"></span>
<button
type="button"
class="ls-handle"
aria-label={t('locales.reorder')}
title={t('locales.reorder')}
tabindex="-1"
>
<MdiIcon name="mdiDragVertical" size={16} />
</button>
<div class="ls-text">
<div class="ls-native" dir={m.rtl ? 'rtl' : 'ltr'} lang={code}>{m.native}</div>
<div class="ls-meta">
<span class="ls-name">{m.name}</span>
<span class="ls-dot" aria-hidden="true">·</span>
<span class="ls-code">{code}</span>
</div>
</div>
<div class="ls-badges">
{#if isPrimary}
<span class="ls-tag ls-tag-primary">
<MdiIcon name="mdiStar" size={10} />
{t('locales.primary')}
</span>
{/if}
{#if isShipped}
<span class="ls-tag ls-tag-shipped" title={t('locales.shippedHint')}>
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
{t('locales.shipped')}
</span>
{/if}
</div>
<div class="ls-actions">
{#if !isPrimary}
<button
type="button"
class="ls-icon-btn"
onclick={() => makePrimary(code)}
aria-label={t('locales.makePrimary')}
title={t('locales.makePrimary')}
>
<MdiIcon name="mdiStarOutline" size={14} />
</button>
{/if}
<button
type="button"
class="ls-icon-btn"
onclick={() => moveUp(code)}
disabled={i === 0}
aria-label={t('locales.moveUp')}
title={t('locales.moveUp')}
>
<MdiIcon name="mdiChevronUp" size={14} />
</button>
<button
type="button"
class="ls-icon-btn"
onclick={() => moveDown(code)}
disabled={i === codes.length - 1}
aria-label={t('locales.moveDown')}
title={t('locales.moveDown')}
>
<MdiIcon name="mdiChevronDown" size={14} />
</button>
<button
type="button"
class="ls-icon-btn ls-icon-danger"
onclick={() => remove(code)}
disabled={codes.length <= 1}
aria-label={t('locales.remove')}
title={codes.length <= 1 ? t('locales.removeLast') : t('locales.remove')}
>
<MdiIcon name="mdiClose" size={14} />
</button>
</div>
</li>
{/each}
</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>
</div>
{/if}
</div>
<p class="ls-hint">
<MdiIcon name="mdiInformationOutline" size={12} />
<span>{t('locales.orderHint')}</span>
</p>
</div>
<style>
.ls-root {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 34rem;
}
/* ---- Empty state -------------------------------------------------- */
.ls-empty {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 1rem 1.125rem;
border: 1px dashed var(--color-border);
border-radius: 0.625rem;
background:
linear-gradient(135deg,
color-mix(in srgb, var(--color-primary) 4%, transparent) 0%,
transparent 60%),
var(--color-background);
}
.ls-empty-glyph {
font-family: var(--font-sans);
font-size: 1.5rem;
letter-spacing: 0.1em;
font-weight: 300;
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
flex-shrink: 0;
line-height: 1;
}
.ls-empty-text {
margin: 0;
font-size: 0.8rem;
color: var(--color-muted-foreground);
}
/* ---- List --------------------------------------------------------- */
.ls-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.ls-row {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem 0.625rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-background);
transition: border-color 0.15s, background 0.15s, transform 0.15s;
overflow: hidden;
}
.ls-row:hover {
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
}
.ls-row.ls-row-dragging {
opacity: 0.4;
}
.ls-row.ls-row-dragover {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-background));
}
.ls-row.ls-row-primary {
background:
linear-gradient(90deg,
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
transparent 30%),
var(--color-background);
}
/* Accent rail — pronounced on primary, near-invisible otherwise */
.ls-rail {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background: transparent;
transition: background 0.15s;
}
.ls-row.ls-row-primary .ls-rail {
background: var(--color-primary);
}
.ls-handle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.125rem;
border: none;
background: transparent;
color: var(--color-muted-foreground);
opacity: 0.4;
cursor: grab;
transition: opacity 0.15s;
}
.ls-row:hover .ls-handle {
opacity: 0.9;
}
.ls-handle:active {
cursor: grabbing;
}
.ls-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.ls-native {
font-family: var(--font-sans);
font-size: 1.125rem;
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.005em;
color: var(--color-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ls-meta {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.7rem;
color: var(--color-muted-foreground);
min-width: 0;
}
.ls-name {
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 500;
font-size: 0.625rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ls-dot {
opacity: 0.5;
}
.ls-code {
font-family: var(--font-mono);
font-size: 0.7rem;
padding: 0.05rem 0.375rem;
border-radius: 0.25rem;
background: var(--color-muted);
color: var(--color-muted-foreground);
}
.ls-badges {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
}
.ls-tag {
display: inline-flex;
align-items: center;
gap: 0.15rem;
font-size: 0.55rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
white-space: nowrap;
}
.ls-tag-primary {
background: var(--color-primary);
color: var(--color-primary-foreground, #fff);
}
.ls-tag-shipped {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.ls-actions {
display: flex;
align-items: center;
gap: 0.0625rem;
}
.ls-icon-btn {
display: inline-flex;
align-items: 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-icon-btn:hover:not(:disabled) {
background: var(--color-muted);
color: var(--color-foreground);
}
.ls-icon-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ls-icon-btn.ls-icon-danger:hover:not(:disabled) {
background: color-mix(in srgb, #ef4444 14%, transparent);
color: #ef4444;
}
/* ---- Add zone ----------------------------------------------------- */
.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 {
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);
}
.ls-add-input {
flex: 1;
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);
color: var(--color-muted-foreground);
}
.ls-sugg.ls-sugg-hl .ls-sugg-code {
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
}
.ls-sugg-shipped {
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;
color: var(--color-muted-foreground);
}
/* ---- Hint --------------------------------------------------------- */
.ls-hint {
display: flex;
align-items: flex-start;
gap: 0.3rem;
margin: 0.125rem 0 0;
font-size: 0.7rem;
color: var(--color-muted-foreground);
line-height: 1.4;
}
</style>
@@ -0,0 +1,585 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
let {
value = $bindable<string>('UTC'),
}: {
value: string;
} = $props();
// --- Catalog -----------------------------------------------------------
const timezones = $derived.by<string[]>(() => {
try {
const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] };
if (typeof intl.supportedValuesOf === 'function') {
return intl.supportedValuesOf('timeZone');
}
} catch { /* fall through */ }
return ['UTC'];
});
const detectedTz = (() => {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
catch { return 'UTC'; }
})();
// --- Live clock --------------------------------------------------------
let now = $state(new Date());
let tickHandle: ReturnType<typeof setInterval> | null = null;
onMount(() => {
tickHandle = setInterval(() => { now = new Date(); }, 1000);
});
onDestroy(() => { if (tickHandle) clearInterval(tickHandle); });
function splitTz(tz: string): { region: string; city: string } {
if (!tz || tz === 'UTC' || tz === 'Etc/UTC') return { region: 'UTC', city: 'UTC' };
const parts = tz.split('/');
if (parts.length === 1) return { region: 'Other', city: parts[0].replace(/_/g, ' ') };
const city = parts[parts.length - 1].replace(/_/g, ' ');
const region = parts.slice(0, -1).join(' / ').replace(/_/g, ' ');
return { region, city };
}
function fmtTime(tz: string): string {
try {
return new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(now);
} catch { return '--:--:--'; }
}
function fmtDate(tz: string): string {
try {
return new Intl.DateTimeFormat(undefined, {
timeZone: tz,
weekday: 'short',
day: 'numeric',
month: 'short',
}).format(now);
} catch { return ''; }
}
function fmtOffset(tz: string): string {
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
timeZoneName: 'shortOffset',
}).formatToParts(now);
const off = parts.find(p => p.type === 'timeZoneName')?.value ?? '';
return off || 'UTC';
} catch { return ''; }
}
// --- Selected state ----------------------------------------------------
const selected = $derived.by(() => {
const s = splitTz(value || 'UTC');
return {
iana: value || 'UTC',
region: s.region,
city: s.city,
time: fmtTime(value || 'UTC'),
date: fmtDate(value || 'UTC'),
offset: fmtOffset(value || 'UTC'),
};
});
// --- Picker ------------------------------------------------------------
let open = $state(false);
let query = $state('');
let highlightIdx = $state(0);
let inputEl = $state<HTMLInputElement | null>(null);
let panelEl = $state<HTMLDivElement | null>(null);
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase().replace(/\s+/g, '_');
if (!q) return timezones;
return timezones.filter(tz => tz.toLowerCase().includes(q));
});
// Group filtered tz list by region prefix for visual hierarchy.
interface Group { region: string; items: string[] }
const groups = $derived.by<Group[]>(() => {
const map = new Map<string, string[]>();
for (const tz of filtered) {
const region = tz.includes('/') ? tz.split('/')[0] : 'Other';
if (!map.has(region)) map.set(region, []);
map.get(region)!.push(tz);
}
const REGION_ORDER = ['UTC', 'Europe', 'America', 'Asia', 'Africa', 'Australia', 'Pacific', 'Atlantic', 'Indian', 'Antarctica', 'Arctic', 'Etc', 'Other'];
return [...map.entries()]
.sort(([a], [b]) => {
const ai = REGION_ORDER.indexOf(a);
const bi = REGION_ORDER.indexOf(b);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
})
.map(([region, items]) => ({ region, items }));
});
// Flattened index for keyboard navigation.
const flat = $derived<string[]>(groups.flatMap(g => g.items));
function openPicker() {
open = true;
query = '';
highlightIdx = Math.max(0, flat.indexOf(value));
requestAnimationFrame(() => {
inputEl?.focus();
scrollToHighlight();
});
}
function closePicker() {
open = false;
query = '';
}
function selectTz(tz: string) {
value = tz;
closePicker();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { closePicker(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, flat.length - 1);
scrollToHighlight();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
scrollToHighlight();
} else if (e.key === 'Enter') {
e.preventDefault();
if (flat[highlightIdx]) selectTz(flat[highlightIdx]);
}
}
function scrollToHighlight() {
requestAnimationFrame(() => {
panelEl?.querySelector('.tz-opt-hl')?.scrollIntoView({ block: 'nearest' });
});
}
$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);
});
</script>
<div class="tz-root">
<!-- Selected card -->
<button
type="button"
class="tz-card"
class:tz-card-open={open}
onclick={() => (open ? closePicker() : openPicker())}
aria-haspopup="listbox"
aria-expanded={open}
>
<div class="tz-card-left">
<div class="tz-region">{selected.region}</div>
<div class="tz-city">{selected.city}</div>
<div class="tz-sub">
<span class="tz-iana">{selected.iana}</span>
{#if selected.date}
<span class="tz-dot">·</span>
<span class="tz-date">{selected.date}</span>
{/if}
</div>
</div>
<div class="tz-card-right">
<div class="tz-clock">{selected.time}</div>
<div class="tz-offset">{selected.offset}</div>
</div>
<span class="tz-chev" aria-hidden="true">
<MdiIcon name={open ? 'mdiChevronUp' : 'mdiChevronDown'} size={16} />
</span>
</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>
<!-- 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>
{/if}
</div>
<style>
.tz-root {
position: relative;
width: 100%;
max-width: 34rem;
}
/* ---- Selected card ------------------------------------------------ */
.tz-card {
position: relative;
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.875rem;
width: 100%;
padding: 0.75rem 1rem 0.75rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.625rem;
background:
linear-gradient(135deg,
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
transparent 55%),
var(--color-background);
color: var(--color-foreground);
text-align: left;
cursor: pointer;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
}
.tz-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
}
.tz-card.tz-card-open {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.tz-card-left {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.tz-region {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
}
.tz-city {
font-family: var(--font-sans);
font-size: 1.25rem;
font-weight: 500;
line-height: 1.1;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-sub {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.7rem;
color: var(--color-muted-foreground);
min-width: 0;
}
.tz-iana {
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-dot { opacity: 0.5; }
.tz-card-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
}
.tz-clock {
font-family: var(--font-mono);
font-size: 1.25rem;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--color-foreground);
line-height: 1;
/* Stable width so seconds ticker doesn't shift layout */
font-variant-numeric: tabular-nums;
}
.tz-offset {
font-family: var(--font-mono);
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.1rem 0.375rem;
border-radius: 9999px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.tz-chev {
color: var(--color-muted-foreground);
display: inline-flex;
align-items: center;
}
/* ---- Panel -------------------------------------------------------- */
.tz-panel {
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);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 26rem;
animation: tz-pop 0.15s ease-out;
}
@keyframes tz-pop {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
}
.tz-search-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground);
}
.tz-search {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 0.85rem;
color: var(--color-foreground);
padding: 0.125rem 0;
min-width: 0;
}
.tz-kbd {
font-size: 0.55rem;
font-family: var(--font-mono);
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
background: var(--color-muted);
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
}
.tz-quick {
display: flex;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.tz-quick-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 9999px;
background: var(--color-background);
font-size: 0.7rem;
color: var(--color-foreground);
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.tz-quick-btn:hover {
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
color: var(--color-primary);
}
.tz-quick-active {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
color: var(--color-primary);
}
.tz-quick-label {
font-weight: 500;
}
.tz-quick-val {
font-family: var(--font-mono);
font-size: 0.65rem;
opacity: 0.7;
}
.tz-list {
overflow-y: auto;
padding: 0.25rem 0;
scrollbar-width: thin;
}
.tz-empty {
padding: 1rem;
text-align: center;
font-size: 0.8rem;
color: var(--color-muted-foreground);
}
.tz-group {
margin-bottom: 0.125rem;
}
.tz-group-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0.375rem 0.75rem 0.25rem;
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
color: var(--color-muted-foreground);
position: sticky;
top: 0;
background: var(--color-card, var(--color-background));
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
z-index: 1;
}
.tz-group-count {
font-family: var(--font-mono);
opacity: 0.6;
}
.tz-opt {
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.35rem 0.75rem;
border: none;
background: transparent;
color: var(--color-foreground);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.tz-opt.tz-opt-hl {
background: var(--color-muted);
}
.tz-opt.tz-opt-sel {
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.tz-opt-city {
font-size: 0.85rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-opt.tz-opt-sel .tz-opt-city {
color: var(--color-primary);
}
.tz-opt-iana {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--color-muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-opt-offset {
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
padding: 0.1rem 0.375rem;
border-radius: 0.25rem;
background: var(--color-muted);
white-space: nowrap;
}
.tz-opt.tz-opt-hl .tz-opt-offset {
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
}
</style>
+46 -7
View File
@@ -671,13 +671,29 @@
"telegram": "Telegram",
"webhookSecret": "Webhook Secret",
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
"cacheTtl": "Media Cache TTL (hours)",
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
"cacheTtl": "URL Cache TTL (hours)",
"cacheTtlHint": "How long to keep URL-keyed Telegram file_ids (e.g. shared links). Set 0 to disable TTL. The asset cache uses content hashing (thumbhash) and ignores this.",
"cacheMaxEntries": "Cache Max Entries",
"cacheMaxEntriesHint": "Upper bound per cache (URL and asset). Oldest entries are evicted first (LRU). Default 5000.",
"cacheStats": "Cache contents",
"cacheStatsHint": "Size shown is the total bytes of media originally uploaded to Telegram for cached entries — i.e. approximate re-upload bandwidth the cache is saving. The cache file itself is only a few KB; the media lives on Telegram's servers.",
"cacheStatsUrl": "URL cache",
"cacheStatsAsset": "Asset cache",
"cacheStatsEntries": "entries",
"cacheStatsEmpty": "empty",
"cacheStatsOldest": "oldest",
"cacheStatsNewest": "newest",
"clearCache": "Clear Media Cache",
"clearCacheHint": "Delete cached Telegram file_ids. Next send will re-upload media.",
"clearCacheConfirmTitle": "Clear Telegram cache?",
"clearCacheConfirm": "This removes all cached Telegram file_ids. Subsequent notifications will re-upload media, which may take longer and use more bandwidth.",
"clearCacheConfirmBtn": "Clear cache",
"clearCacheDone": "Telegram cache cleared",
"timezone": "Timezone",
"timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.",
"locales": "Template Languages",
"supportedLocales": "Supported Locales",
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
"supportedLocalesHint": "Languages available when authoring notification and command templates. Built-in defaults ship for English and Russian; other languages start empty.",
"saved": "Settings saved"
},
"hints": {
@@ -802,17 +818,40 @@
"selectBot": "Select bot...",
"listenerType": "telegram_bot",
"editScope": "Edit album scope",
"scopeAll": "all albums",
"scopeAll": "derived from notification routing",
"albumsShort": "albums",
"scopeTitle": "Album Scope for This Chat",
"scopeDescription": "Restrict which tracked albums this chat can query via commands. Leave on \"inherit\" to allow all albums from the tracker.",
"scopeInherit": "Inherit: allow all tracked albums",
"scopeTitle": "Album Scope Override for This Bot",
"scopeDescription": "By default this bot's commands see only the albums that actually deliver notifications to the chats it speaks to (computed from your notification trackers). Set an explicit override here to widen or narrow that set for every chat this bot serves.",
"scopeInherit": "Inherit: derive from notification routing",
"noCollections": "No albums available."
},
"snackbar": {
"showDetails": "Show details",
"hideDetails": "Hide details"
},
"timezone": {
"searchPlaceholder": "Search cities or IANA codes…",
"detect": "Detect",
"utc": "UTC",
"noMatches": "No timezones match"
},
"locales": {
"empty": "No languages selected. Add one below to start authoring templates.",
"add": "Add language",
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
"addCustom": "Add custom code",
"noSuggestions": "No matches. Type a valid locale code (23 letters).",
"primary": "Primary",
"shipped": "Built-in",
"shippedHint": "Default notification & command templates ship for this language.",
"makePrimary": "Make primary",
"moveUp": "Move up",
"moveDown": "Move down",
"remove": "Remove",
"removeLast": "At least one language is required",
"reorder": "Drag to reorder",
"orderHint": "First language is the primary fallback when a translation is missing. Drag to reorder."
},
"snack": {
"eventsCleared": "{count} event(s) cleared",
"providerSaved": "Provider saved",
+46 -7
View File
@@ -671,13 +671,29 @@
"telegram": "Telegram",
"webhookSecret": "Секрет вебхука",
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
"cacheTtl": "TTL кэша медиа (часы)",
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
"cacheTtl": "TTL URL-кэша (часы)",
"cacheTtlHint": "Сколько хранить Telegram file_id, привязанные к URL (напр. публичные ссылки). 0 — отключить TTL. Кэш ассетов использует хэширование содержимого (thumbhash) и не зависит от этой настройки.",
"cacheMaxEntries": "Макс. записей в кэше",
"cacheMaxEntriesHint": "Верхний предел записей в каждом кэше (URL и ассеты). При превышении удаляются самые старые (LRU). По умолчанию 5000.",
"cacheStats": "Содержимое кэша",
"cacheStatsHint": "Показываемый размер — это суммарный объём медиа, который был изначально загружен в Telegram для закэшированных записей, т.е. приблизительный объём повторных загрузок, который экономит кэш. Сам файл кэша занимает лишь несколько КБ; медиа хранится на серверах Telegram.",
"cacheStatsUrl": "Кэш URL",
"cacheStatsAsset": "Кэш ассетов",
"cacheStatsEntries": "записей",
"cacheStatsEmpty": "пусто",
"cacheStatsOldest": "самая старая",
"cacheStatsNewest": "самая свежая",
"clearCache": "Очистить кэш медиа",
"clearCacheHint": "Удалить кэшированные Telegram file_id. При следующей отправке медиа будут загружены заново.",
"clearCacheConfirmTitle": "Очистить кэш Telegram?",
"clearCacheConfirm": "Это удалит все кэшированные Telegram file_id. Следующие уведомления будут повторно загружать медиа, что может занять больше времени и трафика.",
"clearCacheConfirmBtn": "Очистить кэш",
"clearCacheDone": "Кэш Telegram очищен",
"timezone": "Часовой пояс",
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
"locales": "Языки шаблонов",
"supportedLocales": "Поддерживаемые локали",
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
"supportedLocalesHint": "Языки, доступные для редактирования шаблонов уведомлений и команд. Встроенные шаблоны поставляются для английского и русского; другие языки начинают с пустых.",
"saved": "Настройки сохранены"
},
"hints": {
@@ -802,17 +818,40 @@
"selectBot": "Выберите бота...",
"listenerType": "telegram_bot",
"editScope": "Изменить область альбомов",
"scopeAll": "все альбомы",
"scopeAll": "из маршрутизации уведомлений",
"albumsShort": "альбомов",
"scopeTitle": "Область альбомов для этого чата",
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.",
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы",
"scopeTitle": "Переопределение области альбомов для этого бота",
"scopeDescription": "По умолчанию команды этого бота видят только альбомы, уведомления которых приходят в его чаты (вычисляется из ваших трекеров уведомлений). Задайте явный список здесь, чтобы расширить или сузить этот набор для всех чатов данного бота.",
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
"noCollections": "Нет доступных альбомов."
},
"snackbar": {
"showDetails": "Показать детали",
"hideDetails": "Скрыть детали"
},
"timezone": {
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
"detect": "Определить",
"utc": "UTC",
"noMatches": "Нет совпадений"
},
"locales": {
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
"addCustom": "Добавить свой код",
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
"primary": "Основной",
"shipped": "Встроенный",
"shippedHint": "Для этого языка есть встроенные шаблоны уведомлений и команд.",
"makePrimary": "Сделать основным",
"moveUp": "Выше",
"moveDown": "Ниже",
"remove": "Удалить",
"removeLast": "Должен быть хотя бы один язык",
"reorder": "Перетащите для изменения порядка",
"orderHint": "Первый язык используется как основной при отсутствии перевода. Перетаскивайте, чтобы изменить порядок."
},
"snack": {
"eventsCleared": "Очищено событий: {count}",
"providerSaved": "Провайдер сохранён",
+63 -40
View File
@@ -226,24 +226,15 @@
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
]);
// "More" panel items — everything not in the bottom bar
const mobileMoreItems = $derived<NavItem[]>([
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
{ href: '/bots?tab=telegram', key: 'nav.bots', icon: 'mdiRobot' },
{ href: '/actions', key: 'nav.actions', icon: 'mdiPlayCircleOutline' },
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog' },
{ href: '/template-configs', key: 'nav.templates', icon: 'mdiFileDocumentEdit' },
{ href: '/command-configs', key: 'nav.configs', icon: 'mdiConsoleLine' },
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox' },
...(auth.isAdmin ? [
{ href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' },
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
] : []),
]);
// "More" panel mirrors the full desktop sidebar tree so every subnode is
// reachable on mobile (previously it was a flat hand-picked list that
// hid all target types, bot channels, and several nested pages).
let mobileMoreOpen = $state(false);
function closeMobileMore() {
mobileMoreOpen = false;
}
const isAuthPage = $derived(
page.url.pathname === '/login' || page.url.pathname === '/setup'
);
@@ -538,7 +529,7 @@
</aside>
<!-- Mobile bottom nav -->
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 60; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0 calc(0.375rem + env(safe-area-inset-bottom, 0px)); backdrop-filter: blur(12px);">
{#each mobileNavItems as item}
<a href={item.href} aria-label={t(item.key)}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
@@ -558,40 +549,69 @@
</button>
</nav>
<!-- Mobile "More" panel -->
<!-- Mobile "More" panel — mirrors the full desktop nav tree -->
{#if mobileMoreOpen}
<div class="mobile-more-backdrop" style="position: fixed; inset: 0; z-index: 49; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);"
onclick={() => mobileMoreOpen = false} role="presentation"></div>
<div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;"
onclick={closeMobileMore} role="presentation"></div>
<div class="mobile-more-panel" style="position: fixed; bottom: calc(3rem + env(safe-area-inset-bottom, 0px)); left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: calc(70vh - env(safe-area-inset-bottom, 0px)); overflow-y: auto;"
transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
</div>
{/if}
<div class="grid grid-cols-3 gap-2">
{#each mobileMoreItems as item}
<a href={item.href}
onclick={() => mobileMoreOpen = false}
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
>
<MdiIcon name={item.icon} size={20} />
<span class="text-xs text-center leading-tight">{t(item.key)}</span>
</a>
<div class="space-y-3">
{#each navEntries as entry}
{#if isGroup(entry)}
<div>
<div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
style="color: var(--color-muted-foreground);">
<MdiIcon name={entry.icon} size={13} />
<span>{t(entry.key)}</span>
</div>
<div class="grid grid-cols-3 gap-2">
{#each entry.children as child}
<a href={child.href} onclick={closeMobileMore}
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200 relative"
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
>
<MdiIcon name={child.icon} size={20} />
<span class="text-xs text-center leading-tight">{t(child.key)}</span>
{#if child.countKey && navCounts[child.countKey]}
<span class="nav-badge-sm" style="position: absolute; top: 0.25rem; right: 0.25rem;">{navCounts[child.countKey]}</span>
{/if}
</a>
{/each}
</div>
</div>
{:else}
<a href={entry.href} onclick={closeMobileMore}
class="flex items-center gap-2 p-3 rounded-lg transition-all duration-200 relative"
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
>
<MdiIcon name={entry.icon} size={18} />
<span class="text-sm flex-1">{t(entry.key)}</span>
{#if entry.countKey && navCounts[entry.countKey]}
<span class="nav-badge">{navCounts[entry.countKey]}</span>
{/if}
</a>
{/if}
{/each}
<button onclick={() => { mobileMoreOpen = false; logout(); }}
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiLogout" size={20} />
<span class="text-xs text-center leading-tight">{t('nav.logout')}</span>
</button>
<div class="pt-2" style="border-top: 1px solid var(--color-border);">
<button onclick={() => { closeMobileMore(); logout(); }}
class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiLogout" size={18} />
<span class="text-sm">{t('nav.logout')}</span>
</button>
</div>
</div>
</div>
{/if}
<!-- Main content -->
<main class="flex-1 overflow-auto pb-16 md:pb-0">
<main class="flex-1 overflow-auto md:pb-0"
style="padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));">
{#key page.url.pathname}
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
{@render children()}
@@ -611,19 +631,22 @@
<!-- Password change modal -->
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
<form onsubmit={changePassword} class="space-y-3">
<input type="text" name="username" autocomplete="username" value={auth.user?.username ?? ''}
readonly aria-hidden="true" tabindex="-1"
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;" />
<div>
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
<input id="pwd-current" type="password" autocomplete="current-password" bind:value={pwdCurrent} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="pwd-new" type="password" bind:value={pwdNew} required minlength="8"
<input id="pwd-new" type="password" autocomplete="new-password" bind:value={pwdNew} required minlength="8"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label>
<input id="pwd-confirm" type="password" bind:value={pwdConfirm} required minlength="8"
<input id="pwd-confirm" type="password" autocomplete="new-password" bind:value={pwdConfirm} required minlength="8"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div>
{#if pwdMsg}
@@ -117,6 +117,14 @@
return form.slots[slotName]?.[activeLocale] || '';
}
/** Resolve variable reference for a slot, preferring provider-specific over shared. */
function getVarsFor(slotName: string) {
const providerVars = varsRef[form.provider_type];
return providerVars?.[slotName] ?? varsRef[slotName];
}
let modalVars = $derived(showVarsFor ? getVarsFor(showVarsFor) : null);
/** Set slot template for current locale (immutable update). */
function setSlotValue(slotName: string, value: string) {
form.slots = {
@@ -369,7 +377,7 @@
{t('templateConfig.preview')}
</button>
{/if}
{#if varsRef[slot.name]}
{#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}
@@ -385,7 +393,7 @@
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3}
errorLine={slotErrorLines[slot.name] || null}
variables={varsRef[slot.name] || undefined}
variables={getVarsFor(slot.name) || undefined}
/>
{/if}
@@ -468,11 +476,11 @@
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{varsRef[showVarsFor].description}</p>
{#if showVarsFor && modalVars}
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{modalVars.description}</p>
<div class="space-y-1">
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
{#each Object.entries(modalVars.variables || {}) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
@@ -484,11 +492,19 @@
['album_fields', 'album', 'Album fields'],
['command_fields', 'cmd', 'Command fields'],
['event_fields', 'event', 'Event fields'],
['repo_fields', 'repo', 'Repository fields'],
['issue_fields', 'issue', 'Issue fields'],
['pr_fields', 'pr', 'Pull request fields'],
['commit_fields', 'c', 'Commit fields'],
['board_fields', 'board', 'Board fields'],
['card_fields', 'card', 'Card fields'],
['list_fields', 'lst', 'List fields'],
['device_fields', 'd', 'Device fields'],
] as [fieldKey, prefix, title]}
{#if varsRef[showVarsFor][fieldKey]}
{#if modalVars[fieldKey]}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p>
{#each Object.entries(varsRef[showVarsFor][fieldKey]) as [name, desc]}
{#each Object.entries(modalVars[fieldKey]) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
+121 -10
View File
@@ -9,26 +9,66 @@
import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
interface CacheBucketStats {
count: number;
total_size_bytes: number;
oldest: string | null;
newest: string | null;
}
interface CacheStats {
url: CacheBucketStats;
asset: CacheBucketStats;
}
let loaded = $state(false);
let saving = $state(false);
let clearingCache = $state(false);
let confirmClearCache = $state(false);
let error = $state('');
let settings = $state({
external_url: '',
telegram_webhook_secret: '',
telegram_cache_ttl_hours: '48',
telegram_cache_ttl_hours: '720',
telegram_asset_cache_max_entries: '5000',
supported_locales: 'en,ru',
timezone: 'UTC',
});
let cacheStats = $state<CacheStats | null>(null);
async function loadCacheStats() {
try {
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
} catch { cacheStats = null; }
}
onMount(async () => {
try {
settings = await api('/settings');
await loadCacheStats();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { loaded = true; }
});
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
function formatTs(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
return isNaN(d.getTime()) ? iso : d.toLocaleString();
}
async function save() {
saving = true; error = '';
try {
@@ -37,6 +77,17 @@
} catch (err: any) { error = err.message; snackError(err.message); }
saving = false;
}
async function clearTelegramCache() {
confirmClearCache = false;
clearingCache = true;
try {
await api('/settings/telegram-cache/clear', { method: 'POST' });
snackSuccess(t('settings.clearCacheDone'));
await loadCacheStats();
} catch (err: any) { snackError(err.message); }
clearingCache = false;
}
</script>
<PageHeader title={t('settings.title')} description={t('settings.description')} />
@@ -59,9 +110,8 @@
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
<input bind:value={settings.timezone} placeholder="UTC"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
<TimezoneSelector bind:value={settings.timezone} />
</div>
</div>
</Card>
@@ -75,14 +125,68 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
<input bind:value={settings.telegram_webhook_secret} type="password" placeholder={t('providers.optional')}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</form>
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="1" max="720"
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
</div>
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
{#each [
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
] as bucket}
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
<div class="flex items-baseline justify-between gap-2">
<span class="font-medium">{bucket.label}</span>
{#if bucket.data && bucket.data.count > 0}
<span>
<span class="font-mono">{bucket.data.count}</span>
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
{#if bucket.data.total_size_bytes > 0}
<span style="color: var(--color-muted-foreground);"> · </span>
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
{/if}
</span>
{:else}
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
{/if}
</div>
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
{#if bucket.data.oldest}
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
{/if}
{#if bucket.data.newest}
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<div class="flex items-center gap-3 flex-wrap">
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
<MdiIcon name="mdiDeleteSweep" size={16} />
{clearingCache ? t('common.loading') : t('settings.clearCache')}
</button>
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
</div>
</div>
</Card>
@@ -94,9 +198,8 @@
</h3>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
<input bind:value={settings.supported_locales} placeholder="en,ru,de,fr"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
<LocaleSelector bind:value={settings.supported_locales} />
</div>
</div>
</Card>
@@ -105,4 +208,12 @@
{saving ? t('common.loading') : t('common.save')}
</Button>
</div>
<ConfirmModal open={confirmClearCache}
title={t('settings.clearCacheConfirmTitle')}
message={t('settings.clearCacheConfirm')}
confirmLabel={t('settings.clearCacheConfirmBtn')}
confirmIcon="mdiDeleteSweep"
onconfirm={clearTelegramCache}
oncancel={() => confirmClearCache = false} />
{/if}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.2.2"
version = "0.2.8"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -46,6 +46,7 @@ from .receiver import (
from .telegram.cache import TelegramFileCache
from .telegram.client import TelegramClient
from .telegram.media import (
build_telegram_asset_entry,
extract_asset_id_from_url,
is_asset_cache_key,
is_asset_id,
@@ -266,23 +267,19 @@ class NotificationDispatcher:
# Prefer internal URL for fetching (LAN speed vs public internet)
internal_url = (target.provider_internal_url or "").rstrip("/")
external_url = (target.provider_external_url or "").rstrip("/")
provider_urls = [u for u in (internal_url, external_url) if u]
assets = []
media_assets: list[Any] = [] # aligned with `assets` for preload
for asset in event.added_assets[:max_media]:
url = asset.preview_url or asset.thumbnail_url or asset.full_url
if url:
# Rewrite external URL to internal for faster LAN fetching
if internal_url and external_url and url.startswith(external_url):
url = internal_url + url[len(external_url):]
asset_type = "video" if asset.type.value == "video" else "photo"
asset_headers = {}
if target.provider_api_key and any(url.startswith(u) for u in provider_urls):
asset_headers["x-api-key"] = target.provider_api_key
asset_entry: dict[str, Any] = {"url": url, "type": asset_type, "headers": asset_headers}
# Pass explicit cache_key if set by provider (e.g. Google Photos)
if asset.extra.get("cache_key"):
asset_entry["cache_key"] = asset.extra["cache_key"]
asset_entry = build_telegram_asset_entry(
url=url or "",
media_type=asset.type.value,
api_key=target.provider_api_key,
internal_url=internal_url,
external_url=external_url,
cache_key=asset.extra.get("cache_key"),
)
if asset_entry is not None:
assets.append(asset_entry)
media_assets.append(asset)
@@ -294,10 +291,24 @@ class NotificationDispatcher:
await self._preload_asset_data(assets, media_assets, session, max_size)
default_message = self._render_message(event, target, target.locale)
# Asset cache (when in thumbhash mode) invalidates entries when the
# asset's visual content changes. The resolver maps asset id → its
# current thumbhash. Providers that expose thumbhash put it in
# ``asset.extra["thumbhash"]`` (currently Immich).
thumbhash_map = {
asset.id: asset.extra.get("thumbhash")
for asset in event.added_assets
if asset.extra.get("thumbhash")
}
thumbhash_resolver = (
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
)
client = TelegramClient(
session, bot_token,
url_cache=self._url_cache,
asset_cache=self._asset_cache,
thumbhash_resolver=thumbhash_resolver,
)
for receiver in target.receivers:
@@ -11,56 +11,69 @@ from notify_bridge_core.storage import StorageBackend
_LOGGER = logging.getLogger(__name__)
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
DEFAULT_MAX_ENTRIES = 5000
class TelegramFileCache:
"""Cache for Telegram file_ids to avoid re-uploading media.
Supports two validation modes:
- TTL mode (default): entries expire after a configured time-to-live
- Thumbhash mode: entries validated by comparing stored thumbhash with current
"""
Two complementary invalidation strategies, usable together or separately:
- TTL: entries expire after ``ttl_seconds``. Set to 0 to disable TTL
(cache essentially forever, subject only to the size cap).
- Thumbhash mode: entries are validated on read by comparing the stored
thumbhash with the one the caller supplies; a mismatch drops the entry.
Intended for content-addressable assets (e.g. Immich) where re-uploads
should be triggered by visual change, not elapsed time.
THUMBHASH_MAX_ENTRIES = 2000
``max_entries`` always applies as an LRU size cap (by ``cached_at``).
"""
def __init__(
self,
backend: StorageBackend,
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
use_thumbhash: bool = False,
max_entries: int = DEFAULT_MAX_ENTRIES,
) -> None:
self._backend = backend
self._data: dict[str, Any] | None = None
self._ttl_seconds = ttl_seconds
self._use_thumbhash = use_thumbhash
self._max_entries = max_entries
async def async_load(self) -> None:
self._data = await self._backend.load() or {"files": {}}
await self._cleanup_expired()
async def _cleanup_expired(self) -> None:
if self._use_thumbhash:
files = self._data.get("files", {}) if self._data else {}
if len(files) > self.THUMBHASH_MAX_ENTRIES:
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
for key in sorted_keys[: len(files) - self.THUMBHASH_MAX_ENTRIES]:
del files[key]
await self._backend.save(self._data)
return
if not self._data or "files" not in self._data:
return
files = self._data["files"]
changed = False
now = datetime.now(timezone.utc)
expired = [
url for url, entry in self._data["files"].items()
if entry.get("cached_at") and
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
]
if expired:
# TTL sweep — only when TTL validation is active (i.e. no thumbhash
# mode and a positive TTL). In thumbhash mode we rely entirely on
# content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
# cache forever, subject only to the size cap.
if not self._use_thumbhash and self._ttl_seconds > 0:
now = datetime.now(timezone.utc)
expired = [
url for url, entry in files.items()
if entry.get("cached_at") and
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
]
for key in expired:
del self._data["files"][key]
del files[key]
changed = True
# LRU cap — always enforced. Evicts oldest-cached entries first.
if self._max_entries > 0 and len(files) > self._max_entries:
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
for key in sorted_keys[: len(files) - self._max_entries]:
del files[key]
changed = True
if changed:
await self._backend.save(self._data)
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
@@ -77,7 +90,7 @@ class TelegramFileCache:
if stored and stored != thumbhash:
del self._data["files"][key]
return None
else:
elif self._ttl_seconds > 0:
cached_at_str = entry.get("cached_at")
if cached_at_str:
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
@@ -152,3 +165,32 @@ class TelegramFileCache:
async def async_remove(self) -> None:
await self._backend.remove()
self._data = None
def stats(self) -> dict[str, Any]:
"""Return summary stats about the current cache contents.
Includes the number of cached entries, total tracked size in bytes
(only counts entries with a recorded ``size``), and the oldest /
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
"""
files = self._data.get("files", {}) if self._data else {}
count = len(files)
total_size = 0
oldest: str | None = None
newest: str | None = None
for entry in files.values():
size = entry.get("size")
if isinstance(size, int):
total_size += size
cached_at = entry.get("cached_at")
if cached_at:
if oldest is None or cached_at < oldest:
oldest = cached_at
if newest is None or cached_at > newest:
newest = cached_at
return {
"count": count,
"total_size_bytes": total_size,
"oldest": oldest,
"newest": newest,
}
@@ -89,6 +89,18 @@ class TelegramClient:
self, url: str | None, cache_key: str | None = None,
) -> tuple[TelegramFileCache | None, str | None, str | None]:
if cache_key:
# Route asset-UUID cache keys to the asset cache so single-item
# sends hit the same cache the media-group path uses. Without
# this, a command returning one photo stored file_ids in the
# URL cache and a command returning multiple stored them in
# the asset cache — repeated sends never hit.
if is_asset_cache_key(cache_key):
bare_id = asset_id_from_cache_key(cache_key)
thumbhash = (
self._thumbhash_resolver(bare_id)
if self._thumbhash_resolver else None
)
return self._asset_cache, cache_key, thumbhash
return self._url_cache, cache_key, None
if url:
if is_asset_id(url):
@@ -3,7 +3,7 @@
from __future__ import annotations
import re
from typing import Final
from typing import Any, Final
from urllib.parse import urlparse
# Telegram constants
@@ -52,6 +52,65 @@ def extract_asset_id_from_url(url: str) -> str | None:
return None
def build_telegram_asset_entry(
*,
url: str,
media_type: str,
api_key: str | None = None,
internal_url: str = "",
external_url: str = "",
cache_key: str | None = None,
) -> dict[str, Any] | None:
"""Build a ``TelegramClient.send_notification`` asset dict from raw fields.
Shared by the notification dispatcher and provider command handlers so
both paths agree on media typing, URL rewriting, and auth headers. In
particular: video assets MUST be typed ``"video"`` and point at a real
video endpoint (e.g. Immich ``/video/playback``) — if they are sent as
``"photo"`` pointing at a thumbnail URL, Telegram delivers a still image
for every video in a media group and the user sees a dead poster frame
instead of a playable clip.
Args:
url: Source URL for the asset bytes. Prefer a transcoded/preview
URL for videos (``/video/playback``) and a preview-sized
thumbnail for photos.
media_type: Case-insensitive type token. Accepts ``"video"``/
``"VIDEO"``/``MediaType.VIDEO`` or any photo-like string.
api_key: Optional API key. Attached as ``x-api-key`` iff the URL is
served by one of the provider hosts in ``internal_url`` /
``external_url`` (prevents leaking the key to unrelated hosts).
internal_url: LAN-facing provider URL. Used to rewrite
``external_url`` prefixes so Docker-host downloads stay on the
LAN instead of egressing to the public domain.
external_url: Public provider URL the notification URL was built
from. Only used for the LAN rewrite and the api-key scope check.
cache_key: Optional explicit cache key. Providers whose URLs don't
embed a stable asset id (Google Photos) pass one through so the
file_id cache still works.
Returns ``None`` iff ``url`` is empty.
"""
if not url:
return None
if internal_url and external_url and url.startswith(external_url):
url = internal_url + url[len(external_url):]
normalized_type = str(media_type or "").lower()
entry_type = "video" if normalized_type == "video" else "photo"
headers: dict[str, str] = {}
provider_urls = [u for u in (internal_url, external_url) if u]
if api_key and (not provider_urls or any(url.startswith(u) for u in provider_urls)):
headers["x-api-key"] = api_key
entry: dict[str, Any] = {"url": url, "type": entry_type, "headers": headers}
if cache_key:
entry["cache_key"] = cache_key
return entry
def split_media_by_upload_size(
media_items: list[tuple], max_upload_size: int
) -> list[list[tuple]]:
@@ -193,6 +193,27 @@ def get_asset_video_url(
return None
def build_asset_media_urls(
external_url: str, asset_id: str, asset_type: str,
) -> tuple[str, str]:
"""Return ``(preview_url, full_url)`` for an Immich asset.
Single source of truth for the photo-vs-video endpoint rule. Used by
``asset_to_media`` (notification path) and the bot command handlers
(command path) so both always pick the transcoded ``/video/playback``
for videos and the preview-sized thumbnail for photos — if they
diverge, Telegram ends up delivering a still JPEG for videos in a
media group.
"""
is_video = asset_type == ASSET_TYPE_VIDEO
if is_video:
preview_url = f"{external_url}/api/assets/{asset_id}/video/playback"
else:
preview_url = f"{external_url}/api/assets/{asset_id}/thumbnail?size=preview"
full_url = f"{external_url}/api/assets/{asset_id}/original"
return preview_url, full_url
def build_asset_detail(
asset: ImmichAssetInfo,
external_url: str,
@@ -246,12 +267,7 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
# preview_url is what the notification dispatcher feeds to Telegram as the
# actual media bytes — for videos it must be the transcoded playback (mp4),
# not the JPEG thumbnail, or Telegram receives a JPEG labeled as video/mp4.
if asset.type == ASSET_TYPE_VIDEO:
preview_url = f"{external_url}/api/assets/{asset.id}/video/playback"
full_url = f"{external_url}/api/assets/{asset.id}/original"
else:
preview_url = f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview"
full_url = f"{external_url}/api/assets/{asset.id}/original"
preview_url, full_url = build_asset_media_urls(external_url, asset.id, asset.type)
return MediaAsset(
id=asset.id,
@@ -297,19 +297,9 @@ class ImmichClient:
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids:
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
try:
async with self._session.post(
f"{self._url}/api/search/smart",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
return items[:limit]
except aiohttp.ClientError:
pass
return []
return await self._search_items(
f"{self._url}/api/search/smart", payload, limit, "smart",
)
async def search_metadata(
self,
@@ -322,18 +312,57 @@ class ImmichClient:
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids:
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
return await self._search_items(
f"{self._url}/api/search/metadata", payload, limit, "metadata",
)
async def _search_items(
self,
url: str,
payload: dict[str, Any],
limit: int,
kind: str,
) -> list[dict[str, Any]]:
"""Shared POST-and-extract-items helper with error logging.
Returns an empty list on any error; previously these paths swallowed
non-200s silently, making "/search always returns no results" on
misbehaving Immich deployments impossible to diagnose without a
network trace. Logging keeps the empty-list contract but tells the
operator *why* it's empty.
"""
try:
async with self._session.post(
f"{self._url}/api/search/metadata",
url,
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
return items[:limit]
except aiohttp.ClientError:
pass
if response.status != 200:
body_snip = await response.text()
_LOGGER.warning(
"Immich %s search non-200: HTTP %s body=%s",
kind, response.status, _redact_body(body_snip),
)
return []
data = await response.json()
# Modern Immich: {"assets": {"items": [...], ...}}
assets_block = data.get("assets")
if isinstance(assets_block, dict):
items = assets_block.get("items", []) or []
elif isinstance(assets_block, list):
# Older/alternate shape — flat list of assets.
items = assets_block
else:
_LOGGER.warning(
"Immich %s search returned unexpected shape: keys=%s",
kind, list(data.keys())[:5],
)
items = []
return items[:limit]
except aiohttp.ClientError as err:
_LOGGER.warning("Immich %s search transport error: %s", kind, err)
except Exception as err: # noqa: BLE001 — don't crash caller on unexpected JSON
_LOGGER.warning("Immich %s search parse error: %s", kind, err)
return []
async def search_by_person(
@@ -1,4 +1,3 @@
📊 Status
Trackers: {{ trackers_active }}/{{ trackers_total }} active
Albums: {{ total_albums }}
Last event: {{ last_event }}
Last event: {{ last_event }}
@@ -1,4 +1,3 @@
📊 Статус
Трекеры: {{ trackers_active }}/{{ trackers_total }} активных
Альбомы: {{ total_albums }}
Последнее событие: {{ last_event }}
Последнее событие: {{ last_event }}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.2.2"
version = "0.2.8"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -20,7 +20,8 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
_SETTING_KEYS = {
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
"telegram_cache_ttl_hours": None, # no env fallback, default 48
"telegram_cache_ttl_hours": None, # URL cache TTL; 0 disables TTL
"telegram_asset_cache_max_entries": None, # LRU cap for both caches
"supported_locales": None, # comma-separated locale codes
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
}
@@ -28,11 +29,18 @@ _SETTING_KEYS = {
_DEFAULTS = {
"external_url": "",
"telegram_webhook_secret": "",
"telegram_cache_ttl_hours": "48",
# 720h = 30d. URL cache only; asset cache uses thumbhash validation
# (content-addressable) and ignores TTL entirely.
"telegram_cache_ttl_hours": "720",
"telegram_asset_cache_max_entries": "5000",
"supported_locales": "en,ru",
"timezone": "UTC",
}
# Settings whose changes require dropping in-memory Telegram caches so the
# next dispatch rebuilds them with the new parameters. Files are preserved.
_CACHE_SETTING_KEYS = {"telegram_cache_ttl_hours", "telegram_asset_cache_max_entries"}
async def get_setting(session: AsyncSession, key: str) -> str:
"""Read a setting from DB, falling back to env var then default."""
@@ -48,9 +56,14 @@ async def get_setting(session: AsyncSession, key: str) -> str:
class SettingsUpdate(BaseModel):
# Numeric fields declared as int|str so clients can send either form.
# Svelte's bind:value on <input type="number"> coerces to a JS number,
# so the frontend sends ints for these; older/manual clients may send
# strings. We normalize to str before persisting.
external_url: str | None = None
telegram_webhook_secret: str | None = None
telegram_cache_ttl_hours: str | None = None
telegram_cache_ttl_hours: int | str | None = None
telegram_asset_cache_max_entries: int | str | None = None
supported_locales: str | None = None
timezone: str | None = None
@@ -80,19 +93,39 @@ async def update_settings(
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
old_base_url = await get_setting(session, "external_url")
old_secret = await get_setting(session, "telegram_webhook_secret")
old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
for key in _SETTING_KEYS:
value = getattr(body, key, None)
if value is None:
continue
value_str = str(value)
# GET masks the webhook secret as "***<last4>" so the real value is
# never exposed to the frontend. If the client sends the mask back
# (which happens on every save, since bind:value holds whatever GET
# returned), treat it as "unchanged" — otherwise we'd overwrite the
# real secret with its mask, silently breaking webhook HMAC.
if key == "telegram_webhook_secret" and value_str.startswith("***"):
continue
row = await session.get(AppSetting, key)
if row:
row.value = value
row.value = value_str
else:
row = AppSetting(key=key, value=value)
row = AppSetting(key=key, value=value_str)
session.add(row)
await session.commit()
# Drop in-memory caches if any cache-tuning setting actually changed, so
# the next dispatch rebuilds them with the new parameters. Files survive.
cache_changed = False
for key in _CACHE_SETTING_KEYS:
if await get_setting(session, key) != old_cache_values[key]:
cache_changed = True
break
if cache_changed:
from ..services.watcher import reset_telegram_caches_in_memory
await reset_telegram_caches_in_memory()
new_base_url = await get_setting(session, "external_url")
new_secret = await get_setting(session, "telegram_webhook_secret")
@@ -111,6 +144,25 @@ async def update_settings(
return result
@router.get("/telegram-cache/stats")
async def telegram_cache_stats(
user: User = Depends(require_admin),
):
"""Return counts and sizes for the Telegram file_id caches."""
from ..services.watcher import get_telegram_cache_stats
return await get_telegram_cache_stats()
@router.post("/telegram-cache/clear")
async def clear_telegram_cache(
user: User = Depends(require_admin),
):
"""Clear the Telegram file_id cache (URL and asset) from disk and memory."""
from ..services.watcher import clear_telegram_caches
result = await clear_telegram_caches()
return result
@router.get("/locales")
async def get_supported_locales(
user: User = Depends(get_current_user),
@@ -138,13 +138,11 @@ async def get_command_variables(
# --- Immich-specific ---
immich = {
"status": {
"description": "/status tracker summary",
"description": "/status tracker summary (scoped to this chat)",
"variables": {
**common_vars,
"trackers_active": "Number of active trackers",
"trackers_total": "Total tracker count",
"total_albums": "Total tracked albums",
"last_event": "Last event timestamp string",
"total_albums": "Tracked albums visible to this chat",
"last_event": "Last event timestamp string (scoped to this chat's albums)",
},
},
"albums": {
@@ -56,6 +56,7 @@ class ProviderCommandHandler(ABC):
config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None,
page: int = 1,
) -> CommandResponse | None:
"""Handle a provider-specific command for a single tracker.
@@ -71,6 +72,13 @@ class ProviderCommandHandler(ABC):
bot: The Telegram bot instance.
tracker: The command tracker being dispatched.
config: The command config for this tracker.
listener: The listener row for this (tracker, bot) pair.
allowed_album_ids: Precomputed album scope for this (bot, chat)
pair. Resolved by the dispatcher from the listener override
(if set) or the notification-routing graph. ``None`` means
"no scope restriction" (rarely the right default for album
providers empty set is the common case).
page: 1-based page number for paginated commands (/search, /find).
Returns:
A CommandResponse, or None if unhandled.
@@ -8,7 +8,14 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import EventLog, NotificationTracker, ServiceProvider
from ..database.models import (
EventLog,
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
ServiceProvider,
TargetReceiver,
)
_LOGGER = logging.getLogger(__name__)
@@ -20,25 +27,125 @@ async def get_trackers_for_provider(provider_id: int) -> list[NotificationTracke
return await _get_notification_trackers_for_providers({provider_id})
async def get_last_event_str(tracker_ids: list[int]) -> str:
async def get_last_event_str(
tracker_ids: list[int],
*,
allowed_album_ids: set[str] | None = None,
) -> str:
"""Get formatted timestamp of most recent event for given trackers.
Returns a 'YYYY-MM-DD HH:MM' string, or '-' if no events exist.
When ``allowed_album_ids`` is provided, only events whose
``collection_id`` is in the set are considered matches the per-chat
scope applied via ``CommandTrackerListener.allowed_album_ids``.
"""
if not tracker_ids:
return "-"
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
query = (
select(EventLog)
.where(EventLog.tracker_id.in_(tracker_ids))
.order_by(EventLog.created_at.desc())
.limit(1)
)
if allowed_album_ids is not None:
query = query.where(EventLog.collection_id.in_(list(allowed_album_ids)))
result = await session.exec(query.limit(1))
last_event = result.first()
return last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
async def resolve_chat_album_scope(
*,
provider_id: int,
bot_id: int,
chat_id: str,
) -> set[str]:
"""Compute the album scope for a (provider, bot, chat) triple.
Walks the notification-routing graph: find every notification tracker for
``provider_id`` that ultimately delivers to a Telegram receiver matching
this ``(bot_id, chat_id)``, then union their ``collection_ids``. The
result is the set of albums this specific chat legitimately sees
notifications for which is the natural "allowed albums" for commands
issued in that chat.
Returns:
set of album ids. Empty set = "no tracker routes to this chat"
caller should treat as "show nothing" (defense in depth); otherwise
a bot's chats would leak the provider's full album catalog.
Notes:
- Only enabled ``TargetReceiver`` rows are considered.
- Both direct Telegram targets and broadcast targets that fan out
to a Telegram child target are resolved.
- Explicit ``CommandTrackerListener.allowed_album_ids`` override is
NOT applied here that's the dispatcher's job. This helper is
the "derived" fallback.
"""
engine = get_engine()
async with AsyncSession(engine) as session:
# 1. Telegram receivers in this chat (directly or via broadcast).
direct_rows = (await session.exec(
select(TargetReceiver, NotificationTarget)
.join(
NotificationTarget,
TargetReceiver.target_id == NotificationTarget.id,
)
.where(
TargetReceiver.enabled == True, # noqa: E712
NotificationTarget.type == "telegram",
)
)).all()
target_ids: set[int] = set()
for recv, target in direct_rows:
rc_chat = str(recv.config.get("chat_id", "") or "")
rc_bot = target.config.get("bot_id")
if rc_chat == str(chat_id) and rc_bot == bot_id:
target_ids.add(target.id)
# Follow broadcast parents: any broadcast target whose
# child_target_ids includes one of our direct Telegram target_ids
# also counts as "routes to this chat".
broadcast_rows = (await session.exec(
select(NotificationTarget).where(NotificationTarget.type == "broadcast")
)).all()
for b in broadcast_rows:
children = set(b.config.get("child_target_ids", []) or [])
disabled = set(b.config.get("disabled_child_ids", []) or [])
if (children - disabled) & target_ids:
target_ids.add(b.id)
if not target_ids:
return set()
# 2. Trackers pointing at those targets.
tracker_target_rows = (await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.target_id.in_(target_ids)
)
)).all()
tracker_ids = {tt.tracker_id for tt in tracker_target_rows}
if not tracker_ids:
return set()
# 3. Filter trackers by provider and collect collection_ids.
trackers = (await session.exec(
select(NotificationTracker).where(
NotificationTracker.id.in_(tracker_ids),
NotificationTracker.provider_id == provider_id,
)
)).all()
scope: set[str] = set()
for tr in trackers:
for aid in (tr.collection_ids or []):
if aid:
scope.add(aid)
return scope
def get_tracked_collection_ids(
provider: ServiceProvider,
trackers: list[NotificationTracker],
@@ -79,6 +79,7 @@ class GiteaCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Gitea has no album model)
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
@@ -286,6 +286,8 @@ async def handle_command(
page = max(1, count_override)
count_override = None
from .command_utils import resolve_chat_album_scope
responses: list[CommandResponse] = []
for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
@@ -303,10 +305,27 @@ async def handle_command(
count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media"
# Resolve the album scope for this (provider, bot, chat) triple.
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
# - Otherwise derive from notification routing: only albums that
# already deliver notifications to this chat are queryable from
# it. Prevents commands leaking the full album catalog into
# chats that were never set up to receive from those trackers.
if listener is not None and listener.allowed_album_ids is not None:
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
else:
allowed_album_ids = await resolve_chat_album_scope(
provider_id=provider.id,
bot_id=bot.id,
chat_id=chat_id,
)
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
listener=listener, page=page,
listener=listener,
allowed_album_ids=allowed_album_ids,
page=page,
)
if result is not None:
responses.append(result)
@@ -348,12 +367,26 @@ async def send_reply(
bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Send a text reply via TelegramClient."""
if session is None:
from ..services.http_session import get_http_session
session = await get_http_session()
client = TelegramClient(session, bot_token)
result = await client.send_message(chat_id, text, reply_to_message_id=reply_to_message_id)
"""Send a text reply to a chat.
Thin wrapper that goes through the single ``services.telegram_send``
entry point so commands and notifications share one routine same
HTTP session pool, same file_id caches.
Command responses are listings (albums, people, events, ...) that
embed multiple links; Telegram's default behavior of rendering a
preview of the first URL is almost never what the user wants and
clashes with the "Disable link previews" toggle operators set on
their Telegram target. We always pass
``disable_web_page_preview=True`` here.
"""
from ..services.telegram_send import send_telegram_message
result = await send_telegram_message(
bot_token, chat_id, text,
reply_to_message_id=reply_to_message_id,
disable_web_page_preview=True,
)
if not result.get("success"):
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
@@ -363,30 +396,28 @@ async def send_media_group(
reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Send media items via TelegramClient.send_notification."""
"""Send media items via the shared Telegram routine.
``media_items`` must already be in TelegramClient asset format each
entry contains ``type`` (``"photo"``/``"video"``/``"document"``),
``url``, optional ``cache_key``, and optional ``headers``. Provider
command handlers build this format via
``build_telegram_asset_entry`` the same helper the notification
dispatcher uses so videos keep their ``"video"`` type and point at
a real video URL instead of a still thumbnail.
Uses ``services.telegram_send.send_telegram_media`` so the URL cache
and asset cache are wired in exactly like the notification path.
Repeated ``/latest`` / ``/random`` commands that match previously-sent
assets hit the cache and skip the re-upload.
"""
if not media_items:
return
# Convert command handler media format to TelegramClient asset format
assets = []
for item in media_items:
assets.append({
"type": "photo",
"url": item.get("thumbnail_url", ""),
"cache_key": item.get("asset_id", ""),
"headers": {"x-api-key": item.get("api_key", "")},
})
from ..services.telegram_send import send_telegram_media
# Build caption from first item
captions = [item.get("caption", "") for item in media_items if item.get("caption")]
caption = "\n".join(captions) if captions else None
if session is None:
from ..services.http_session import get_http_session
session = await get_http_session()
client = TelegramClient(session, bot_token)
result = await client.send_notification(
chat_id, assets=assets, caption=caption,
result = await send_telegram_media(
bot_token, chat_id, media_items,
reply_to_message_id=reply_to_message_id,
chat_action=None,
)
@@ -17,7 +17,10 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_albums(
provider: ServiceProvider, locale: str,
provider: ServiceProvider,
locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
if not trackers:
@@ -31,6 +34,13 @@ async def _cmd_albums(
if aid not in seen:
seen.add(aid)
album_ids.append(aid)
# Intersect with the dispatcher-resolved scope (listener override, else
# derived from notification routing for this chat). Without this,
# /albums leaks the full tracked-album list into chats never wired up.
if allowed_album_ids is not None:
album_ids = [aid for aid in album_ids if aid in allowed_album_ids]
if not album_ids:
return {"albums": []}
@@ -6,7 +6,11 @@ import asyncio
import logging
from typing import Any
from notify_bridge_core.providers.immich.asset_utils import get_public_url
from notify_bridge_core.notifications.telegram.media import build_telegram_asset_entry
from notify_bridge_core.providers.immich.asset_utils import (
build_asset_media_urls,
get_public_url,
)
from ..handler import _render_cmd_template
@@ -74,13 +78,16 @@ def build_asset_dict(
) -> dict[str, Any]:
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict."""
if isinstance(asset, dict):
# Immich raw search responses nest geo under exifInfo — pull it out so
# templates can use flat asset.city / asset.country.
exif = asset.get("exifInfo") or {}
d = {
"id": asset.get("id", ""),
"originalFileName": asset.get("originalFileName", asset.get("filename", "")),
"type": asset.get("type", "IMAGE"),
"createdAt": asset.get("createdAt", asset.get("created_at", asset.get("fileCreatedAt", ""))),
"city": asset.get("city", ""),
"country": asset.get("country", ""),
"city": asset.get("city") or exif.get("city") or "",
"country": asset.get("country") or exif.get("country") or "",
"is_favorite": asset.get("is_favorite", asset.get("isFavorite", False)),
"public_url": asset.get("public_url", public_url),
}
@@ -123,16 +130,32 @@ def _format_assets(
})
if response_mode == "media":
# Reuse the same URL rule (build_asset_media_urls) and entry builder
# (build_telegram_asset_entry) as the notification dispatcher so both
# paths agree on video → /video/playback and photo → thumbnail. When
# these diverged, Telegram rendered a still JPEG for each video in
# the media group instead of the real clip.
#
# We deliberately do NOT pass ``cache_key`` here. TelegramClient
# derives it from the URL as ``<host>:<uuid>`` — identical to what
# the notification dispatcher produces via extract_asset_id_from_url.
# Passing the bare UUID would put command writes in a separate
# namespace from notification writes, so neither path could hit the
# other's cached file_ids (which is what made the cache look empty
# from the WebUI after running /random).
media_items: list[dict[str, Any]] = []
for asset in assets:
asset_id = asset.get("id", "")
media_items.append({
"type": "photo",
"asset_id": asset_id,
"caption": "",
"thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview",
"api_key": client.api_key,
})
asset_type = (asset.get("type") or "").upper()
preview_url, _ = build_asset_media_urls(client.url, asset_id, asset_type)
entry = build_telegram_asset_entry(
url=preview_url,
media_type="video" if asset_type == "VIDEO" else "image",
api_key=client.api_key,
internal_url=client.url,
)
if entry is not None:
media_items.append(entry)
# Return text message + media items — text is sent first, media as reply
return {"text": text, "media": media_items}
@@ -27,6 +27,8 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_events(
provider: ServiceProvider,
count: int, locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
tracker_ids = [t.id for t in trackers]
@@ -35,12 +37,14 @@ async def _cmd_events(
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
query = (
select(EventLog)
.where(EventLog.tracker_id.in_(tracker_ids))
.order_by(EventLog.created_at.desc())
.limit(count)
)
if allowed_album_ids is not None:
query = query.where(EventLog.collection_id.in_(list(allowed_album_ids)))
result = await session.exec(query.limit(count))
events = result.all()
events_data = [
@@ -25,17 +25,31 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_status(
provider: ServiceProvider, locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
active = sum(1 for t in trackers if t.enabled)
total = len(trackers)
total_albums = sum(len(t.collection_ids or []) for t in trackers)
# Count only albums visible to this chat. Without the scope filter,
# /status in a restricted chat leaks the full album count across the
# provider. ``None`` = no filter; empty set = show nothing.
total_albums = 0
for t in trackers:
for aid in (t.collection_ids or []):
if allowed_album_ids is None or aid in allowed_album_ids:
total_albums += 1
# Last-event timestamp is already scoped — see get_last_event_str, which
# filters EventLog by collection_id against allowed_album_ids.
tracker_ids = [t.id for t in trackers]
last_str = await get_last_event_str(tracker_ids)
last_str = await get_last_event_str(
tracker_ids, allowed_album_ids=allowed_album_ids,
)
# Tracker counts (``trackers_active`` / ``trackers_total``) are a
# per-provider aggregate — they'd leak info about trackers this chat
# has no visibility into once we've scoped everything else. Omitted.
return {
"trackers_active": active, "trackers_total": total,
"total_albums": total_albums, "last_event": last_str,
}
@@ -80,16 +94,17 @@ class ImmichCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None,
page: int = 1,
) -> CommandResponse | None:
if cmd == "status":
ctx = await _cmd_status(provider, locale)
ctx = await _cmd_status(provider, locale, allowed_album_ids=allowed_album_ids)
return CommandResponse(text=_render_cmd_template(cmd_templates, "status", locale, ctx))
if cmd == "albums":
ctx = await _cmd_albums(provider, locale)
ctx = await _cmd_albums(provider, locale, allowed_album_ids=allowed_album_ids)
return CommandResponse(text=_render_cmd_template(cmd_templates, "albums", locale, ctx))
if cmd == "events":
ctx = await _cmd_events(provider, count, locale)
ctx = await _cmd_events(provider, count, locale, allowed_album_ids=allowed_album_ids)
return CommandResponse(text=_render_cmd_template(cmd_templates, "events", locale, ctx))
if cmd == "people":
ctx = await _cmd_people(provider, locale)
@@ -99,7 +114,7 @@ class ImmichCommandHandler(ProviderCommandHandler):
return await _cmd_immich(
cmd, args, count, locale, response_mode,
provider, cmd_templates,
listener=listener, page=page,
allowed_album_ids=allowed_album_ids, page=page,
)
return None
@@ -109,7 +124,7 @@ async def _cmd_immich(
response_mode: str, provider: ServiceProvider,
cmd_templates: dict[str, dict[str, str]],
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None,
page: int = 1,
) -> CommandResponse | None:
"""Handle commands that need Immich API access and may return media."""
@@ -123,10 +138,12 @@ async def _cmd_immich(
seen.add(aid)
all_album_ids.append(aid)
# Per-chat album scope: intersect with listener.allowed_album_ids when set.
if listener is not None and listener.allowed_album_ids is not None:
allowed = set(listener.allowed_album_ids)
all_album_ids = [aid for aid in all_album_ids if aid in allowed]
# Intersect with the scope resolved by the dispatcher (from the listener
# override if set, otherwise from the notification-routing graph for this
# chat). ``None`` = no filter (rare); empty set = show nothing (common
# when the chat has no tracker routing).
if allowed_album_ids is not None:
all_album_ids = [aid for aid in all_album_ids if aid in allowed_album_ids]
ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/")
@@ -5,17 +5,14 @@ from __future__ import annotations
from typing import Any
from ..handler import _render_cmd_template
from .common import _format_assets
from .common import _format_assets, build_asset_dict
def _enrich_assets(assets: list[dict[str, Any]], asset_public_urls: dict[str, str]) -> list[dict[str, Any]]:
"""Add public_url to assets from the pre-built map. Returns new list without mutating inputs."""
if not asset_public_urls:
return assets
"""Normalize raw Immich assets and attach public_url from the pre-built map."""
pub = asset_public_urls or {}
return [
{**asset, "public_url": asset_public_urls.get(asset.get("id", ""), "")}
if asset.get("id", "") in asset_public_urls and not asset.get("public_url")
else asset
build_asset_dict(asset, public_url=pub.get(asset.get("id", ""), ""))
for asset in assets
]
@@ -31,7 +28,7 @@ async def cmd_search(
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""})
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count, page=page)
_enrich_assets(assets, asset_public_urls or {})
assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates)
@@ -46,7 +43,7 @@ async def cmd_find(
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""})
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count, page=page)
_enrich_assets(assets, asset_public_urls or {})
assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates)
@@ -68,7 +65,7 @@ async def cmd_person(
if not person_id:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
assets = await client.search_by_person(person_id, limit=count)
_enrich_assets(assets, asset_public_urls or {})
assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "person", args, locale, response_mode, client, cmd_templates)
@@ -84,5 +81,5 @@ async def cmd_place(
assets = await client.search_smart(
f"photos taken in {args}", album_ids=all_album_ids, limit=count
)
_enrich_assets(assets, asset_public_urls or {})
assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "place", args, locale, response_mode, client, cmd_templates)
@@ -54,6 +54,7 @@ class NutCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (NUT has no album model)
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
@@ -71,6 +71,7 @@ class PlankaCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Planka has no album model)
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
@@ -0,0 +1,119 @@
"""Single entry point for all Telegram send operations.
Both the notification dispatcher (event-driven) and the bot command
handlers (user-driven) funnel their Telegram API calls through this
module. Keeping construction in one place means:
* The shared aiohttp session is always reused (one TCP pool for the
whole process).
* The Telegram file_id caches (URL cache + asset cache) are always
wired in, so repeated sends whether from a scheduled tracker or
a ``/latest`` command reuse cached file_ids instead of re-uploading
the same bytes.
* Future cross-cutting concerns (rate limiting, telemetry, retries)
have exactly one place to live.
The actual Telegram API routine is still ``TelegramClient`` in core
this module just guarantees every caller gets a properly-wired client.
"""
from __future__ import annotations
from typing import Any, Callable
import aiohttp
from notify_bridge_core.notifications.telegram.client import (
NotificationResult,
TelegramClient,
)
from .http_session import get_http_session
from .watcher import _get_telegram_caches
async def get_telegram_client(
bot_token: str,
*,
session: aiohttp.ClientSession | None = None,
thumbhash_resolver: Callable[[str], str | None] | None = None,
) -> TelegramClient:
"""Return a ``TelegramClient`` wired to shared session + shared caches.
Every Telegram send in the process should acquire its client from
here constructing ``TelegramClient`` directly skips the caches and
silently halves cache hit rate.
Args:
bot_token: The bot's API token.
session: Optional explicit aiohttp session. Defaults to the
process-wide shared session.
thumbhash_resolver: Optional asset-id thumbhash lookup. The
notification dispatcher passes one so asset-cache entries
invalidate on visual change; the command path doesn't need it
(commands always ask for a fresh result).
"""
if session is None:
session = await get_http_session()
url_cache, asset_cache = await _get_telegram_caches()
return TelegramClient(
session, bot_token,
url_cache=url_cache,
asset_cache=asset_cache,
thumbhash_resolver=thumbhash_resolver,
)
async def send_telegram_message(
bot_token: str,
chat_id: str,
text: str,
*,
reply_to_message_id: int | None = None,
disable_web_page_preview: bool = True,
parse_mode: str = "HTML",
) -> NotificationResult:
"""Send a plain-text Telegram message with caches wired in."""
client = await get_telegram_client(bot_token)
return await client.send_message(
chat_id, text,
reply_to_message_id=reply_to_message_id,
disable_web_page_preview=disable_web_page_preview,
parse_mode=parse_mode,
)
async def send_telegram_media(
bot_token: str,
chat_id: str,
assets: list[dict[str, Any]],
*,
caption: str | None = None,
reply_to_message_id: int | None = None,
max_group_size: int = 10,
chunk_delay: int = 0,
max_asset_data_size: int | None = None,
send_large_photos_as_documents: bool = False,
chat_action: str | None = "typing",
thumbhash_resolver: Callable[[str], str | None] | None = None,
) -> NotificationResult:
"""Send a Telegram media group (or single asset) with caches wired in.
``assets`` must be in ``TelegramClient`` format see
``notify_bridge_core.notifications.telegram.media.build_telegram_asset_entry``
for the canonical builder.
"""
client = await get_telegram_client(
bot_token, thumbhash_resolver=thumbhash_resolver,
)
return await client.send_notification(
chat_id,
assets=assets,
caption=caption,
reply_to_message_id=reply_to_message_id,
max_group_size=max_group_size,
chunk_delay=chunk_delay,
max_asset_data_size=max_asset_data_size,
send_large_photos_as_documents=send_large_photos_as_documents,
chat_action=chat_action,
)
@@ -35,8 +35,34 @@ _asset_cache: TelegramFileCache | None = None
_cache_lock = asyncio.Lock()
async def _load_cache_settings() -> tuple[int, int]:
"""Return (url_ttl_seconds, asset_max_entries) from app settings.
Defaults apply when the settings rows are missing. Reads in a short-lived
session to avoid coupling to the caller's transaction.
"""
from ..api.app_settings import get_setting
async with AsyncSession(get_engine()) as session:
ttl_hours_str = await get_setting(session, "telegram_cache_ttl_hours")
max_entries_str = await get_setting(session, "telegram_asset_cache_max_entries")
try:
ttl_hours = int(ttl_hours_str) if ttl_hours_str else 720
except ValueError:
ttl_hours = 720
try:
max_entries = int(max_entries_str) if max_entries_str else 5000
except ValueError:
max_entries = 5000
return ttl_hours * 3600, max_entries
async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFileCache | None]:
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR."""
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR.
The URL cache runs in TTL mode (URLs aren't content-addressable); the asset
cache runs in thumbhash mode so entries invalidate on visual change rather
than age. Both honor an LRU size cap from settings.
"""
global _url_cache, _asset_cache
if _url_cache is not None:
return _url_cache, _asset_cache
@@ -50,16 +76,91 @@ async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFile
if not data_dir:
return None, None
cache_dir = Path(data_dir) / "cache"
url_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_url_cache.json"))
asset_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_asset_cache.json"))
ttl_seconds, max_entries = await _load_cache_settings()
url_cache = TelegramFileCache(
JsonFileBackend(cache_dir / "telegram_url_cache.json"),
ttl_seconds=ttl_seconds,
max_entries=max_entries,
)
asset_cache = TelegramFileCache(
JsonFileBackend(cache_dir / "telegram_asset_cache.json"),
use_thumbhash=True,
max_entries=max_entries,
)
await url_cache.async_load()
await asset_cache.async_load()
_url_cache = url_cache
_asset_cache = asset_cache
_LOGGER.info("Initialized Telegram file caches in %s", cache_dir)
_LOGGER.info(
"Initialized Telegram caches in %s (url ttl=%ds, max_entries=%d, asset thumbhash mode)",
cache_dir, ttl_seconds, max_entries,
)
return _url_cache, _asset_cache
async def reset_telegram_caches_in_memory() -> None:
"""Drop in-memory cache refs without touching files on disk.
Used after settings changes so the next dispatch re-initializes caches
with fresh parameters. Contrast with ``clear_telegram_caches`` which also
deletes cached file_ids.
"""
global _url_cache, _asset_cache
async with _cache_lock:
_url_cache = None
_asset_cache = None
_LOGGER.info("Reset Telegram cache refs in memory (files preserved)")
async def get_telegram_cache_stats() -> dict[str, Any]:
"""Return stats for the URL and asset Telegram caches.
Loads caches lazily if they haven't been touched by a dispatch yet.
Returns zero-counts when ``NOTIFY_BRIDGE_DATA_DIR`` is not configured.
"""
url_cache, asset_cache = await _get_telegram_caches()
empty = {"count": 0, "total_size_bytes": 0, "oldest": None, "newest": None}
return {
"url": url_cache.stats() if url_cache else empty,
"asset": asset_cache.stats() if asset_cache else empty,
}
async def clear_telegram_caches() -> dict[str, Any]:
"""Delete both Telegram file caches from disk and reset in-memory state.
Next dispatch re-initializes the caches via `_get_telegram_caches()`.
Returns a summary with the paths that were removed.
"""
global _url_cache, _asset_cache
async with _cache_lock:
removed: list[str] = []
for cache, label in ((_url_cache, "url"), (_asset_cache, "asset")):
if cache is not None:
await cache.async_remove()
removed.append(label)
# Also remove files from disk in case caches were never initialized
# in this process (data_dir set but dispatch never ran).
import os
from pathlib import Path
data_dir = os.environ.get("NOTIFY_BRIDGE_DATA_DIR")
if data_dir:
cache_dir = Path(data_dir) / "cache"
for name in ("telegram_url_cache.json", "telegram_asset_cache.json"):
path = cache_dir / name
if path.exists():
try:
path.unlink()
except OSError as e:
_LOGGER.warning("Failed to remove %s: %s", path, e)
_url_cache = None
_asset_cache = None
_LOGGER.info("Cleared Telegram file caches: %s", removed or "none in memory")
return {"cleared": True, "removed": removed}
async def check_tracker(tracker_id: int) -> dict[str, Any]:
"""Poll a tracker's provider for changes and dispatch notifications."""
engine = get_engine()