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:
@@ -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
|
||||
Reference in New Issue
Block a user