feat: observability, per-receiver Telegram options, oversized-video fallback

Operability:
- Correlation IDs end-to-end: shared dispatch_id between log lines and
  EventLog rows (event/watcher/scheduled/deferred/action/HA/command paths)
  and a new X-Request-Id middleware that normalizes inbound ids and binds
  request_id into log context.
- dispatch_summary block merged into EventLog.details: per-target
  success/failure counts plus Telegram media delivered/skipped/failed and
  truncated error lists, so partial outcomes surface in the UI.
- Diagnostic mode: admin can flip one module to DEBUG for a bounded
  window with auto-revert (in-memory only; setup_logging() resets on
  boot, lifespan reverts on shutdown). New /diagnostic-mode endpoints
  plus DiagnosticsCassette UI on the settings page.

Telegram:
- Per-receiver options: disable_notification (silent send) and
  message_thread_id (forum-topic routing), wired through the dispatcher
  via a ContextVar so all four send sites (sendMessage / sendPhoto-Video-
  Document / sendMediaGroup / cache-hit POST) pick them up.
- send_large_videos_as_documents target setting: bypass the 50 MB
  sendVideo cap by falling back to sendDocument for oversized videos.
- sendMediaGroup byte-budget enforcement (TELEGRAM_MAX_GROUP_TOTAL_BYTES,
  45 MB) with per-item fallback on chunk failure so a stale file_id no
  longer silently drops a cached asset.

Tests:
- New: diagnostic_mode, dispatch_summary, request_correlation,
  telegram_media_group_partial, telegram_per_send_options.

Docs:
- .claude/reviews/: six-axis production-readiness review of v0.8.1.
- .claude/docs/functional-review-2026-05-28.md: focused review of
  Telegram/Immich/logging subsystems.
