Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions gateway/relay/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,25 +1138,39 @@ async def _localize_inbound_media(self, event) -> None:
urls = list(getattr(event, "media_urls", None) or [])
if not urls:
return
# media_types is INDEXED IN PARALLEL with media_urls by every
# downstream classifier (_event_media_type_at). Any URL we drop or
# rewrite here must carry its MIME with it, or the surviving
# attachments inherit a neighbour's type and get mis-routed (an
# image classified by a PDF's mime is not treated as an image).
# Carry (url, mime) as PAIRS through the whole loop.
types = list(getattr(event, "media_types", None) or [])
pairs = [
(u, types[i] if i < len(types) else "") for i, u in enumerate(urls)
]
client = self._get_media_client()
localized: list[str] = []
for url in urls:
localized: list[tuple[str, str]] = []
for url, mime in pairs:
if not isinstance(url, str) or not url:
continue
if client is None:
# No authenticated client: keep public URLs, drop re-hosts.
if "/relay/media/" not in url:
localized.append(url)
localized.append((url, mime))
continue
path = await client.download(url)
if path:
localized.append(path)
localized.append((path, mime))
elif "/relay/media/" not in url:
# A public URL that failed to download still has value as
# a URL (native adapters pass URLs to vision in some
# lanes); a dead re-host reference does not.
localized.append(url)
event.media_urls = localized
localized.append((url, mime))
event.media_urls = [u for u, _ in localized]
# Keep the parallel-array invariant: one mime slot per surviving
# url, always. A short/stale media_types would shift entries onto
# the wrong url the moment anything indexes or merges them.
event.media_types = [m for _, m in localized]
except Exception: # noqa: BLE001 - media localization must never break inbound
logger.debug("relay inbound media localization failed", exc_info=True)

Expand Down
11 changes: 10 additions & 1 deletion gateway/relay/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ def media_base_url(relay_dial_url: str) -> str:
return raw


# Discord's CDN (and other public hosts) reject urllib's default
# ``Python-urllib/x.y`` User-Agent with HTTP 403 — which silently killed EVERY
# Discord CDN pass-through download (voice notes, images, documents): the
# localizer kept the raw URL and downstream consumers then tried to open a URL
# as a file path. Always send a descriptive UA.
_MEDIA_USER_AGENT = "HermesAgent-Relay/1.0 (+https://github.com/NousResearch/hermes-agent)"


class RelayMediaClient:
"""Authenticated client for the connector's ``/relay/media`` routes."""

Expand Down Expand Up @@ -128,6 +136,7 @@ async def upload(
or "application/octet-stream"
)
headers = {
"User-Agent": _MEDIA_USER_AGENT,
"Authorization": f"Bearer {self._bearer()}",
"Content-Type": content_type,
"X-Media-Filename": (filename or path.name)[:255],
Expand Down Expand Up @@ -164,7 +173,7 @@ async def download(self, url: str, *, suggested_name: Optional[str] = None) -> O
needs_auth = self.is_relay_media_url(url)
if needs_auth and not self.enabled:
return None
headers = {}
headers = {"User-Agent": _MEDIA_USER_AGENT}
if needs_auth:
headers["Authorization"] = f"Bearer {self._bearer()}"

Expand Down
58 changes: 58 additions & 0 deletions gateway/relay/ws_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,47 @@ def _normalize_slack_parent_command(
return normalized, normalized_type


def _media_types_from_wire(raw: Dict[str, Any]) -> list[str]:
"""Per-attachment MIME types, aligned to ``media_urls`` BY URL.

INVARIANT: the returned list is ALWAYS the same length as ``media_urls``
(padded with ``""``), or empty when there are no urls. Consumers index the
two lists by the same ``i`` (``_event_media_type_at``) AND concatenate them
pairwise (``merge_pending_message_event`` extends both), so a short
``media_types`` is not a harmless absence — on a merge it shifts every
subsequent entry onto the wrong url.

Resolution is BY URL LOOKUP, never by position: ``media_urls`` (legacy,
flat) and ``media`` (rich, Phase 2) are independent wire fields that may
disagree in order, and a length check alone accepts a reordered pair —
attaching one attachment's MIME to another's url, which silently
mis-routes it. A url with no matching ``media[]`` entry keeps its slot as
``""`` and falls back to message-level classification.
"""
urls = raw.get("media_urls")
if not isinstance(urls, list) or not urls:
return []
media = raw.get("media")
mime_by_url: dict[str, str] = {}
if isinstance(media, list):
for m in media:
if isinstance(m, dict):
url = m.get("url")
if isinstance(url, str) and url:
mime_by_url[url] = m.get("mime") or ""
# One slot per url, ALWAYS — even with no media[] at all (all ""), so the
# parallel-array invariant holds for every consumer.
types = [mime_by_url.get(u, "") if isinstance(u, str) else "" for u in urls]
missing = sum(1 for t in types if not t)
if missing and mime_by_url:
logger.debug(
"relay inbound: %d/%d media_urls had no matching media[] mime",
missing,
len(types),
)
return types


def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
"""Rebuild a MessageEvent from the connector's normalized inbound payload.

Expand Down Expand Up @@ -306,6 +347,23 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
reply_to_author_name=(raw.get("reply_to") or {}).get("author"),
reply_to_is_own_message=bool((raw.get("reply_to") or {}).get("is_own", False)),
media_urls=raw.get("media_urls") or [],
# Per-attachment MIME types, parallel to media_urls, from the
# connector's rich media[] array. run.py's per-attachment classifiers
# (_event_media_type_at) consult media_types[i] FIRST and only fall
# back to the message-level type when it's empty — so this mapping is
# what lets a relayed image/document/audio attachment route exactly
# like its native-adapter equivalent (and what makes a kind:"voice"
# attachment STT-eligible). Entries without a mime keep positional
# alignment with an empty string.
#
# media_urls and media[] are independent wire fields. Today's
# connector builds both from the same list, but a malformed or
# future producer could disagree — and a LENGTH MISMATCH would
# silently misassociate a MIME with the wrong URL (worse than no
# MIME at all: it mis-routes an attachment). Fail safe: map only
# when the two agree, otherwise leave media_types empty and let the
# message-level type drive classification (pre-fix behaviour).
media_types=_media_types_from_wire(raw),
# Surrounding channel/group CONTEXT the connector attached for this
# addressed turn (design relay-channel-context): a read-only, oldest→
# newest list of nearby non-addressed messages (Model A pull / Model B
Expand Down
53 changes: 53 additions & 0 deletions tests/gateway/relay/test_relay_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,56 @@ async def test_client_upload_rejects_oversize_and_missing(tmp_path: Path):
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 ")
Loading
Loading