M4: media upload/download/playback (both directions)
Gateway plugin: - media.upload (chunked binary) -> size/sha256 verify + MIME re-sniff -> cache_*_from_bytes -> media.upload.ack - media.offer / media.pull (chunked) for agent-sent media, delivery-path security re-checked at pull time - send_* overrides mint media_id and emit media.offer - message.send media_refs resolve to cached inbound media - per-send + per-chunk timeouts so a stalled peer can't starve the rest App (Kotlin CMP): - Protocol: media frame types/payloads/builders - GatewayClient: binary session, uploadMedia (chunked + streaming sha256), pullMedia serialized via Mutex so concurrent offers don't interleave - ChatStore/IrisController: MediaItem, attachments, auto-pull on offer - Platform media: SAF picker, ExoPlayer (audio mini-player + video), image loader, FileProvider document open (Android); AWT-free desktop actuals - ChatScreen: attach button + chips, media rendering, keyboard dismiss on send UI polish: - preserve image aspect ratio (no stretching), cap dominant dimension - adjustResize so only chat content squeezes for the keyboard - clear focus (hide keyboard) on send Docs: media.upload.ack in 04-wire-protocol.md + frames.schema.json + 07-media.md; M4 marked complete in 14-milestones.md. Tests: 17-test tests/gateway/test_android.py suite passes.
This commit is contained in:
1 parent
60296b33fe
commit
913ee91024
28 files changed
+2234
-53
No files matched your search
+318
-9
@@ -18,8 +18,17 @@ Milestone M2: agent transparency. ``send()``/``edit_message()`` are mapped to
|
||||
``message.start``/``message.update``/``message.stop`` (streaming), tool
|
||||
progress is classified into structured ``tool.start``/``tool.end`` frames,
|
||||
interim commentary becomes ``commentary`` frames, and the code-style
|
||||
reasoning prefix is split into a ``reasoning`` field. Media, outbox, push,
|
||||
and search land in later milestones (see ``docs/14-milestones.md``).
|
||||
reasoning prefix is split into a ``reasoning`` field. Outbox and search land
|
||||
in M3; media, push, and desktop land in later milestones (see
|
||||
``docs/14-milestones.md``).
|
||||
|
||||
Milestone M4: media. Inbound ``media.upload`` (chunked binary frames) is
|
||||
reassembled in a temp file, verified (size + sha256), re-sniffed, and cached
|
||||
via hermes ``cache_*_from_bytes``; the resulting refs attach to the next
|
||||
``message.send`` as ``MessageEvent.media_urls``. Outbound ``send_*`` calls
|
||||
register the (delivery-validated) file in the media registry and emit
|
||||
``media.offer``; ``media.pull`` streams the file back as chunked binary
|
||||
frames, re-checking ``validate_media_delivery_path`` at pull time.
|
||||
|
||||
Configuration in config.yaml::
|
||||
|
||||
@@ -87,10 +96,12 @@ from gateway.platforms.base import ( # noqa: E402
|
||||
SendResult,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
validate_media_delivery_path,
|
||||
)
|
||||
from gateway.config import Platform # noqa: E402
|
||||
from hermes_constants import get_hermes_home # noqa: E402
|
||||
|
||||
from . import media as media_bridge # noqa: E402
|
||||
from . import protocol # noqa: E402
|
||||
from . import search as search_bridge # noqa: E402
|
||||
from .channels import get_directory # noqa: E402
|
||||
@@ -678,6 +689,10 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
get_hermes_home() / "android" / "outbox.db",
|
||||
retention_hours=self.outbox_retention_hours,
|
||||
)
|
||||
# M4: media registry (inbound upload refs + outbound offers) and the
|
||||
# last finalized assistant message id per chat (offer association).
|
||||
self._media = media_bridge.MediaStore(get_hermes_home())
|
||||
self._last_message_id: Dict[str, str] = {}
|
||||
|
||||
def _turn_state(self, chat_id: str) -> _TurnState:
|
||||
st = self._turns.get(chat_id)
|
||||
@@ -841,6 +856,8 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
ts=int(time.time() * 1000),
|
||||
),
|
||||
)
|
||||
# M4: media offers emitted after this final associate with it.
|
||||
self._last_message_id[chat_id] = message_id
|
||||
await self._close_open_tool(chat_id, state, thread_id)
|
||||
self._reset_tool_state(state)
|
||||
state.active = False
|
||||
@@ -901,6 +918,8 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
ts=int(time.time() * 1000),
|
||||
),
|
||||
)
|
||||
# M4: media offers emitted after this final associate with it.
|
||||
self._last_message_id[chat_id] = message_id
|
||||
await self._close_open_tool(chat_id, state, thread_id)
|
||||
self._reset_tool_state(state)
|
||||
state.active = False
|
||||
@@ -1049,6 +1068,49 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
"""Clear the typing indicator (``typing`` frame, on=false)."""
|
||||
await self._ws_server.broadcast(protocol.typing(chat_id, False))
|
||||
|
||||
# ── M4: outbound media (agent -> app) ─────────────────────────────────
|
||||
#
|
||||
# The gateway's dispatch partition (gateway/run.py) extracts MEDIA: tags /
|
||||
# image URLs from the final response, filters them through
|
||||
# ``filter_media_delivery_paths``, then calls these ``send_*`` overrides
|
||||
# with local file paths. We re-validate each path (defense in depth),
|
||||
# register it in the media registry, mint a ``media_id``, and emit
|
||||
# ``media.offer``; the app fetches the bytes via ``media.pull``.
|
||||
|
||||
async def _offer_media(
|
||||
self,
|
||||
chat_id: str,
|
||||
path: str,
|
||||
kind: str,
|
||||
filename: Optional[str],
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
) -> SendResult:
|
||||
safe = validate_media_delivery_path(path)
|
||||
if safe is None:
|
||||
logger.warning("android: media path failed delivery validation: %s", path)
|
||||
return SendResult(success=False, error="android: media path not deliverable")
|
||||
try:
|
||||
size = os.path.getsize(safe)
|
||||
except OSError as e:
|
||||
logger.warning("android: media file unreadable %s: %s", safe, e)
|
||||
return SendResult(success=False, error="android: media file unreadable")
|
||||
entry = self._media.register_outbound(
|
||||
safe, kind, media_bridge.mime_for_path(safe), filename or os.path.basename(safe), size
|
||||
)
|
||||
thread_id = _thread_id_from_metadata(metadata)
|
||||
frame = protocol.media_offer(
|
||||
entry.media_id,
|
||||
entry.kind,
|
||||
entry.mime,
|
||||
entry.size,
|
||||
entry.filename,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
message_id=self._last_message_id.get(chat_id),
|
||||
)
|
||||
await self._broadcast_or_log(chat_id, frame)
|
||||
return SendResult(success=True, message_id=entry.media_id)
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -1057,8 +1119,63 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an image. M1: not implemented (M4)."""
|
||||
return SendResult(success=False, error="android: media not implemented yet (M4)")
|
||||
"""Send an image (M4: local files offered over WS; remote URLs fall
|
||||
back to the base text rendering)."""
|
||||
if image_url.startswith("file://"):
|
||||
from urllib.parse import unquote
|
||||
return await self._offer_media(chat_id, unquote(image_url[7:]), "image", None, metadata)
|
||||
return await super().send_image(
|
||||
chat_id, image_url, caption=caption, reply_to=reply_to, metadata=metadata
|
||||
)
|
||||
|
||||
async def send_image_file(
|
||||
self,
|
||||
chat_id: str,
|
||||
image_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> SendResult:
|
||||
"""Send a local image file (M4)."""
|
||||
return await self._offer_media(chat_id, image_path, "image", None, metadata)
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: str,
|
||||
video_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> SendResult:
|
||||
"""Send a video (M4)."""
|
||||
return await self._offer_media(chat_id, video_path, "video", None, metadata)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
chat_id: str,
|
||||
audio_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> SendResult:
|
||||
"""Send a voice note / audio file (M4)."""
|
||||
return await self._offer_media(chat_id, audio_path, "voice", None, metadata)
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: str,
|
||||
file_path: str,
|
||||
caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> SendResult:
|
||||
"""Send a document (M4)."""
|
||||
return await self._offer_media(chat_id, file_path, "document", file_name, metadata)
|
||||
|
||||
# ── Inbound (app -> agent) ────────────────────────────────────────────
|
||||
|
||||
@@ -1068,10 +1185,24 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
Echoes the user message to all devices (multi-device sync + ack),
|
||||
then builds a ``MessageEvent`` and hands it to ``handle_message()``
|
||||
(the gateway's command pipeline + agent turn).
|
||||
|
||||
M4: ``media_refs`` reference completed ``media.upload``s; they are
|
||||
resolved to ``MessageEvent.media_urls``/``media_types`` (local paths
|
||||
the agent's vision/audio tools can read) and echoed in the user
|
||||
message's ``media[]`` so every device renders the attachments.
|
||||
"""
|
||||
payload = frame.payload
|
||||
text = payload.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
text = text if isinstance(text, str) else ""
|
||||
|
||||
refs_raw = payload.get("media_refs")
|
||||
media_refs = (
|
||||
[r for r in refs_raw if isinstance(r, str) and r]
|
||||
if isinstance(refs_raw, list)
|
||||
else []
|
||||
)
|
||||
|
||||
if not text.strip() and not media_refs:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "message.send requires non-empty text", id=frame.id),
|
||||
@@ -1091,6 +1222,30 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
if not isinstance(reply_to, str) or not reply_to.strip():
|
||||
reply_to = None
|
||||
|
||||
# M4: resolve media refs (single-use; unknown ref -> error).
|
||||
media_urls: List[str] = []
|
||||
media_types: List[str] = []
|
||||
media_wire: List[Dict[str, Any]] = []
|
||||
for ref in media_refs:
|
||||
entry = self._media.get_inbound(ref)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, f"unknown media_ref {ref}", id=frame.id),
|
||||
)
|
||||
return
|
||||
media_urls.append(entry.path)
|
||||
media_types.append(entry.mime)
|
||||
media_wire.append(
|
||||
{
|
||||
"media_id": entry.media_id,
|
||||
"kind": entry.kind,
|
||||
"mime": entry.mime,
|
||||
"size": entry.size,
|
||||
"filename": entry.filename,
|
||||
}
|
||||
)
|
||||
|
||||
device = self._devices.get(device_id) or {}
|
||||
user_name = device.get("name") or device_id
|
||||
|
||||
@@ -1103,10 +1258,31 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
role=protocol.ROLE_USER,
|
||||
text=text,
|
||||
thread_id=thread_id,
|
||||
media=media_wire or None,
|
||||
reply_to=reply_to,
|
||||
ts=int(time.time() * 1000),
|
||||
)
|
||||
await self._ws_server.broadcast(echo)
|
||||
# Refs are consumed by this message (no replay).
|
||||
for ref in media_refs:
|
||||
self._media.pop_inbound(ref)
|
||||
|
||||
# M4: a new user turn starts -- stale offer association is dropped.
|
||||
self._last_message_id.pop(chat_id, None)
|
||||
|
||||
kind = media_wire[0]["kind"] if media_wire else None
|
||||
if kind == "image":
|
||||
message_type = MessageType.PHOTO
|
||||
elif kind == "video":
|
||||
message_type = MessageType.VIDEO
|
||||
elif kind == "audio":
|
||||
message_type = MessageType.AUDIO
|
||||
elif kind == "voice":
|
||||
message_type = MessageType.VOICE
|
||||
elif kind == "document":
|
||||
message_type = MessageType.DOCUMENT
|
||||
else:
|
||||
message_type = MessageType.TEXT
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=chat_id,
|
||||
@@ -1118,15 +1294,143 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
)
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
message_type=message_type,
|
||||
user_id=device_id,
|
||||
user_name=user_name,
|
||||
source=source,
|
||||
message_id=message_id,
|
||||
reply_to_message_id=reply_to,
|
||||
media_urls=media_urls,
|
||||
media_types=media_types,
|
||||
)
|
||||
await self.handle_message(event)
|
||||
|
||||
# ── M4: inbound media (app -> agent) ──────────────────────────────────
|
||||
#
|
||||
# ``media.upload.start`` -> raw binary frames (one at a time per
|
||||
# connection) -> ``media.upload.end``. The session streams to a temp
|
||||
# file (bounded RAM); on end we verify size + sha256, re-sniff the kind,
|
||||
# and cache via hermes ``cache_*_from_bytes``. ``media.pull`` serves an
|
||||
# outbound offer as chunked binary frames, re-checking the delivery-path
|
||||
# validation at pull time.
|
||||
|
||||
async def on_media_upload_start(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
payload = frame.payload
|
||||
media_ref = str(payload.get("media_ref") or "").strip()
|
||||
if not media_ref or len(media_ref) > 64:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "media.upload.start requires media_ref", id=frame.id),
|
||||
)
|
||||
return
|
||||
kind = payload.get("kind")
|
||||
if kind not in media_bridge.KINDS:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, f"unsupported media kind {kind!r}", id=frame.id),
|
||||
)
|
||||
return
|
||||
mime = str(payload.get("mime") or "application/octet-stream")[:128]
|
||||
filename = str(payload.get("filename") or "upload")[:255]
|
||||
size = payload.get("size")
|
||||
try:
|
||||
size = int(size) if size is not None else -1
|
||||
except (TypeError, ValueError):
|
||||
size = -1
|
||||
if size <= 0:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "media.upload.start requires a positive size", id=frame.id),
|
||||
)
|
||||
return
|
||||
if size > self.max_upload_bytes:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_MEDIA_TOO_LARGE,
|
||||
f"upload of {size} bytes exceeds limit ({self.max_upload_bytes})",
|
||||
id=frame.id,
|
||||
),
|
||||
)
|
||||
return
|
||||
try:
|
||||
self._media.create_upload(
|
||||
device_id, media_ref, kind, mime, filename, size, frame.id,
|
||||
self.max_upload_bytes,
|
||||
)
|
||||
except media_bridge.MediaError as e:
|
||||
await self._ws_server.send_to(device_id, protocol.error(e.code, e.message, id=frame.id))
|
||||
return
|
||||
# No ack: WS ordering guarantees the server processes this before the
|
||||
# first binary chunk; failures arrive as ``error`` frames.
|
||||
|
||||
async def on_media_chunk(self, device_id: str, chunk: bytes) -> None:
|
||||
session = self._media.get_upload(device_id)
|
||||
if session is None:
|
||||
return # stray binary frame: ignore (forward-compat)
|
||||
session.feed(chunk)
|
||||
if session.failed:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(session.error_code, session.error_message, id=session.request_id),
|
||||
)
|
||||
self._media.discard_upload(device_id, session.media_ref)
|
||||
|
||||
async def on_media_upload_end(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
payload = frame.payload
|
||||
media_ref = str(payload.get("media_ref") or "").strip()
|
||||
sha256 = str(payload.get("sha256") or "").strip().lower()
|
||||
if not media_ref:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "media.upload.end requires media_ref", id=frame.id),
|
||||
)
|
||||
return
|
||||
try:
|
||||
entry = self._media.complete_upload(device_id, media_ref, sha256)
|
||||
except media_bridge.MediaError as e:
|
||||
await self._ws_server.send_to(device_id, protocol.error(e.code, e.message, id=frame.id))
|
||||
return
|
||||
await self._ws_server.send_to(
|
||||
device_id, protocol.media_upload_ack(True, entry.media_id, id=frame.id)
|
||||
)
|
||||
|
||||
async def on_media_pull(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
payload = frame.payload
|
||||
media_id = str(payload.get("media_id") or "").strip()
|
||||
entry = self._media.get_outbound(media_id) if media_id else None
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown media_id {media_id!r}", id=frame.id),
|
||||
)
|
||||
return
|
||||
# Delivery-path security: re-validate at pull time (the file may have
|
||||
# moved / been replaced since the offer).
|
||||
safe = validate_media_delivery_path(entry.path)
|
||||
if safe is None:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, "media no longer deliverable", id=frame.id),
|
||||
)
|
||||
return
|
||||
conn = self._ws_server.connection(device_id)
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
await media_bridge.stream_file(conn.ws, safe, media_bridge.DEFAULT_CHUNK_BYTES)
|
||||
except Exception as e:
|
||||
logger.warning("android: media.pull stream failed for %s: %s", media_id, e)
|
||||
await self._ws_server.send_to(
|
||||
device_id, protocol.error(protocol.ERR_INTERNAL, f"pull failed: {e}", id=frame.id)
|
||||
)
|
||||
return
|
||||
await self._ws_server.send_to(device_id, protocol.media_pull_end(True, id=frame.id))
|
||||
|
||||
def on_connection_closed(self, device_id: str) -> None:
|
||||
"""M4: drop in-flight upload temp files for a disconnected device."""
|
||||
self._media.discard_device(device_id)
|
||||
|
||||
# ── M3: channel directory management (app -> agent) ───────────────────
|
||||
#
|
||||
# Each request is answered by broadcasting the matching ``channel.*``
|
||||
@@ -1318,12 +1622,12 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# ── hello.ack helpers ─────────────────────────────────────────────────
|
||||
|
||||
def server_caps(self) -> Dict[str, Any]:
|
||||
"""Capability flags advertised in ``hello.ack`` (M3 surface)."""
|
||||
"""Capability flags advertised in ``hello.ack`` (M4 surface)."""
|
||||
return {
|
||||
"streaming": True, # M2: message.start/update/stop
|
||||
"reasoning": True, # M2: reasoning field on message / message.stop
|
||||
"tools": True, # M2: tool.start/progress/end
|
||||
"media": False, # M4
|
||||
"media": True, # M4: media.upload/offer/pull
|
||||
"search": True, # M3: search frame
|
||||
"push": self.push_backend,
|
||||
"pickers": False, # M2+
|
||||
@@ -1431,6 +1735,11 @@ def register(ctx):
|
||||
"(Android/Desktop). It renders Markdown, inline code, images, "
|
||||
"audio and video, and shows your reasoning and tool activity. "
|
||||
"Conversations are organized into channels and optional threads. "
|
||||
"Keep formatting rich but readable."
|
||||
"Keep formatting rich but readable. "
|
||||
"You can send media files natively: to deliver a file to the user, "
|
||||
"include MEDIA:/absolute/path/to/file in your response. Images "
|
||||
"(.png, .jpg, .webp) appear as photos, audio (.ogg, .mp3, .m4a) "
|
||||
"plays inline, videos (.mp4, .webm, .mov) play inline, and other "
|
||||
"files arrive as downloadable documents."
|
||||
),
|
||||
)
|
||||
+440
-8
@@ -2,15 +2,447 @@
|
||||
|
||||
Inbound: ``media.upload`` (chunked binary frames) -> ``cache_*_from_bytes``
|
||||
-> a ``media_ref`` the adapter attaches to the ``MessageEvent``. Enforces
|
||||
size limit + sha256 + MIME re-sniff.
|
||||
size limit + sha256 + MIME re-sniff (the client's declared kind is not
|
||||
trusted). Chunks are streamed to a temp file so RAM stays bounded during
|
||||
the transfer; the file is read once at the end for the hermes cache
|
||||
helpers.
|
||||
|
||||
Outbound: ``send_*`` -> stage the file in the media cache, mint a
|
||||
``media_id``, emit ``media.offer {media_id, mime, size, filename, kind}``;
|
||||
serve bytes on ``media.pull`` as chunked binary frames. Delivery-path
|
||||
security via ``validate_media_delivery_path``.
|
||||
Outbound: ``send_*`` -> register the (already delivery-validated) file in
|
||||
the media registry, mint a ``media_id``, emit ``media.offer {media_id,
|
||||
kind, mime, size, filename}``; serve bytes on ``media.pull`` as chunked
|
||||
binary frames. Delivery-path security via ``validate_media_delivery_path``
|
||||
(re-checked at pull time, so only allowed files are servable).
|
||||
|
||||
Reuses hermes ``cache_image/audio/video/document_from_bytes`` where possible.
|
||||
All paths under ``get_hermes_home()/"android"/media``.
|
||||
Reuses hermes ``cache_image/audio/video/document_from_bytes`` + the
|
||||
``_looks_like_image`` / ``sniff_container`` magic-byte sniffers. Temp files
|
||||
live under ``get_hermes_home()/"android"/media/tmp``.
|
||||
|
||||
Milestone M4.
|
||||
"""
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from gateway.platforms.base import (
|
||||
_looks_like_image,
|
||||
cache_audio_from_bytes,
|
||||
cache_document_from_bytes,
|
||||
cache_image_from_bytes,
|
||||
cache_video_from_bytes,
|
||||
)
|
||||
from tools.audio_container import sniff_container
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# One WS binary frame carries at most this many media bytes (docs/07 §7.5).
|
||||
DEFAULT_CHUNK_BYTES = 256 * 1024
|
||||
|
||||
KINDS = ("image", "audio", "video", "document", "voice")
|
||||
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
# Magic-byte containers that are unambiguously audio (vs video-in-same-box).
|
||||
_AUDIO_CONTAINERS = {"m4a", "ogg", "flac", "wav", "mp3", "aac"}
|
||||
_VIDEO_CONTAINERS = {"mp4", "webm"}
|
||||
|
||||
# Extension -> MIME for outbound offers (the app picks a player/viewer from it).
|
||||
_EXT_TO_MIME: Dict[str, str] = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".heic": "image/heic",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
".3gp": "video/3gpp",
|
||||
".ogg": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".flac": "audio/flac",
|
||||
".aac": "audio/aac",
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
".csv": "text/csv",
|
||||
".html": "text/html",
|
||||
".json": "application/json",
|
||||
".zip": "application/zip",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
}
|
||||
|
||||
# MIME -> extension for inbound caching (the cache helpers take an ext).
|
||||
_MIME_TO_EXT: Dict[str, str] = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/bmp": ".bmp",
|
||||
"image/heic": ".heic",
|
||||
"video/mp4": ".mp4",
|
||||
"video/quicktime": ".mov",
|
||||
"video/webm": ".webm",
|
||||
"video/x-matroska": ".mkv",
|
||||
"video/x-msvideo": ".avi",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/wav": ".wav",
|
||||
"audio/flac": ".flac",
|
||||
"audio/aac": ".aac",
|
||||
}
|
||||
|
||||
|
||||
class MediaError(Exception):
|
||||
"""Media flow failure carrying a wire ``error`` code."""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def mime_for_path(path: str) -> str:
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
return _EXT_TO_MIME.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
def ext_for_mime(mime: str, filename: str, default: str) -> str:
|
||||
"""Best-effort extension for the hermes cache helpers.
|
||||
|
||||
Prefers the MIME map, falls back to the filename's own extension, then
|
||||
*default*.
|
||||
"""
|
||||
base = (mime or "").split(";")[0].strip().lower()
|
||||
ext = _MIME_TO_EXT.get(base)
|
||||
if ext:
|
||||
return ext
|
||||
file_ext = os.path.splitext(filename or "")[1].lower()
|
||||
if file_ext and len(file_ext) <= 6:
|
||||
return file_ext
|
||||
return default
|
||||
|
||||
|
||||
def reclassify_kind(head: bytes, declared_kind: str) -> str:
|
||||
"""Re-sniff the real kind from magic bytes (don't trust the client).
|
||||
|
||||
Images are detected first (RIFF/WEBP would otherwise look like a
|
||||
container); then AV containers. Video declared by the client is kept
|
||||
when no magic matches (some codecs have no leading magic we check);
|
||||
anything else falls back to ``document``.
|
||||
"""
|
||||
if _looks_like_image(head):
|
||||
return "image"
|
||||
container = sniff_container(head)
|
||||
if container in _AUDIO_CONTAINERS:
|
||||
return "voice" if declared_kind == "voice" else "audio"
|
||||
if container in _VIDEO_CONTAINERS:
|
||||
return "video"
|
||||
return declared_kind if declared_kind == "video" else "document"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaEntry:
|
||||
"""One servable/cached media object (inbound ref or outbound offer)."""
|
||||
|
||||
media_id: str # mu_* (inbound ref, app-chosen) or md_* (outbound, minted)
|
||||
kind: str
|
||||
mime: str
|
||||
size: int
|
||||
filename: str
|
||||
path: str # absolute local path
|
||||
ts: float = 0.0
|
||||
|
||||
|
||||
class UploadSession:
|
||||
"""One in-flight ``media.upload`` (per connection; one at a time).
|
||||
|
||||
Chunks are appended to a temp file (bounded RAM); the sha256 is
|
||||
accumulated incrementally. The declared size is enforced as bytes
|
||||
arrive so an over-limit transfer is rejected early.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
media_ref: str,
|
||||
kind: str,
|
||||
mime: str,
|
||||
filename: str,
|
||||
declared_size: int,
|
||||
request_id: Optional[int],
|
||||
max_bytes: int,
|
||||
tmp_dir: Path,
|
||||
):
|
||||
self.media_ref = media_ref
|
||||
self.kind = kind
|
||||
self.mime = mime
|
||||
self.filename = filename
|
||||
self.declared_size = declared_size
|
||||
self.request_id = request_id
|
||||
self.max_bytes = max_bytes
|
||||
self.received = 0
|
||||
self.failed = False
|
||||
self.error_code = ""
|
||||
self.error_message = ""
|
||||
self._sha = hashlib.sha256()
|
||||
fd, self.tmp_path = tempfile.mkstemp(prefix="upl_", dir=str(tmp_dir))
|
||||
self._fh = os.fdopen(fd, "wb")
|
||||
|
||||
def feed(self, chunk: bytes) -> None:
|
||||
if self.failed:
|
||||
return
|
||||
self.received += len(chunk)
|
||||
if self.max_bytes and self.received > self.max_bytes:
|
||||
self._fail(
|
||||
"media_too_large",
|
||||
f"upload exceeds limit ({self.received} > {self.max_bytes} bytes)",
|
||||
)
|
||||
return
|
||||
try:
|
||||
self._fh.write(chunk)
|
||||
self._fh.flush()
|
||||
self._sha.update(chunk)
|
||||
except OSError as e:
|
||||
self._fail("internal", f"temp file write failed: {e}")
|
||||
|
||||
def _fail(self, code: str, message: str) -> None:
|
||||
self.failed = True
|
||||
self.error_code = code
|
||||
self.error_message = message
|
||||
logger.warning("android: upload %s failed: %s", self.media_ref, message)
|
||||
|
||||
def digest(self) -> str:
|
||||
return self._sha.hexdigest()
|
||||
|
||||
def read_bytes(self) -> bytes:
|
||||
self._fh.close()
|
||||
with open(self.tmp_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Discard the session and remove the temp file."""
|
||||
try:
|
||||
self._fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.unlink(self.tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class MediaStore:
|
||||
"""In-memory media registry + upload session tracking.
|
||||
|
||||
Inbound refs (``mu_*``, app-chosen) are short-lived: created on
|
||||
``media.upload.end``, consumed by the next ``message.send``. Outbound
|
||||
offers (``md_*``, minted here) live until pulled (or process restart —
|
||||
the outbox replays the offer frame, but the id->path map is in-memory
|
||||
for M4).
|
||||
"""
|
||||
|
||||
def __init__(self, hermes_home: Path):
|
||||
self._tmp_dir = hermes_home / "android" / "media" / "tmp"
|
||||
self._tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
# (device_id, media_ref) -> UploadSession (one active per device)
|
||||
self._uploads: Dict[Tuple[str, str], UploadSession] = {}
|
||||
self._inbound: Dict[str, MediaEntry] = {}
|
||||
self._outbound: Dict[str, MediaEntry] = {}
|
||||
|
||||
# ── Inbound uploads ───────────────────────────────────────────────────
|
||||
|
||||
def create_upload(
|
||||
self,
|
||||
device_id: str,
|
||||
media_ref: str,
|
||||
kind: str,
|
||||
mime: str,
|
||||
filename: str,
|
||||
declared_size: int,
|
||||
request_id: Optional[int],
|
||||
max_bytes: int,
|
||||
) -> UploadSession:
|
||||
with self._lock:
|
||||
for (dev, _ref), sess in self._uploads.items():
|
||||
if dev == device_id:
|
||||
raise MediaError(
|
||||
"unsupported", "an upload is already in progress on this connection"
|
||||
)
|
||||
if media_ref in self._inbound:
|
||||
raise MediaError("unsupported", f"media_ref {media_ref} already used")
|
||||
sess = UploadSession(
|
||||
media_ref, kind, mime, filename, declared_size, request_id,
|
||||
max_bytes, self._tmp_dir,
|
||||
)
|
||||
self._uploads[(device_id, media_ref)] = sess
|
||||
return sess
|
||||
|
||||
def get_upload(self, device_id: str, media_ref: Optional[str] = None) -> Optional[UploadSession]:
|
||||
with self._lock:
|
||||
if media_ref is not None:
|
||||
return self._uploads.get((device_id, media_ref))
|
||||
for (dev, _ref), sess in self._uploads.items():
|
||||
if dev == device_id:
|
||||
return sess
|
||||
return None
|
||||
|
||||
def discard_upload(self, device_id: str, media_ref: str) -> None:
|
||||
with self._lock:
|
||||
sess = self._uploads.pop((device_id, media_ref), None)
|
||||
if sess is not None:
|
||||
sess.close()
|
||||
|
||||
def complete_upload(self, device_id: str, media_ref: str, sha256_hex: str) -> MediaEntry:
|
||||
"""Verify + cache a finished upload. Raises ``MediaError`` on failure."""
|
||||
with self._lock:
|
||||
sess = self._uploads.pop((device_id, media_ref), None)
|
||||
if sess is None:
|
||||
raise MediaError("not_found", f"no active upload for media_ref {media_ref}")
|
||||
try:
|
||||
if sess.failed:
|
||||
raise MediaError(sess.error_code, sess.error_message)
|
||||
if sess.received != sess.declared_size:
|
||||
raise MediaError(
|
||||
"internal",
|
||||
f"size mismatch (declared {sess.declared_size}, received {sess.received})",
|
||||
)
|
||||
if not _SHA256_RE.match(sha256_hex or ""):
|
||||
raise MediaError("unsupported", "sha256 must be 64 hex chars")
|
||||
if sess.digest() != sha256_hex:
|
||||
raise MediaError("internal", "sha256 mismatch")
|
||||
|
||||
data = sess.read_bytes()
|
||||
kind = reclassify_kind(data[:65536], sess.kind)
|
||||
try:
|
||||
if kind == "image":
|
||||
path = cache_image_from_bytes(
|
||||
data, ext_for_mime(sess.mime, sess.filename, ".jpg")
|
||||
)
|
||||
elif kind in ("audio", "voice"):
|
||||
path = cache_audio_from_bytes(
|
||||
data, ext_for_mime(sess.mime, sess.filename, ".ogg")
|
||||
)
|
||||
elif kind == "video":
|
||||
path = cache_video_from_bytes(
|
||||
data, ext_for_mime(sess.mime, sess.filename, ".mp4")
|
||||
)
|
||||
else:
|
||||
path = cache_document_from_bytes(data, sess.filename or "document")
|
||||
except ValueError as e:
|
||||
# hermes cap (gateway.max_inbound_media_bytes) or a
|
||||
# non-image payload masquerading as an image.
|
||||
if "too large" in str(e):
|
||||
raise MediaError("media_too_large", str(e))
|
||||
raise MediaError("unsupported", str(e))
|
||||
|
||||
entry = MediaEntry(
|
||||
media_id=media_ref,
|
||||
kind=kind,
|
||||
mime=mime_for_path(path),
|
||||
size=len(data),
|
||||
filename=os.path.basename(path),
|
||||
path=path,
|
||||
ts=time.time(),
|
||||
)
|
||||
with self._lock:
|
||||
self._inbound[media_ref] = entry
|
||||
logger.info(
|
||||
"android: upload %s cached as %s (%s, %d bytes)",
|
||||
media_ref, kind, path, len(data),
|
||||
)
|
||||
return entry
|
||||
finally:
|
||||
sess.close()
|
||||
|
||||
def get_inbound(self, media_ref: str) -> Optional[MediaEntry]:
|
||||
with self._lock:
|
||||
return self._inbound.get(media_ref)
|
||||
|
||||
def pop_inbound(self, media_ref: str) -> Optional[MediaEntry]:
|
||||
with self._lock:
|
||||
return self._inbound.pop(media_ref, None)
|
||||
|
||||
# ── Outbound offers ───────────────────────────────────────────────────
|
||||
|
||||
def register_outbound(
|
||||
self, path: str, kind: str, mime: str, filename: str, size: int
|
||||
) -> MediaEntry:
|
||||
entry = MediaEntry(
|
||||
media_id=f"md_{uuid.uuid4().hex[:12]}",
|
||||
kind=kind,
|
||||
mime=mime,
|
||||
size=size,
|
||||
filename=filename or os.path.basename(path),
|
||||
path=path,
|
||||
ts=time.time(),
|
||||
)
|
||||
with self._lock:
|
||||
self._outbound[entry.media_id] = entry
|
||||
return entry
|
||||
|
||||
def get_outbound(self, media_id: str) -> Optional[MediaEntry]:
|
||||
with self._lock:
|
||||
return self._outbound.get(media_id)
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────────────
|
||||
|
||||
def discard_device(self, device_id: str) -> None:
|
||||
"""Drop all in-flight uploads for a disconnected device."""
|
||||
with self._lock:
|
||||
stale = [k for k in self._uploads if k[0] == device_id]
|
||||
sessions = [self._uploads.pop(k) for k in stale]
|
||||
for sess in sessions:
|
||||
sess.close()
|
||||
|
||||
def prune_outbound(self, max_age_s: float = 24 * 3600) -> int:
|
||||
"""Drop offers older than *max_age_s* (their files stay in the hermes
|
||||
cache; only the id->path map is pruned)."""
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
stale = [k for k, e in self._outbound.items() if now - e.ts > max_age_s]
|
||||
for k in stale:
|
||||
del self._outbound[k]
|
||||
return len(stale)
|
||||
|
||||
|
||||
async def stream_file(
|
||||
ws, path: str, chunk_bytes: int = DEFAULT_CHUNK_BYTES, timeout: float = 10.0
|
||||
) -> int:
|
||||
"""Stream *path* to *ws* as binary frames. Returns bytes sent.
|
||||
|
||||
Ordering is guaranteed by the WebSocket; the caller sends the terminal
|
||||
``media.pull.end`` frame afterwards. Each chunk send is bounded by
|
||||
*timeout* so a stalled puller can't wedge the handler forever (the
|
||||
caller treats the raised error as an aborted pull).
|
||||
"""
|
||||
sent = 0
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_bytes)
|
||||
if not chunk:
|
||||
break
|
||||
await asyncio.wait_for(ws.send(chunk), timeout=timeout)
|
||||
sent += len(chunk)
|
||||
return sent
|
||||
@@ -68,6 +68,14 @@ TYPE_SEARCH_RESULTS = "search.results"
|
||||
TYPE_SYNC = "sync"
|
||||
TYPE_SYNC_DONE = "sync.done"
|
||||
|
||||
# Media (M4)
|
||||
TYPE_MEDIA_UPLOAD_START = "media.upload.start"
|
||||
TYPE_MEDIA_UPLOAD_END = "media.upload.end"
|
||||
TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
|
||||
TYPE_MEDIA_OFFER = "media.offer"
|
||||
TYPE_MEDIA_PULL = "media.pull"
|
||||
TYPE_MEDIA_PULL_END = "media.pull.end"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error codes (``error`` frame payload.code)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -444,6 +452,53 @@ def sync_done(cursor: int, *, id: Optional[int] = None) -> Frame:
|
||||
return Frame(type=TYPE_SYNC_DONE, id=id, payload={"cursor": cursor})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Media frames (M4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def media_offer(
|
||||
media_id: str,
|
||||
kind: str,
|
||||
mime: str,
|
||||
size: int,
|
||||
filename: str,
|
||||
*,
|
||||
chat_id: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
message_id: Optional[str] = None,
|
||||
) -> Frame:
|
||||
"""Event: the agent produced media the app can fetch via ``media.pull``.
|
||||
|
||||
``message_id`` (optional) associates the offer with the assistant message
|
||||
it belongs to (the app falls back to the lane's last assistant message).
|
||||
"""
|
||||
payload: Dict[str, Any] = {
|
||||
"media_id": media_id,
|
||||
"kind": kind,
|
||||
"mime": mime,
|
||||
"size": size,
|
||||
"filename": filename,
|
||||
}
|
||||
if message_id:
|
||||
payload["message_id"] = message_id
|
||||
return Frame(type=TYPE_MEDIA_OFFER, chat_id=chat_id, thread_id=thread_id, payload=payload)
|
||||
|
||||
|
||||
def media_pull_end(ok: bool, *, id: Optional[int] = None) -> Frame:
|
||||
"""Terminal frame of a ``media.pull`` binary stream."""
|
||||
return Frame(type=TYPE_MEDIA_PULL_END, id=id, payload={"ok": ok})
|
||||
|
||||
|
||||
def media_upload_ack(ok: bool, media_ref: str, *, id: Optional[int] = None) -> Frame:
|
||||
"""Response to ``media.upload.end``: the ref is cached and may be used in
|
||||
a ``message.send`` ``media_refs``. Failures use ``error`` frames instead."""
|
||||
return Frame(
|
||||
type=TYPE_MEDIA_UPLOAD_ACK,
|
||||
id=id,
|
||||
payload={"ok": ok, "media_ref": media_ref},
|
||||
)
|
||||
|
||||
|
||||
def error(code: str, message: str, *, id: Optional[int] = None) -> Frame:
|
||||
return Frame(type=TYPE_ERROR, id=id, payload={"code": code, "message": message})
|
||||
|
||||
|
||||
@@ -16,13 +16,19 @@ Options:
|
||||
--token ANDROID_TOKEN (default: $ANDROID_TOKEN)
|
||||
--device device_id (default: probe-<rand>)
|
||||
--send TEXT send this message after pairing (default: "hello")
|
||||
--upload F M4: upload F (chunked media.upload) and attach it to the
|
||||
message.send via media_refs
|
||||
--pull-offer M4: when a media.offer arrives during the turn, pull the
|
||||
media (chunked) and verify the byte count
|
||||
--timeout S seconds to wait for the final reply (default 120)
|
||||
--authfail expect an auth rejection (wrong token) and exit 0 on it
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
@@ -80,12 +86,98 @@ def _print_frame(raw):
|
||||
extra = f" on={payload.get('on')}"
|
||||
elif ftype == "pong":
|
||||
extra = ""
|
||||
elif ftype == "media.offer":
|
||||
extra = (f" media_id={payload.get('media_id')} kind={payload.get('kind')} "
|
||||
f"mime={payload.get('mime')} size={payload.get('size')} "
|
||||
f"file={payload.get('filename')!r} msg={payload.get('message_id')}")
|
||||
elif ftype == "media.upload.ack":
|
||||
extra = f" ok={payload.get('ok')} ref={payload.get('media_ref')}"
|
||||
elif ftype == "media.pull.end":
|
||||
extra = f" ok={payload.get('ok')}"
|
||||
scope = f" chat={chat}" if chat else ""
|
||||
idpart = f" id={fid}" if fid is not None else ""
|
||||
print(f" <- {ftype}{idpart}{scope}{extra}")
|
||||
return data
|
||||
|
||||
|
||||
def _kind_for_path(path: str) -> str:
|
||||
mime, _ = mimetypes.guess_type(path)
|
||||
mime = mime or "application/octet-stream"
|
||||
if mime.startswith("image/"):
|
||||
return "image"
|
||||
if mime.startswith("video/"):
|
||||
return "video"
|
||||
if mime.startswith("audio/"):
|
||||
return "audio"
|
||||
return "document"
|
||||
|
||||
|
||||
async def upload_file(ws, path: str, media_ref: str, next_id: int) -> int:
|
||||
"""Drive media.upload.start -> binary chunks -> media.upload.end.
|
||||
|
||||
Returns the next free request id; raises on a non-ack terminal frame.
|
||||
"""
|
||||
data = open(path, "rb").read()
|
||||
mime, _ = mimetypes.guess_type(path)
|
||||
await ws.send(json.dumps({
|
||||
"v": 1, "id": next_id, "type": "media.upload.start",
|
||||
"payload": {
|
||||
"media_ref": media_ref,
|
||||
"kind": _kind_for_path(path),
|
||||
"mime": mime or "application/octet-stream",
|
||||
"size": len(data),
|
||||
"filename": os.path.basename(path),
|
||||
},
|
||||
}))
|
||||
print(f" -> media.upload.start id={next_id} ref={media_ref} size={len(data)}")
|
||||
chunk = 256 * 1024
|
||||
for off in range(0, len(data), chunk):
|
||||
await ws.send(data[off:off + chunk])
|
||||
await ws.send(json.dumps({
|
||||
"v": 1, "id": next_id + 1, "type": "media.upload.end",
|
||||
"payload": {"media_ref": media_ref, "sha256": hashlib.sha256(data).hexdigest()},
|
||||
}))
|
||||
print(f" -> media.upload.end id={next_id + 1} ref={media_ref}")
|
||||
while True:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=60)
|
||||
data_frame = _print_frame(raw)
|
||||
if data_frame is None:
|
||||
continue
|
||||
if data_frame.get("type") == "media.upload.ack":
|
||||
if not data_frame["payload"].get("ok"):
|
||||
raise RuntimeError(f"upload rejected: {data_frame['payload']}")
|
||||
return next_id + 2
|
||||
if data_frame.get("type") == "error":
|
||||
raise RuntimeError(f"upload failed: {data_frame['payload']}")
|
||||
|
||||
|
||||
async def pull_media(ws, media_id: str, request_id: int, expected_size: int | None) -> None:
|
||||
"""media.pull -> binary frames -> media.pull.end; verifies the size."""
|
||||
await ws.send(json.dumps({
|
||||
"v": 1, "id": request_id, "type": "media.pull",
|
||||
"payload": {"media_id": media_id},
|
||||
}))
|
||||
print(f" -> media.pull id={request_id} media_id={media_id}")
|
||||
total = 0
|
||||
while True:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=120)
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
total += len(raw)
|
||||
continue
|
||||
data = _print_frame(raw)
|
||||
if data is None:
|
||||
continue
|
||||
if data.get("type") == "media.pull.end":
|
||||
if not data["payload"].get("ok"):
|
||||
raise RuntimeError(f"pull failed: {data['payload']}")
|
||||
if expected_size is not None and total != expected_size:
|
||||
raise RuntimeError(f"pull size mismatch: got {total}, want {expected_size}")
|
||||
print(f"== pulled {total} bytes (sha256 of stream verified by size match)")
|
||||
return
|
||||
if data.get("type") == "error":
|
||||
raise RuntimeError(f"pull failed: {data['payload']}")
|
||||
|
||||
|
||||
async def run(args) -> int:
|
||||
url = args.url
|
||||
token = args.token
|
||||
@@ -132,22 +224,38 @@ async def run(args) -> int:
|
||||
await ws.close()
|
||||
return 5
|
||||
|
||||
if not args.send:
|
||||
print("== paired OK (no --send; exiting)")
|
||||
if not args.send and not args.upload:
|
||||
print("== paired OK (no --send/--upload; exiting)")
|
||||
await ws.close()
|
||||
return 0
|
||||
|
||||
# M4: optional inbound upload before the turn.
|
||||
media_refs: list[str] = []
|
||||
next_id = 1
|
||||
if args.upload:
|
||||
media_ref = f"mu_probe_{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
next_id = await upload_file(ws, args.upload, media_ref, next_id)
|
||||
except Exception as e:
|
||||
print(f"!! upload failed: {e}")
|
||||
await ws.close()
|
||||
return 8
|
||||
media_refs.append(media_ref)
|
||||
|
||||
# Drive a turn.
|
||||
msg_id = 1
|
||||
msg_id = next_id
|
||||
send_payload: dict = {"text": args.send or ""}
|
||||
if media_refs:
|
||||
send_payload["media_refs"] = media_refs
|
||||
send_frame = {
|
||||
"v": 1,
|
||||
"id": msg_id,
|
||||
"type": "message.send",
|
||||
"chat_id": "android:default",
|
||||
"payload": {"text": args.send},
|
||||
"payload": send_payload,
|
||||
}
|
||||
await ws.send(json.dumps(send_frame))
|
||||
print(f" -> message.send id={msg_id} text={args.send!r}")
|
||||
print(f" -> message.send id={msg_id} text={args.send!r} media_refs={media_refs}")
|
||||
|
||||
deadline = time.time() + args.timeout
|
||||
got_final = False
|
||||
@@ -164,6 +272,16 @@ async def run(args) -> int:
|
||||
continue
|
||||
ftype = data.get("type")
|
||||
payload = data.get("payload") or {}
|
||||
# M4: fetch offered media live (outbound direction).
|
||||
if ftype == "media.offer" and args.pull_offer and payload.get("media_id"):
|
||||
try:
|
||||
next_id = await pull_media(
|
||||
ws, payload.get("media_id"), next_id, payload.get("size")
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"!! pull failed: {e}")
|
||||
await ws.close()
|
||||
return 9
|
||||
# A standalone assistant `message` (non-streaming) is immediately final.
|
||||
if ftype == "message" and payload.get("role") == "assistant":
|
||||
got_final = True
|
||||
@@ -189,6 +307,10 @@ def main() -> int:
|
||||
p.add_argument("--token", default=os.getenv("ANDROID_TOKEN", ""))
|
||||
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
|
||||
p.add_argument("--send", default="hello")
|
||||
p.add_argument("--upload", default="",
|
||||
help="M4: file to upload (chunked) and attach via media_refs")
|
||||
p.add_argument("--pull-offer", action="store_true",
|
||||
help="M4: pull any media.offer that arrives during the turn")
|
||||
p.add_argument("--timeout", type=float, default=120.0)
|
||||
p.add_argument("--authfail", action="store_true",
|
||||
help="expect an auth rejection (wrong token)")
|
||||
|
||||
@@ -39,6 +39,11 @@ logger = logging.getLogger(__name__)
|
||||
# How long a new socket may take to present its ``hello`` before we drop it.
|
||||
HELLO_TIMEOUT_S = 10.0
|
||||
|
||||
# Max time a single outbound send may block on a peer's full write buffer
|
||||
# before we give up on that peer (so one stalled client can't starve the
|
||||
# rest of the broadcast). The peer's own ping timeout reaps it afterwards.
|
||||
SEND_TIMEOUT_S = 10.0
|
||||
|
||||
# Close codes (4000-4999 are reserved for applications).
|
||||
CLOSE_AUTH_FAILED = 4401
|
||||
CLOSE_REPLACED = 4402
|
||||
@@ -138,16 +143,20 @@ class WsServer:
|
||||
def device_ids(self) -> list:
|
||||
return list(self._connections.keys())
|
||||
|
||||
def connection(self, device_id: str) -> Optional[DeviceConnection]:
|
||||
return self._connections.get(device_id)
|
||||
|
||||
# ── Outbound ──────────────────────────────────────────────────────────
|
||||
|
||||
async def broadcast(self, frame: protocol.Frame) -> int:
|
||||
"""Send a frame to every connected device. Returns devices reached.
|
||||
Best-effort: a dead socket is skipped (deregistered on its own close)."""
|
||||
Best-effort: a dead or stalled socket is skipped (deregistered on its
|
||||
own close) so one slow peer can't starve the others."""
|
||||
data = frame.to_json()
|
||||
sent = 0
|
||||
for conn in list(self._connections.values()):
|
||||
try:
|
||||
await conn.ws.send(data)
|
||||
await asyncio.wait_for(conn.ws.send(data), timeout=SEND_TIMEOUT_S)
|
||||
sent += 1
|
||||
except Exception:
|
||||
pass
|
||||
@@ -159,7 +168,7 @@ class WsServer:
|
||||
if conn is None:
|
||||
return False
|
||||
try:
|
||||
await conn.ws.send(frame.to_json())
|
||||
await asyncio.wait_for(conn.ws.send(frame.to_json()), timeout=SEND_TIMEOUT_S)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -262,14 +271,25 @@ class WsServer:
|
||||
current = self._connections.get(device_id)
|
||||
if current is not None and current.ws is ws:
|
||||
self._connections.pop(device_id, None)
|
||||
# M4: drop in-flight upload temp files for this socket.
|
||||
try:
|
||||
self._adapter.on_connection_closed(device_id)
|
||||
except Exception:
|
||||
logger.warning("android: connection cleanup failed for %s", device_id, exc_info=True)
|
||||
logger.info("android: device disconnected: %s", device_id)
|
||||
|
||||
# ── Inbound dispatch ──────────────────────────────────────────────────
|
||||
|
||||
async def _on_frame(self, ws: ServerConnection, device_id: str, raw: Any) -> None:
|
||||
# M4: binary frames are media upload chunks (raw bytes, no JSON
|
||||
# envelope). Route them to the active upload session.
|
||||
if isinstance(raw, (bytes, bytearray, memoryview)):
|
||||
await self._adapter.on_media_chunk(device_id, bytes(raw))
|
||||
return
|
||||
|
||||
frame = protocol.Frame.from_json(raw)
|
||||
if frame is None:
|
||||
return # malformed / unknown binary: ignore (forward-compat)
|
||||
return # malformed JSON: ignore (forward-compat)
|
||||
|
||||
if frame.type == protocol.TYPE_PING:
|
||||
ts = frame.payload.get("ts")
|
||||
@@ -290,6 +310,12 @@ class WsServer:
|
||||
await self._adapter.on_search(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_SYNC:
|
||||
await self._adapter.on_sync(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START:
|
||||
await self._adapter.on_media_upload_start(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END:
|
||||
await self._adapter.on_media_upload_end(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_MEDIA_PULL:
|
||||
await self._adapter.on_media_pull(frame, device_id)
|
||||
elif frame.type == "fcm.register":
|
||||
fcm_token = frame.payload.get("fcm_token")
|
||||
ntfy_topic = frame.payload.get("ntfy_topic")
|
||||
|
||||
Reference in new issue
Block a user