This commit is contained in:
2026-05-28 15:19:31 +03:00
parent 85a8f1e71c
commit 6a8f374678
39 changed files with 7239 additions and 142 deletions
@@ -0,0 +1,249 @@
"""Per-send Telegram options (`disable_notification`, `message_thread_id`).
Verifies the ContextVar-based plumbing inside ``TelegramClient`` so the
two new flags actually land in the request payloads at all four send
paths (sendMessage, single-asset send, media-group, cache-hit POST) and
that concurrent ``asyncio.gather`` fan-outs in the dispatcher don't leak
options between tasks.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
import pytest
from aiohttp import FormData
def test_telegram_receiver_factory_reads_new_fields() -> None:
"""The receiver factory turns config-dict keys into typed fields."""
from notify_bridge_core.notifications.receiver import (
TelegramReceiver, build_receiver,
)
recv = build_receiver(
"telegram",
{
"chat_id": "12345",
"disable_notification": True,
"message_thread_id": "7", # string form, common from JSON UI
},
)
assert isinstance(recv, TelegramReceiver)
assert recv.chat_id == "12345"
assert recv.disable_notification is True
assert recv.message_thread_id == 7
def test_telegram_receiver_factory_defaults_when_missing() -> None:
"""Missing keys default to off / general topic."""
from notify_bridge_core.notifications.receiver import (
TelegramReceiver, build_receiver,
)
recv = build_receiver("telegram", {"chat_id": "12345"})
assert isinstance(recv, TelegramReceiver)
assert recv.disable_notification is False
assert recv.message_thread_id is None
@pytest.mark.parametrize(
"raw_thread, expected",
[
(None, None),
("", None),
("not-a-number", None),
("42", 42),
(42, 42),
# ``0`` is Telegram's "general topic" sentinel — collapse to None
# so the Bot API just omits the field, matching the frontend's
# ``<= 0 → unset`` behaviour.
("0", None),
(0, None),
(-5, None),
# bool would otherwise pass through as int(True)==1 / int(False)==0
# and silently route into topic #1; reject explicitly.
(True, None),
(False, None),
],
)
def test_telegram_receiver_thread_id_coercion(raw_thread: Any, expected: Any) -> None:
from notify_bridge_core.notifications.receiver import build_receiver
recv = build_receiver(
"telegram",
{"chat_id": "1", "message_thread_id": raw_thread},
)
assert recv.message_thread_id == expected # type: ignore[attr-defined]
def test_apply_send_opts_to_payload_merges_when_bound() -> None:
"""Inside ``_bind_send_options``, payload helper writes the two keys."""
from notify_bridge_core.notifications.telegram.client import (
_SendOptions,
_apply_send_opts_to_payload,
_bind_send_options,
)
payload: dict[str, Any] = {"chat_id": "1"}
with _bind_send_options(_SendOptions(disable_notification=True, message_thread_id=7)):
_apply_send_opts_to_payload(payload)
assert payload["disable_notification"] is True
assert payload["message_thread_id"] == 7
def test_apply_send_opts_to_payload_omits_when_default() -> None:
"""No bind = no flags written (Bot API treats omission as default)."""
from notify_bridge_core.notifications.telegram.client import (
_apply_send_opts_to_payload,
)
payload: dict[str, Any] = {"chat_id": "1"}
_apply_send_opts_to_payload(payload)
assert "disable_notification" not in payload
assert "message_thread_id" not in payload
def test_apply_send_opts_to_form_merges_when_bound() -> None:
"""Multipart payload helper writes the two fields when bound."""
from notify_bridge_core.notifications.telegram.client import (
_SendOptions,
_apply_send_opts_to_form,
_bind_send_options,
)
form = FormData()
with _bind_send_options(_SendOptions(disable_notification=True, message_thread_id=42)):
_apply_send_opts_to_form(form)
# aiohttp.FormData stores fields as ``(MultiDict{name, ...}, headers, value)``.
name_to_value = {}
for type_opts, _headers, value in form._fields: # type: ignore[attr-defined]
name_to_value[type_opts.get("name")] = value
assert name_to_value.get("disable_notification") == "true"
assert name_to_value.get("message_thread_id") == "42"
def test_bind_send_options_resets_on_exit() -> None:
"""Token-reset semantics: the var is restored even after a raise."""
from notify_bridge_core.notifications.telegram.client import (
_SendOptions,
_bind_send_options,
_send_options_var,
)
default = _send_options_var.get()
try:
with _bind_send_options(_SendOptions(disable_notification=True)):
raise RuntimeError("boom")
except RuntimeError:
pass
assert _send_options_var.get() == default
@pytest.mark.asyncio
async def test_concurrent_binds_do_not_leak_between_tasks() -> None:
"""Two ``asyncio.gather`` tasks see only their own bound options.
This is the load-bearing invariant for the dispatcher's per-receiver
fan-out: one chat with ``disable_notification=True`` must not silence
a peer chat in the same dispatch.
"""
from notify_bridge_core.notifications.telegram.client import (
_SendOptions,
_apply_send_opts_to_payload,
_bind_send_options,
)
results: list[dict[str, Any]] = []
async def run_with(opts: _SendOptions, label: str) -> None:
payload: dict[str, Any] = {"label": label}
with _bind_send_options(opts):
# Yield to the loop to interleave with the sibling task.
await asyncio.sleep(0)
_apply_send_opts_to_payload(payload)
results.append(payload)
await asyncio.gather(
run_with(_SendOptions(disable_notification=True, message_thread_id=1), "silent"),
run_with(_SendOptions(disable_notification=False, message_thread_id=2), "loud"),
)
by_label = {r["label"]: r for r in results}
assert by_label["silent"].get("disable_notification") is True
assert by_label["silent"].get("message_thread_id") == 1
assert "disable_notification" not in by_label["loud"] # False → omitted
assert by_label["loud"].get("message_thread_id") == 2
@pytest.mark.asyncio
async def test_send_message_passes_options_into_payload(monkeypatch) -> None:
"""``send_message(disable_notification=True, message_thread_id=N)``
surfaces both keys in the JSON request body."""
from notify_bridge_core.notifications.telegram.client import TelegramClient
captured: dict[str, Any] = {}
class _FakeResp:
status = 200
async def json(self) -> dict[str, Any]:
return {"ok": True, "result": {"message_id": 99}}
async def __aenter__(self) -> "_FakeResp":
return self
async def __aexit__(self, *args: Any) -> None:
return None
class _FakeSession:
def post(self, url: str, *, json: dict[str, Any] | None = None, **_kw: Any) -> _FakeResp:
captured["url"] = url
captured["json"] = json
return _FakeResp()
client = TelegramClient(_FakeSession(), "TEST:token") # type: ignore[arg-type]
result = await client.send_message(
chat_id="123",
text="hello",
disable_notification=True,
message_thread_id=5,
)
assert result["success"] is True
payload = captured["json"]
assert payload["disable_notification"] is True
assert payload["message_thread_id"] == 5
@pytest.mark.asyncio
async def test_send_message_without_options_omits_keys(monkeypatch) -> None:
"""Default kwargs leave the payload Bot-API-clean."""
from notify_bridge_core.notifications.telegram.client import TelegramClient
captured: dict[str, Any] = {}
class _FakeResp:
status = 200
async def json(self) -> dict[str, Any]:
return {"ok": True, "result": {"message_id": 1}}
async def __aenter__(self) -> "_FakeResp":
return self
async def __aexit__(self, *args: Any) -> None:
return None
class _FakeSession:
def post(self, url: str, *, json: dict[str, Any] | None = None, **_kw: Any) -> _FakeResp:
captured["json"] = json
return _FakeResp()
client = TelegramClient(_FakeSession(), "TEST:token") # type: ignore[arg-type]
await client.send_message(chat_id="123", text="hello")
payload = captured["json"]
assert "disable_notification" not in payload
assert "message_thread_id" not in payload