mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Telegram groups with Topics enabled always received notifications in the General topic, since only Bot Token and Chat ID were configurable. Splitting notifications per printer meant running a separate chat for each one. The Telegram provider now takes an optional Forum Topic ID - the last number in a topic's link, t.me/c/1234567890/25 - and routes its messages there. Left empty, nothing changes. The value is coerced to an int once in _send_telegram and attached to both the sendMessage JSON body and the sendPhoto form data. That ordering matters: Telegram rejects a string message_thread_id in the JSON body while accepting one in the multipart call, so passing the raw form value through would have worked for thumbnail notifications and 400'd for plain-text ones. A non-numeric value is rejected in the form and again server-side before any request goes out. No migration - provider config is a JSON blob. Adds Forum Topic ID plus help text to the Telegram section of the provider dialog, translated in all 13 locales. Backend tests cover omitted / blank / int-typed / non-numeric values and both send paths; frontend tests cover the field being optional, absent for other providers, round-tripping on save, and blocking save on a bad value.
108 lines
4.1 KiB
Python
108 lines
4.1 KiB
Python
"""Tests for optional Telegram forum-topic delivery via message_thread_id (#1518).
|
|
|
|
Telegram forum groups route messages to a topic by ``message_thread_id``. The
|
|
field is optional: when it is absent, Telegram posts to the group's General
|
|
topic, which is the behaviour every existing install already relies on.
|
|
|
|
The subtlety worth pinning is the type. ``sendMessage`` is posted as JSON, and
|
|
Telegram rejects a *string* thread id there, while the multipart ``sendPhoto``
|
|
call would accept one. A string passed straight through would therefore work
|
|
for notifications carrying a thumbnail and 400 for plain-text ones — so these
|
|
tests assert an ``int`` reaches both call sites.
|
|
"""
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from backend.app.services.notification_service import NotificationService
|
|
|
|
|
|
class _CaptureClient:
|
|
"""Stand-in for httpx.AsyncClient recording the JSON body and form data."""
|
|
|
|
def __init__(self):
|
|
self.is_closed = False
|
|
self.calls: list[dict] = []
|
|
|
|
async def post(self, url, data=None, files=None, json=None):
|
|
self.calls.append({"url": url, "data": data, "files": files, "json": json})
|
|
return httpx.Response(200, json={"ok": True, "result": {}})
|
|
|
|
|
|
@pytest.fixture
|
|
def service_with_capture():
|
|
service = NotificationService()
|
|
client = _CaptureClient()
|
|
service._http_client = client # bypass real HTTP
|
|
return service, client
|
|
|
|
|
|
BASE_CONFIG = {"bot_token": "123456:AAbbCC", "chat_id": "-1002520100736"}
|
|
PNG = b"\x89PNG\r\n\x1a\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_thread_id_omitted_when_unset(service_with_capture):
|
|
"""Default config must produce exactly the pre-#1518 payload."""
|
|
service, client = service_with_capture
|
|
ok, _ = await service._send_telegram(BASE_CONFIG, "*T*\nbody")
|
|
assert ok
|
|
assert "message_thread_id" not in client.calls[0]["json"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blank_thread_id_is_treated_as_unset(service_with_capture):
|
|
"""An emptied-out form field must not turn into a bogus topic."""
|
|
service, client = service_with_capture
|
|
ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": " "}, "*T*\nbody")
|
|
assert ok
|
|
assert "message_thread_id" not in client.calls[0]["json"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sendmessage_carries_thread_id_as_int(service_with_capture):
|
|
service, client = service_with_capture
|
|
ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "25"}, "*T*\nbody")
|
|
assert ok
|
|
body = client.calls[0]["json"]
|
|
assert body["message_thread_id"] == 25
|
|
assert isinstance(body["message_thread_id"], int), "Telegram 400s on a string thread id in JSON"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sendphoto_carries_thread_id(service_with_capture):
|
|
"""Thumbnail notifications take the multipart path and must route too."""
|
|
service, client = service_with_capture
|
|
ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "25"}, "*T*\nbody", image_data=PNG)
|
|
assert ok
|
|
call = client.calls[0]
|
|
assert call["url"].endswith("/sendPhoto")
|
|
assert call["data"]["message_thread_id"] == 25
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_thread_id_accepts_native_int(service_with_capture):
|
|
"""config is a JSON blob — the value may already deserialise as an int."""
|
|
service, client = service_with_capture
|
|
ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": 25}, "*T*\nbody")
|
|
assert ok
|
|
assert client.calls[0]["json"]["message_thread_id"] == 25
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_numeric_thread_id_fails_without_sending(service_with_capture):
|
|
"""Reject locally rather than let Telegram answer with an opaque 400."""
|
|
service, client = service_with_capture
|
|
ok, error = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "General"}, "*T*\nbody")
|
|
assert not ok
|
|
assert "not a number" in error
|
|
assert client.calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_message_does_not_leak_bot_token(service_with_capture):
|
|
service, _ = service_with_capture
|
|
ok, error = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "oops"}, "*T*\nbody")
|
|
assert not ok
|
|
assert "AAbbCC" not in error
|