Compare commits

...

2 Commits

Author SHA1 Message Date
alexei.dolgolyov e6481605ca chore: release v0.2.7
Release / release (push) Successful in 4m23s
2026-04-22 16:47:25 +03:00
alexei.dolgolyov 6de9a1289e fix(telegram): unify send routine across notifications and commands
- Route cache_key values that look like asset UUIDs through asset_cache
  in TelegramClient._get_cache_and_key. Single-asset sends previously
  stored file_ids in url_cache while the media-group path stored them
  in asset_cache, so repeat sends never hit.
- Extract build_asset_media_urls so the notification dispatcher
  (asset_to_media) and the bot command handlers (common._format_assets)
  share one rule for /video/playback vs thumbnail URLs.
- Add services/telegram_send.py as the single factory for constructing
  a TelegramClient. It always wires the shared aiohttp session and both
  file caches, so commands now reuse file_ids populated by notification
  dispatches (and vice versa) instead of re-uploading the same bytes.
- send_reply / send_media_group in commands/handler.py now delegate to
  the factory rather than constructing their own uncached clients.
2026-04-22 16:45:31 +03:00
9 changed files with 206 additions and 70 deletions
+10 -15
View File
@@ -1,27 +1,22 @@
# v0.2.6 (2026-04-22) # v0.2.7 (2026-04-22)
Bug-fix release. Notably: saving settings was silently overwriting the Follow-up to v0.2.6: unifies the Telegram send routine across notifications
Telegram webhook secret with its own display mask, invalidating HMAC on and bot commands so both sides share the same aiohttp session, the same
every webhook-mode bot after any settings save. Also fixes template-editor `file_id` caches, and the same rules for video / thumbnail URL construction.
variable discovery for provider-specific command slots (`/search`, `/status`, Eliminates repeat uploads that happened because single-asset sends and media
`/repos`, `/issues`, `/boards`), asset enrichment (city / country / favorite) groups were keyed in different caches.
for Immich `/search` / `/find` / `/person` / `/place`, and video rendering
in command media groups.
## Bug Fixes ## Bug Fixes
- **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)) - **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))
- **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)) - **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))
- **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)) - **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))
- **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> <details>
<summary>All Commits</summary> <summary>All Commits</summary>
- [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)* - [6de9a12](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6de9a12) — fix(telegram): unify send routine across notifications and commands *(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> </details>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"private": true, "private": true,
"version": "0.2.6", "version": "0.2.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-core" name = "notify-bridge-core"
version = "0.2.6" version = "0.2.7"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates" description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -89,6 +89,18 @@ class TelegramClient:
self, url: str | None, cache_key: str | None = None, self, url: str | None, cache_key: str | None = None,
) -> tuple[TelegramFileCache | None, str | None, str | None]: ) -> tuple[TelegramFileCache | None, str | None, str | None]:
if cache_key: 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 return self._url_cache, cache_key, None
if url: if url:
if is_asset_id(url): if is_asset_id(url):
@@ -193,6 +193,27 @@ def get_asset_video_url(
return None 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( def build_asset_detail(
asset: ImmichAssetInfo, asset: ImmichAssetInfo,
external_url: str, 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 # preview_url is what the notification dispatcher feeds to Telegram as the
# actual media bytes — for videos it must be the transcoded playback (mp4), # 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. # not the JPEG thumbnail, or Telegram receives a JPEG labeled as video/mp4.
if asset.type == ASSET_TYPE_VIDEO: preview_url, full_url = build_asset_media_urls(external_url, asset.id, asset.type)
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"
return MediaAsset( return MediaAsset(
id=asset.id, id=asset.id,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-server" name = "notify-bridge-server"
version = "0.2.6" version = "0.2.7"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database" description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -367,20 +367,23 @@ async def send_reply(
bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None, bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None, session: aiohttp.ClientSession | None = None,
) -> None: ) -> None:
"""Send a text reply via TelegramClient. """Send a text reply to a chat.
Command responses are listings (albums, people, events, ...) that embed Thin wrapper that goes through the single ``services.telegram_send``
multiple links; Telegram's default behavior of rendering a preview of entry point so commands and notifications share one routine — same
the first URL is almost never what the user wants and clashes with the HTTP session pool, same file_id caches.
"Disable link previews" toggle operators set on their Telegram target.
We always pass ``disable_web_page_preview=True`` here. 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.
""" """
if session is None: from ..services.telegram_send import send_telegram_message
from ..services.http_session import get_http_session
session = await get_http_session() result = await send_telegram_message(
client = TelegramClient(session, bot_token) bot_token, chat_id, text,
result = await client.send_message(
chat_id, text,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
disable_web_page_preview=True, disable_web_page_preview=True,
) )
@@ -393,38 +396,28 @@ async def send_media_group(
reply_to_message_id: int | None = None, reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None, session: aiohttp.ClientSession | None = 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 ``media_items`` must already be in TelegramClient asset format — each
entry contains ``type`` (``"photo"``/``"video"``/``"document"``), entry contains ``type`` (``"photo"``/``"video"``/``"document"``),
``url``, optional ``cache_key``, and optional ``headers``. Provider ``url``, optional ``cache_key``, and optional ``headers``. Provider
command handlers build this format directly (via command handlers build this format via
``build_telegram_asset_entry``) so videos keep their ``"video"`` type ``build_telegram_asset_entry`` — the same helper the notification
and point at a real video URL instead of a still thumbnail. dispatcher uses — 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 Uses ``services.telegram_send.send_telegram_media`` so the URL cache
so repeated ``/latest`` / ``/random`` commands don't re-upload bytes and asset cache are wired in exactly like the notification path.
for assets Telegram has already seen. If the cache hasn't been Repeated ``/latest`` / ``/random`` commands that match previously-sent
initialized (no data dir configured) we fall through to a plain assets hit the cache and skip the re-upload.
upload — identical behavior to the notification path.
""" """
if not media_items: if not media_items:
return return
if session is None: from ..services.telegram_send import send_telegram_media
from ..services.http_session import get_http_session
session = await get_http_session()
from ..services.watcher import _get_telegram_caches result = await send_telegram_media(
url_cache, asset_cache = await _get_telegram_caches() bot_token, chat_id, media_items,
client = TelegramClient(
session, bot_token,
url_cache=url_cache,
asset_cache=asset_cache,
)
result = await client.send_notification(
chat_id, assets=media_items,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
chat_action=None, chat_action=None,
) )
@@ -7,7 +7,10 @@ import logging
from typing import Any from typing import Any
from notify_bridge_core.notifications.telegram.media import build_telegram_asset_entry 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 notify_bridge_core.providers.immich.asset_utils import (
build_asset_media_urls,
get_public_url,
)
from ..handler import _render_cmd_template from ..handler import _render_cmd_template
@@ -127,21 +130,19 @@ def _format_assets(
}) })
if response_mode == "media": if response_mode == "media":
# Reuse the same entry-building helper as the notification dispatcher # Reuse the same URL rule (build_asset_media_urls) and entry builder
# so videos keep their "video" type and point at /video/playback — # (build_telegram_asset_entry) as the notification dispatcher so both
# typing them as "photo" made Telegram render the still poster # paths agree on video → /video/playback and photo → thumbnail. When
# thumbnail in media groups instead of the real clip. # 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]] = [] media_items: list[dict[str, Any]] = []
for asset in assets: for asset in assets:
asset_id = asset.get("id", "") asset_id = asset.get("id", "")
is_video = (asset.get("type") or "").upper() == "VIDEO" asset_type = (asset.get("type") or "").upper()
if is_video: preview_url, _ = build_asset_media_urls(client.url, asset_id, asset_type)
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( entry = build_telegram_asset_entry(
url=url, url=preview_url,
media_type="video" if is_video else "image", media_type="video" if asset_type == "VIDEO" else "image",
api_key=client.api_key, api_key=client.api_key,
internal_url=client.url, internal_url=client.url,
cache_key=asset_id, cache_key=asset_id,
@@ -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,
)