Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 325eabd751 | |||
| fab6169cf9 | |||
| 85311684d9 |
+15
-7
@@ -1,19 +1,27 @@
|
||||
# v0.2.5 (2026-04-22)
|
||||
# v0.2.6 (2026-04-22)
|
||||
|
||||
Hotfix release on top of v0.2.4 — the settings page couldn't save numeric
|
||||
fields after the cache-TTL / max-entries rework. See v0.2.4 notes for the
|
||||
main changes (thumbhash-validated cache, settings UX overhaul, mobile-nav
|
||||
parity).
|
||||
Bug-fix release. Notably: saving settings was silently overwriting the
|
||||
Telegram webhook secret with its own display mask, invalidating HMAC on
|
||||
every webhook-mode bot after any settings save. Also fixes template-editor
|
||||
variable discovery for provider-specific command slots (`/search`, `/status`,
|
||||
`/repos`, `/issues`, `/boards`), asset enrichment (city / country / favorite)
|
||||
for Immich `/search` / `/find` / `/person` / `/place`, and video rendering
|
||||
in command media groups.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Accept numeric values in settings update payload** — Svelte's `bind:value` on `<input type="number">` coerces to a JS number, and Pydantic v2 wouldn't auto-coerce `int → str`, producing a 422 on every save that touched a numeric setting (TTL, max entries) after v0.2.4. Widened numeric fields to `int | str | None` in `SettingsUpdate` and normalized to `str` before persisting. ([d7d0a5d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d7d0a5d))
|
||||
- **Don't clobber the Telegram webhook secret with its mask on save** — `GET /settings` returns the secret masked as `***<last4>`; the frontend bound that masked value into state and shipped it back on any Save, so the PUT handler persisted the mask as the new secret. The next GET re-masked the mask to itself, so the UI showed no corruption while HMAC verification silently broke for every webhook-mode bot. Incoming values that begin with `***` are now treated as *unchanged*; empty strings still clear the secret explicitly. **Operators running webhook-mode bots should save the page once with a known-good secret after upgrading.** ([8531168](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8531168))
|
||||
- **Surface Variables button / autocomplete for provider-specific command slots** — the command-template-configs UI only resolved slot variables against the shared catalog, so Immich's `/search` and `/status`, Gitea's `/repos` / `/issues`, and Planka's `/boards` offered no autocomplete. It now resolves against the active provider (`varsRef[provider_type][slot]`) first, falling back to shared entries. ([fab6169](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fab6169))
|
||||
- **Enrich raw Immich search results through `build_asset_dict`** — `/search`, `/find`, `/person`, `/place` previously handed raw API rows to templates, so `city` / `country` (from `exifInfo`) and `is_favorite` (mapped from `isFavorite`) were missing and templates couldn't render location or favorite indicators. Now normalised the same way as notification events. ([fab6169](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fab6169))
|
||||
- **Videos render correctly in command media groups** — `/latest`, `/random`, `/favorites` were sending videos as still thumbnails because the media-group path duplicated asset-typing logic. Extracted `build_telegram_asset_entry` into a shared helper so the notification dispatcher and command groups agree on video typing and `/video/playback` URLs. ([fab6169](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fab6169))
|
||||
- **Command media groups reuse the Telegram `file_id` cache** — `send_media_group` was re-uploading assets on every repeat command instead of honoring the cache the notification dispatcher already populates. Now shares the cache, avoiding re-upload churn. ([fab6169](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fab6169))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
- [d7d0a5d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d7d0a5d) — fix(settings): accept numeric values in update payload *(alexei.dolgolyov)*
|
||||
- [fab6169](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fab6169) — fix(commands): enrich search assets, surface variables for all command slots *(alexei.dolgolyov)*
|
||||
- [8531168](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8531168) — fix(settings): don't clobber webhook secret with its mask on save *(alexei.dolgolyov)*
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
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)
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-server"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -100,6 +100,13 @@ async def update_settings(
|
||||
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_str
|
||||
|
||||
@@ -393,30 +393,38 @@ 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 TelegramClient.send_notification.
|
||||
|
||||
``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 directly (via
|
||||
``build_telegram_asset_entry``) so videos keep their ``"video"`` type
|
||||
and point at a real video URL instead of a still thumbnail.
|
||||
|
||||
Reuses the same Telegram file_id caches as the notification dispatcher
|
||||
so repeated ``/latest`` / ``/random`` commands don't re-upload bytes
|
||||
for assets Telegram has already seen. If the cache hasn't been
|
||||
initialized (no data dir configured) we fall through to a plain
|
||||
upload — identical behavior to the notification path.
|
||||
"""
|
||||
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", "")},
|
||||
})
|
||||
|
||||
# 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)
|
||||
|
||||
from ..services.watcher import _get_telegram_caches
|
||||
url_cache, asset_cache = await _get_telegram_caches()
|
||||
|
||||
client = TelegramClient(
|
||||
session, bot_token,
|
||||
url_cache=url_cache,
|
||||
asset_cache=asset_cache,
|
||||
)
|
||||
result = await client.send_notification(
|
||||
chat_id, assets=assets, caption=caption,
|
||||
chat_id, assets=media_items,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
chat_action=None,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from notify_bridge_core.notifications.telegram.media import build_telegram_asset_entry
|
||||
from notify_bridge_core.providers.immich.asset_utils import get_public_url
|
||||
|
||||
from ..handler import _render_cmd_template
|
||||
@@ -74,13 +75,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 +127,27 @@ def _format_assets(
|
||||
})
|
||||
|
||||
if response_mode == "media":
|
||||
# Reuse the same entry-building helper as the notification dispatcher
|
||||
# so videos keep their "video" type and point at /video/playback —
|
||||
# typing them as "photo" made Telegram render the still poster
|
||||
# thumbnail in media groups 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,
|
||||
})
|
||||
is_video = (asset.get("type") or "").upper() == "VIDEO"
|
||||
if is_video:
|
||||
url = f"{client.url}/api/assets/{asset_id}/video/playback"
|
||||
else:
|
||||
url = f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview"
|
||||
entry = build_telegram_asset_entry(
|
||||
url=url,
|
||||
media_type="video" if is_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}
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user