57a807373d
524ab5399 widened the media read timeout from send_video to "all upload send
paths" - send_voice/send_audio/send_photo/send_document/send_media_group/
send_animation. Both send_photo calls inside send_image() were missed, so
they still ran on the short timeout the rest of the Bot API is tuned for
while the sibling media paths already pass it.
The missed pair is the worst one to miss: send_image tries a URL send first,
then falls back to downloading the image and uploading the bytes - the path
documented as "supports up to 10MB", i.e. the slowest send in the file and
the one whose server-side processing wait most often outlasts the short
budget. When it times out the handler's last resort posts the bare URL as
text, so the picture silently never arrives as a picture.
Pass _MEDIA_SEND_READ_TIMEOUT on both, covered by two behavioral tests that
drive send_image for real - the URL send and the forced byte-upload fallback
- and assert the read_timeout that actually reaches the Bot API.
136 lines
4.4 KiB
Python
136 lines
4.4 KiB
Python
"""Every Telegram media upload passes the long media read timeout.
|
|
|
|
Telegram processes an uploaded photo/video/document server-side before it
|
|
answers, so the wait for the response is unrelated to how fast the bytes went
|
|
out and can outlast the short read timeout the rest of the Bot API is tuned
|
|
for. ``_MEDIA_SEND_READ_TIMEOUT`` is the longer budget; a media send that
|
|
misses it fails on a slow upload and falls back to posting the bare URL as
|
|
text, so the picture never arrives as a picture.
|
|
|
|
``send_image`` was the gap: both of its ``send_photo`` calls -- the URL send
|
|
and the byte-upload fallback that handles up to 10MB -- went out on the short
|
|
timeout. Both are driven for real below and asserted on the ``read_timeout``
|
|
that actually reaches the Bot API.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
def _ensure_telegram_mock():
|
|
"""Install mock telegram modules only when the real library is absent.
|
|
|
|
Probe with a real import rather than inspecting ``sys.modules``: when PTB
|
|
is installed but not yet imported, a ``sys.modules`` check alone would
|
|
install the mock first and poison every later test that needs the real
|
|
library (``tests/test_telegram_polling_progress_ptb.py`` exercises the
|
|
genuine ``BaseRequest``).
|
|
"""
|
|
try:
|
|
import telegram # noqa: F401
|
|
|
|
return
|
|
except ImportError:
|
|
pass
|
|
telegram_mod = MagicMock()
|
|
telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
|
|
telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
|
|
for name in ("GROUP", "SUPERGROUP", "CHANNEL", "PRIVATE"):
|
|
setattr(telegram_mod.constants.ChatType, name, name.lower())
|
|
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
|
|
sys.modules.setdefault(name, telegram_mod)
|
|
|
|
|
|
_ensure_telegram_mock()
|
|
|
|
from gateway.config import PlatformConfig # noqa: E402
|
|
from plugins.platforms.telegram import adapter as tg # noqa: E402
|
|
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter():
|
|
a = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token"))
|
|
a._bot = MagicMock()
|
|
a._metadata_thread_id = lambda metadata: None
|
|
a._thread_kwargs_for_send = lambda *args, **kwargs: {}
|
|
a._notification_kwargs = lambda metadata: {}
|
|
a._reply_to_message_id_for_send = lambda *args, **kwargs: None
|
|
|
|
async def _direct(fn, payload, *args, **kwargs):
|
|
return await fn(**payload)
|
|
|
|
a._send_with_dm_topic_reply_anchor_retry = _direct
|
|
return a
|
|
|
|
|
|
def _stub_download(monkeypatch, size: int):
|
|
"""Make the fallback's SSRF-safe client return ``size`` bytes."""
|
|
|
|
class _Resp:
|
|
content = b"x" * size
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
class _Client:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def get(self, url):
|
|
return _Resp()
|
|
|
|
import tools.url_safety as url_safety
|
|
|
|
monkeypatch.setattr(url_safety, "create_ssrf_safe_async_client", lambda **kw: _Client())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_url_path_uses_media_read_timeout(adapter):
|
|
calls = []
|
|
|
|
async def _photo(**kwargs):
|
|
calls.append(kwargs)
|
|
msg = MagicMock()
|
|
msg.message_id = 1
|
|
return msg
|
|
|
|
adapter._bot.send_photo = AsyncMock(side_effect=_photo)
|
|
|
|
result = await adapter.send_image("123", "https://example.com/pic.png", caption="hi")
|
|
|
|
assert result.success
|
|
assert calls[0]["read_timeout"] == tg._MEDIA_SEND_READ_TIMEOUT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_upload_fallback_uses_media_read_timeout(adapter, monkeypatch):
|
|
"""The >5MB fallback is the slowest send in the file — it needs it most."""
|
|
_stub_download(monkeypatch, 8 * 1024 * 1024)
|
|
calls = []
|
|
|
|
async def _photo(**kwargs):
|
|
calls.append(kwargs)
|
|
if len(calls) == 1:
|
|
raise RuntimeError("Photo too big for a URL send")
|
|
msg = MagicMock()
|
|
msg.message_id = 2
|
|
return msg
|
|
|
|
adapter._bot.send_photo = AsyncMock(side_effect=_photo)
|
|
|
|
result = await adapter.send_image("123", "https://example.com/big.png", caption="hi")
|
|
|
|
assert result.success
|
|
assert len(calls) == 2, "expected the byte-upload fallback to run"
|
|
upload = calls[1]
|
|
assert isinstance(upload["photo"], (bytes, bytearray))
|
|
assert upload["read_timeout"] == tg._MEDIA_SEND_READ_TIMEOUT
|