Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6481605ca | |||
| 6de9a1289e | |||
| 325eabd751 | |||
| fab6169cf9 | |||
| 85311684d9 | |||
| d7daadadc2 | |||
| e04ad16ca6 | |||
| d7d0a5d921 | |||
| 93df538819 | |||
| 2be608ba95 | |||
| 5028f15f4f | |||
| 5a232f18b8 | |||
| 3b76a09759 | |||
| 4ff3876e49 |
+11
-16
@@ -1,27 +1,22 @@
|
||||
## v0.2.2 (2026-04-22)
|
||||
# v0.2.7 (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 to v0.2.6: unifies the Telegram send routine across notifications
|
||||
and bot commands so both sides share the same aiohttp session, the same
|
||||
`file_id` caches, and the same rules for video / thumbnail URL construction.
|
||||
Eliminates repeat uploads that happened because single-asset sends and media
|
||||
groups were keyed in different caches.
|
||||
|
||||
### 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))
|
||||
- **Single-asset sends now hit the asset cache** — `TelegramClient._get_cache_and_key` treats `cache_key` values that look like asset UUIDs as asset-cache entries. Single-asset sends were storing `file_id`s in `url_cache` while the media-group path stored them in `asset_cache`, so repeat sends of the same asset never hit the cache and re-uploaded. ([6de9a12](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6de9a12))
|
||||
- **Notifications and commands now share the Telegram client factory** — new `services/telegram_send.py` is the single construction path for `TelegramClient`: always wires the shared aiohttp session and both `file_id` caches. `send_reply` and `send_media_group` in `commands/handler.py` now delegate to the factory instead of constructing their own uncached clients, so commands reuse `file_id`s populated by notification dispatches (and vice versa) instead of re-uploading the same bytes. ([6de9a12](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6de9a12))
|
||||
- **Single rule for `/video/playback` vs thumbnail URLs** — extracted `build_asset_media_urls` so the notification dispatcher's `asset_to_media` and the bot command handlers' `common._format_assets` agree on when to use the playback URL and when to use the thumbnail. Removes a subtle drift that could show stills in one path and video in the other for the same asset. ([6de9a12](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6de9a12))
|
||||
|
||||
---
|
||||
|
||||
<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)_
|
||||
- [6de9a12](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6de9a12) — fix(telegram): unify send routine across notifications and commands *(alexei.dolgolyov)*
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.7",
|
||||
"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: 2–3 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>
|
||||
@@ -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 (2–3 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",
|
||||
|
||||
@@ -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": "Провайдер сохранён",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.2.2"
|
||||
version = "0.2.7"
|
||||
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 }}
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-server"
|
||||
version = "0.2.2"
|
||||
version = "0.2.7"
|
||||
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,25 @@ 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.
|
||||
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,
|
||||
cache_key=asset_id,
|
||||
)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user