Files
hermes-agent/gateway/relay/media.py
T

185 lines
7.3 KiB
Python

"""Relay media client — gateway↔connector media plane (Phase 2). EXPERIMENTAL.
The relay wire carries media BY REFERENCE: inbound ``media_urls`` name
connector re-hosts (``{connector}/relay/media/{id}``) and an outbound
``send_media`` op names a ``source_url`` the connector resolves back to bytes.
- ``download(url)`` → GET a re-hosted attachment to a local temp file (the
agent's vision/file tools consume LOCAL paths, like every native adapter).
- ``upload(path)`` → POST local bytes to ``/relay/media``; returns the
``/relay/media/{id}`` reference for a subsequent ``send_media`` op, so a
locally-generated artifact crosses without the gateway needing a public URL.
Both present the same per-gateway signed bearer the WS upgrade uses
(``make_upgrade_token``). Uploads are per-gateway-owned on the connector;
downloads accept this gateway's uploads and connector ingest re-hosts.
Transport is stdlib ``urllib`` in a thread executor (no HTTP client deps).
"""
from __future__ import annotations
import asyncio
import json
import logging
import mimetypes
import os
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
from typing import Optional
from gateway.relay.auth import make_upgrade_token
logger = logging.getLogger(__name__)
# Mirrors the connector's MEDIA_MAX_BYTES (mediaStore.ts): fail fast here
# instead of round-tripping to a connector 413.
MEDIA_MAX_BYTES = 25 * 1024 * 1024
_REQUEST_TIMEOUT_S = 30.0
# Discord's CDN (and other public hosts) 403 urllib's default UA, which
# silently killed every CDN pass-through download. Always send a descriptive UA.
_MEDIA_USER_AGENT = "HermesAgent-Relay/1.0 (+https://github.com/NousResearch/hermes-agent)"
def media_base_url(relay_dial_url: str) -> str:
"""Map the ``ws(s)://…/relay`` dial URL to the ``http(s)://…`` base (same derivation as ``_provision_url``)."""
raw = (relay_dial_url or "").strip().rstrip("/")
if raw.startswith("ws://"):
raw = "http://" + raw[len("ws://") :]
elif raw.startswith("wss://"):
raw = "https://" + raw[len("wss://") :]
if raw.endswith("/relay"):
raw = raw[: -len("/relay")]
return raw
class RelayMediaClient:
"""Authenticated client for the connector's ``/relay/media`` routes."""
def __init__(
self,
base_url: str,
gateway_id: Optional[str],
secret: Optional[str],
) -> None:
self._base_url = base_url.rstrip("/")
self._gateway_id = gateway_id or ""
self._secret = secret or ""
@property
def enabled(self) -> bool:
"""True when the client can authenticate (per-gateway creds present)."""
return bool(self._base_url and self._gateway_id and self._secret)
def _bearer(self) -> str:
return make_upgrade_token(self._gateway_id, self._secret)
def is_relay_media_url(self, url: str) -> bool:
"""Is ``url`` a connector re-host reference (needs our bearer to GET)?"""
return "/relay/media/" in (url or "")
async def upload(
self,
file_path: str,
*,
mime: Optional[str] = None,
filename: Optional[str] = None,
) -> Optional[str]:
"""POST local file bytes to ``/relay/media``; return the reference URL or None on any failure."""
if not self.enabled:
return None
path = Path(file_path)
try:
data = path.read_bytes()
except OSError:
logger.warning("relay media upload: cannot read %s", file_path)
return None
if not data or len(data) > MEDIA_MAX_BYTES:
logger.warning(
"relay media upload: %s size %d outside (0, %d]",
file_path,
len(data),
MEDIA_MAX_BYTES,
)
return None
content_type = (
mime
or mimetypes.guess_type(filename or path.name)[0]
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],
}
url = f"{self._base_url}/relay/media"
def _post() -> Optional[str]:
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT_S) as resp:
body = json.loads(resp.read().decode("utf-8"))
media_id = body.get("id")
if not media_id:
return None
return f"{self._base_url}/relay/media/{media_id}"
except (urllib.error.URLError, ValueError, OSError) as exc:
logger.warning("relay media upload failed: %s", exc)
return None
return await asyncio.get_running_loop().run_in_executor(None, _post)
async def download(self, url: str, *, suggested_name: Optional[str] = None) -> Optional[str]:
"""GET an attachment to a local temp file; return its path or None on any failure.
The bearer is presented only for connector re-host URLs; public URLs
(e.g. a Discord CDN pass-through) are fetched without it.
"""
if not url:
return None
needs_auth = self.is_relay_media_url(url)
if needs_auth and not self.enabled:
return None
headers = {"User-Agent": _MEDIA_USER_AGENT}
if needs_auth:
headers["Authorization"] = f"Bearer {self._bearer()}"
def _get() -> Optional[str]:
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT_S) as resp:
length = int(resp.headers.get("Content-Length") or 0)
if length > MEDIA_MAX_BYTES:
logger.warning("relay media download too large: %s", url)
return None
data = resp.read(MEDIA_MAX_BYTES + 1)
if not data or len(data) > MEDIA_MAX_BYTES:
return None
# Extension matters: vision/file tools sniff by extension.
# Prefer suggested/content-disposition name, then mime, then .bin.
name = suggested_name or ""
if not name:
cd = resp.headers.get("Content-Disposition") or ""
if "filename=" in cd:
name = cd.split("filename=", 1)[1].strip().strip('"')
ext = Path(name).suffix if name else ""
if not ext:
mime = (resp.headers.get("Content-Type") or "").split(";")[0]
ext = mimetypes.guess_extension(mime) or ".bin"
fd, tmp_path = tempfile.mkstemp(prefix="relay_media_", suffix=ext)
with os.fdopen(fd, "wb") as fh:
fh.write(data)
return tmp_path
except (urllib.error.URLError, ValueError, OSError) as exc:
logger.warning("relay media download failed for %s: %s", url, exc)
return None
return await asyncio.get_running_loop().run_in_executor(None, _get)
__all__ = ["RelayMediaClient", "media_base_url", "MEDIA_MAX_BYTES"]