6a8f374678
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.
250 lines
8.4 KiB
Python
250 lines
8.4 KiB
Python
"""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
|