mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
* fix(telegram): deliver rejected photos as documents * fix(telegram): log rejected-photo document fallback
261 lines
11 KiB
Python
261 lines
11 KiB
Python
"""Telegram photo rejection recovery through real SDK request serialization.
|
|
|
|
The offline transport models documented photo geometry limits. It is not a
|
|
live Telegram reproduction, and these tests never contact the platform.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import struct
|
|
import zlib
|
|
from dataclasses import replace
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from telegram import Bot
|
|
from telegram.error import NetworkError, TimedOut
|
|
from telegram.request import BaseRequest
|
|
|
|
from app.channels.message_bus import MessageBus, OutboundMessage, ResolvedAttachment
|
|
from app.channels.telegram import TelegramChannel
|
|
|
|
|
|
def _png(width: int, height: int) -> bytes:
|
|
def chunk(kind: bytes, data: bytes) -> bytes:
|
|
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
|
|
|
|
rows = (b"\0" + bytes((width + 7) // 8)) * height
|
|
header = struct.pack(">IIBBBBB", width, height, 1, 0, 0, 0, 0)
|
|
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"")
|
|
|
|
|
|
class _PhotoRequest(BaseRequest):
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.errors = {}
|
|
self.blocked_endpoint = None
|
|
self.started = asyncio.Event()
|
|
self.release = asyncio.Event()
|
|
|
|
@property
|
|
def read_timeout(self):
|
|
return 5
|
|
|
|
async def initialize(self):
|
|
pass
|
|
|
|
async def shutdown(self):
|
|
pass
|
|
|
|
async def do_request(self, url, method, request_data=None, **kwargs):
|
|
endpoint = url.rsplit("/", 1)[-1]
|
|
params = request_data.parameters if request_data else {}
|
|
if endpoint == "getMe":
|
|
result = {"id": 1, "is_bot": True, "first_name": "Offline", "username": "offline_bot"}
|
|
else:
|
|
uploads = request_data.multipart_data
|
|
self.calls.append((endpoint, params, uploads))
|
|
if endpoint == self.blocked_endpoint:
|
|
self.started.set()
|
|
await self.release.wait()
|
|
error = self.errors.get(endpoint)
|
|
if isinstance(error, Exception):
|
|
raise error
|
|
if error:
|
|
status, description = error
|
|
return status, json.dumps({"ok": False, "error_code": status, "description": description, "parameters": {"retry_after": 30} if status == 429 else {}}).encode()
|
|
if endpoint == "sendPhoto":
|
|
data = next(iter(uploads.values()))[1]
|
|
width, height = struct.unpack(">II", data[16:24])
|
|
if width + height > 10000 or max(width, height) > 20 * min(width, height):
|
|
return 400, b'{"ok": false, "error_code": 400, "description": "Bad Request: PHOTO_INVALID_DIMENSIONS"}'
|
|
result = {"message_id": 100 + len(self.calls), "date": 0, "chat": {"id": params["chat_id"], "type": "private" if params["chat_id"] > 0 else "supergroup"}}
|
|
return 200, json.dumps({"ok": True, "result": result}).encode()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def channel():
|
|
request = _PhotoRequest()
|
|
async with Bot("123:offline-test-only", request=request, get_updates_request=request) as bot:
|
|
instance = TelegramChannel(MessageBus(), {"bot_token": "123:offline-test-only"})
|
|
instance._application = SimpleNamespace(bot=bot)
|
|
instance._remember_reply_thread = AsyncMock(return_value=True)
|
|
yield instance, request
|
|
|
|
|
|
def _attachment(tmp_path, width=64, height=64):
|
|
path = tmp_path / "original chart.png"
|
|
data = _png(width, height)
|
|
path.write_bytes(data)
|
|
return ResolvedAttachment("/mnt/user-data/outputs/" + path.name, path, path.name, "image/png", len(data), True)
|
|
|
|
|
|
def _message(chat_id="12345"):
|
|
return OutboundMessage(channel_name="telegram", chat_id=chat_id, thread_id="thread", text="Complete answer")
|
|
|
|
|
|
def _endpoints(request):
|
|
return [call[0] for call in request.calls]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("chat_id", ["12345", "-12345"])
|
|
@pytest.mark.parametrize("dimensions", [(100, 3000), (6000, 5000)])
|
|
async def test_rejected_photo_delivers_original_document_and_preserves_reply(channel, tmp_path, chat_id, dimensions):
|
|
instance, request = channel
|
|
attachment = _attachment(tmp_path, *dimensions)
|
|
original = attachment.actual_path.read_bytes()
|
|
instance._last_bot_message[chat_id] = 42
|
|
message = _message(chat_id)
|
|
|
|
assert await instance.send_file(message, attachment)
|
|
|
|
assert _endpoints(request) == ["sendPhoto", "sendDocument"]
|
|
photo, document = request.calls
|
|
assert photo[2]["photo"] == document[2]["document"]
|
|
assert document[2]["document"][:2] == (attachment.filename, original)
|
|
assert photo[1]["chat_id"] == document[1]["chat_id"] == int(chat_id)
|
|
assert photo[1]["reply_parameters"] == document[1]["reply_parameters"]
|
|
reply = document[1]["reply_parameters"]
|
|
assert reply["message_id"] == 42
|
|
assert bool(reply.get("allow_sending_without_reply")) is (int(chat_id) > 0)
|
|
assert instance._last_bot_message[chat_id] == 102
|
|
instance._remember_reply_thread.assert_awaited_once_with(message, 102)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("description", ["Bad Request: IMAGE_PROCESS_FAILED", "Bad Request: image_process_failed", "Bad Request: photo_invalid_dimensions"])
|
|
async def test_explicit_media_rejections_fall_back_once(channel, tmp_path, description):
|
|
instance, request = channel
|
|
request.errors["sendPhoto"] = (400, description)
|
|
assert await instance.send_file(_message(), _attachment(tmp_path))
|
|
assert _endpoints(request) == ["sendPhoto", "sendDocument"]
|
|
assert "reply_parameters" not in request.calls[-1][1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("is_image,size,endpoint", [(True, 10 * 1024 * 1024, "sendPhoto"), (True, 10 * 1024 * 1024 + 1, "sendDocument"), (False, 72, "sendDocument"), (False, 50 * 1024 * 1024, "sendDocument")])
|
|
async def test_existing_photo_and_document_selection(channel, tmp_path, is_image, size, endpoint):
|
|
instance, request = channel
|
|
# Metadata sizes exercise the existing routing boundaries without large files.
|
|
attachment = replace(_attachment(tmp_path), is_image=is_image, size=size)
|
|
assert await instance.send_file(_message(), attachment)
|
|
assert _endpoints(request) == [endpoint]
|
|
instance._remember_reply_thread.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
(400, "Bad Request: message to be replied not found"),
|
|
(400, "Bad Request: unknown photo failure"),
|
|
(400, "Bad Request: PHOTO_INVALID_DIMENSIONS extra detail"),
|
|
(403, "Forbidden: bot was blocked by the user"),
|
|
(403, "PHOTO_INVALID_DIMENSIONS"),
|
|
(429, "Too Many Requests"),
|
|
NetworkError("PHOTO_INVALID_DIMENSIONS"),
|
|
TimedOut(),
|
|
],
|
|
)
|
|
async def test_unrelated_or_uncertain_photo_errors_do_not_resend(channel, tmp_path, error):
|
|
instance, request = channel
|
|
request.errors["sendPhoto"] = error
|
|
instance._last_bot_message["12345"] = 42
|
|
assert not await instance.send_file(_message(), _attachment(tmp_path))
|
|
assert _endpoints(request) == ["sendPhoto"]
|
|
assert instance._last_bot_message["12345"] == 42
|
|
instance._remember_reply_thread.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("error", [(400, "Bad Request: PHOTO_INVALID_DIMENSIONS"), (403, "Forbidden: bot was blocked by the user"), (429, "Too Many Requests"), NetworkError("offline connection failed")])
|
|
async def test_document_fallback_failure_is_not_retried_or_registered(channel, tmp_path, error):
|
|
instance, request = channel
|
|
request.errors["sendDocument"] = error
|
|
instance._last_bot_message["12345"] = 42
|
|
assert not await instance.send_file(_message(), _attachment(tmp_path, 100, 3000))
|
|
assert _endpoints(request) == ["sendPhoto", "sendDocument"]
|
|
assert instance._last_bot_message["12345"] == 42
|
|
instance._remember_reply_thread.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("endpoint", ["sendPhoto", "sendDocument"])
|
|
async def test_cancellation_during_send_propagates_without_registration(channel, tmp_path, endpoint):
|
|
instance, request = channel
|
|
request.blocked_endpoint = endpoint
|
|
attachment = _attachment(tmp_path, 100, 3000)
|
|
instance._last_bot_message["12345"] = 42
|
|
task = asyncio.create_task(instance.send_file(_message(), attachment))
|
|
try:
|
|
await asyncio.wait_for(request.started.wait(), timeout=5)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
finally:
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
assert _endpoints(request) == (["sendPhoto"] if endpoint == "sendPhoto" else ["sendPhoto", "sendDocument"])
|
|
assert instance._last_bot_message["12345"] == 42
|
|
instance._remember_reply_thread.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_file_and_oversized_attachment_do_not_send(channel, tmp_path):
|
|
instance, request = channel
|
|
attachment = _attachment(tmp_path)
|
|
assert not await instance.send_file(_message(), replace(attachment, size=50 * 1024 * 1024 + 1))
|
|
attachment.actual_path.unlink()
|
|
assert not await instance.send_file(_message(), attachment)
|
|
assert not request.calls
|
|
instance._remember_reply_thread.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mixed_attachment_batch_continues_without_resending_success(channel, tmp_path):
|
|
instance, request = channel
|
|
wide = _attachment(tmp_path, 100, 3000)
|
|
ordinary_path = tmp_path / "notes.txt"
|
|
ordinary_path.write_bytes(b"notes")
|
|
ordinary = ResolvedAttachment("/mnt/user-data/outputs/notes.txt", ordinary_path, "notes.txt", "text/plain", 5, False)
|
|
message = replace(_message(), attachments=[wide, ordinary])
|
|
assert await instance._send_attachments(message) == []
|
|
assert _endpoints(request) == ["sendPhoto", "sendDocument", "sendDocument"]
|
|
assert request.calls[-1][2]["document"][:2] == ("notes.txt", b"notes")
|
|
assert request.calls[-1][1]["reply_parameters"]["message_id"] == 102
|
|
assert instance._remember_reply_thread.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_binds_delivered_group_document_to_original_thread(channel, tmp_path):
|
|
instance, request = channel
|
|
instance._remember_reply_thread = TelegramChannel._remember_reply_thread.__get__(instance)
|
|
store = SimpleNamespace(claim_thread_id=AsyncMock())
|
|
instance.config["channel_store"] = store
|
|
instance._last_bot_message["-12345"] = 42
|
|
message = replace(_message("-12345"), thread_ts="17")
|
|
|
|
assert await instance.send_file(message, _attachment(tmp_path, 100, 3000))
|
|
|
|
assert _endpoints(request) == ["sendPhoto", "sendDocument"]
|
|
assert store.claim_thread_id.await_count == 2
|
|
claims = store.claim_thread_id.await_args_list
|
|
assert {entry.args for entry in claims} == {("telegram", "-12345", "thread")}
|
|
assert {entry.kwargs["topic_id"] for entry in claims} == {"17", "102"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unavailable_bot_and_invalid_chat_do_not_send(channel, tmp_path):
|
|
instance, request = channel
|
|
attachment = _attachment(tmp_path)
|
|
assert not await instance.send_file(_message("not-a-chat-id"), attachment)
|
|
instance._application = None
|
|
assert not await instance.send_file(_message(), attachment)
|
|
assert not request.calls
|
|
instance._remember_reply_thread.assert_not_awaited()
|