232 lines
8.5 KiB
Python
232 lines
8.5 KiB
Python
"""Relay Phase 2 media tests — send_media egress lanes + inbound media localization.
|
|
|
|
Covers:
|
|
- the five ``send_*`` overrides route through ONE ``send_media`` op with the
|
|
right ``media_kind`` and honor op-level capability gating (a connector not
|
|
advertising ``send_media`` falls back to the base-class behaviour);
|
|
- local-path sources upload through the RelayMediaClient first (the
|
|
connector cannot reach our filesystem) and public URLs pass through;
|
|
- a connector decline / failed upload degrades to the pre-media fallback;
|
|
- inbound ``media_urls`` are localized to temp paths (re-hosts downloaded
|
|
with the per-gateway bearer; dead re-host refs dropped; public URLs kept
|
|
when no client is available);
|
|
- the RelayMediaClient URL derivation + auth header shape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig
|
|
from gateway.relay.adapter import RelayAdapter
|
|
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
|
|
from gateway.relay.media import RelayMediaClient, media_base_url
|
|
|
|
from tests.gateway.relay.stub_connector import StubConnector
|
|
|
|
|
|
def make_desc(**kw) -> CapabilityDescriptor:
|
|
base = dict(
|
|
contract_version=CONTRACT_VERSION,
|
|
platform="telegram",
|
|
label="Telegram",
|
|
max_message_length=4096,
|
|
supports_draft_streaming=False,
|
|
supports_edit=True,
|
|
supports_threads=True,
|
|
markdown_dialect="markdown_v2",
|
|
len_unit="utf16",
|
|
supported_ops=(
|
|
"send",
|
|
"edit",
|
|
"typing",
|
|
"get_chat_info",
|
|
"send_media",
|
|
),
|
|
)
|
|
base.update(kw)
|
|
return CapabilityDescriptor(**base)
|
|
|
|
|
|
class FakeMediaClient:
|
|
"""In-memory stand-in for RelayMediaClient (no HTTP)."""
|
|
|
|
def __init__(self) -> None:
|
|
self.enabled = True
|
|
self.uploads: list[tuple[str, Optional[str]]] = []
|
|
self.downloads: list[str] = []
|
|
self.upload_result: Optional[str] = "https://conn.example/relay/media/aa11"
|
|
self.download_result: Optional[str] = "/tmp/relay_media_fake.png"
|
|
|
|
async def upload(self, file_path, *, mime=None, filename=None):
|
|
self.uploads.append((str(file_path), filename))
|
|
return self.upload_result
|
|
|
|
async def download(self, url, *, suggested_name=None):
|
|
self.downloads.append(url)
|
|
return self.download_result
|
|
|
|
def is_relay_media_url(self, url: str) -> bool:
|
|
return "/relay/media/" in (url or "")
|
|
|
|
|
|
def _adapter(**desc_kw) -> tuple[RelayAdapter, StubConnector, FakeMediaClient]:
|
|
stub = StubConnector(make_desc(**desc_kw))
|
|
adapter = RelayAdapter(PlatformConfig(), make_desc(**desc_kw), transport=stub)
|
|
fake = FakeMediaClient()
|
|
adapter._media_client = fake # bypass env-derived construction
|
|
return adapter, stub, fake
|
|
|
|
|
|
# ── egress: the five overrides ───────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_url_passes_through_without_upload():
|
|
adapter, stub, fake = _adapter()
|
|
result = await adapter.send_image(
|
|
"chat1", "https://fal.media/x.png", caption="a pic", reply_to="m9"
|
|
)
|
|
assert result.success is True
|
|
assert result.message_id == "md1"
|
|
assert fake.uploads == [] # public URL → no upload leg
|
|
action = stub.sent[-1]
|
|
assert action["op"] == "send_media"
|
|
assert action["media_kind"] == "image"
|
|
assert action["source_url"] == "https://fal.media/x.png"
|
|
assert action["content"] == "a pic"
|
|
assert action["reply_to"] == "m9"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_path_lanes_upload_first(tmp_path: Path):
|
|
adapter, stub, fake = _adapter()
|
|
f = tmp_path / "clip.ogg"
|
|
f.write_bytes(b"oggbytes")
|
|
result = await adapter.send_voice("chat1", str(f), caption="listen")
|
|
assert result.success is True
|
|
assert fake.uploads == [(str(f), None)]
|
|
action = stub.sent[-1]
|
|
assert action["op"] == "send_media"
|
|
assert action["media_kind"] == "voice"
|
|
# The wire carries the RE-HOST reference, never the local path.
|
|
assert action["source_url"] == fake.upload_result
|
|
assert str(f) not in str(action)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_op_gating_falls_back_when_not_advertised(tmp_path: Path):
|
|
# Connector advertises only the legacy ops — send_media must never hit the wire.
|
|
adapter, stub, fake = _adapter(
|
|
supported_ops=("send", "edit", "typing", "get_chat_info")
|
|
)
|
|
result = await adapter.send_image("chat1", "https://x.io/a.png", caption="hi")
|
|
# Base-class fallback: caption + URL as a text send.
|
|
assert result.success is True
|
|
ops = [a["op"] for a in stub.sent]
|
|
assert "send_media" not in ops
|
|
assert ops[-1] == "send"
|
|
assert "https://x.io/a.png" in stub.sent[-1]["content"]
|
|
|
|
|
|
# ── inbound localization ─────────────────────────────────────────────────
|
|
|
|
|
|
def _make_event(media_urls):
|
|
from gateway.platforms.base import MessageEvent, MessageType
|
|
from gateway.session import SessionSource
|
|
|
|
return MessageEvent(
|
|
text="look",
|
|
message_type=MessageType.TEXT,
|
|
source=SessionSource(
|
|
platform="telegram", chat_id="c1", chat_type="dm", user_id="u1"
|
|
),
|
|
media_urls=list(media_urls),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inbound_without_client_keeps_public_drops_rehost():
|
|
adapter, _stub, _fake = _adapter()
|
|
adapter._media_client = None
|
|
adapter._get_media_client = lambda: None # type: ignore[method-assign]
|
|
event = _make_event(
|
|
[
|
|
"https://conn.example/relay/media/deadbeef",
|
|
"https://cdn.discordapp.com/attachments/a/b.png",
|
|
]
|
|
)
|
|
await adapter._localize_inbound_media(event)
|
|
assert event.media_urls == ["https://cdn.discordapp.com/attachments/a/b.png"]
|
|
|
|
|
|
# ── RelayMediaClient unit surface ────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_upload_rejects_oversize_and_missing(tmp_path: Path):
|
|
c = RelayMediaClient("https://c.example", "gw1", "sec")
|
|
# Missing file → None (no network attempted).
|
|
assert await c.upload(str(tmp_path / "nope.bin")) is None
|
|
# Empty file → None.
|
|
empty = tmp_path / "empty.bin"
|
|
empty.write_bytes(b"")
|
|
assert await c.upload(str(empty)) is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_sends_a_user_agent_on_every_request():
|
|
"""Discord's CDN 403s the default ``Python-urllib/x.y`` User-Agent.
|
|
|
|
Live-verified on staging 2026-08-26: every Discord CDN pass-through
|
|
download failed with ``HTTP Error 403: Forbidden``, the localizer then kept
|
|
the raw URL, and the transcriber tried to open a URL as a file path
|
|
("Audio file not found") — so voice notes, images and documents were ALL
|
|
silently dead on the Discord relay lane. Reproduced from a clean shell:
|
|
urllib with no UA → 403; the same URL with any descriptive UA → 200.
|
|
|
|
Assert the header on BOTH url classes (public pass-through and
|
|
bearer-authenticated re-host), because they take different header paths.
|
|
"""
|
|
seen: list[dict] = []
|
|
|
|
class _Resp:
|
|
headers = {"Content-Type": "audio/ogg", "Content-Length": "4"}
|
|
|
|
def read(self, *_a):
|
|
return b"OggS"
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_a):
|
|
return False
|
|
|
|
def _fake_urlopen(req, timeout=None): # noqa: ARG001
|
|
seen.append(dict(req.headers))
|
|
return _Resp()
|
|
|
|
import urllib.request as _ur
|
|
|
|
orig = _ur.urlopen
|
|
_ur.urlopen = _fake_urlopen # type: ignore[assignment]
|
|
try:
|
|
c = RelayMediaClient("https://conn.example", "gw1", "sec")
|
|
assert await c.download("https://cdn.discordapp.com/attachments/1/2/v.ogg")
|
|
assert await c.download("https://conn.example/relay/media/deadbeef")
|
|
finally:
|
|
_ur.urlopen = orig # type: ignore[assignment]
|
|
|
|
assert len(seen) == 2
|
|
for headers in seen:
|
|
# urllib title-cases header keys on Request.
|
|
ua = headers.get("User-agent") or headers.get("User-Agent")
|
|
assert ua, f"no User-Agent sent; urllib would default to Python-urllib (403s on Discord CDN): {headers}"
|
|
assert "python-urllib" not in ua.lower()
|
|
# The re-host request must still carry its bearer (no regression).
|
|
rehost_headers = seen[1]
|
|
assert (rehost_headers.get("Authorization") or "").startswith("Bearer ")
|