HTTP fallback leg (docs/19): POST /v1/frame + SSE /v1/events + long-poll
Second, short-lived-connection transport next to the WS: same frames, same outbox/cursor, same token, served over plain HTTP (stdlib ThreadingHTTPServer bridged into the asyncio loop; zero new deps). - http_server.py: /v1/health (unauthenticated), POST /v1/frame (accept-and-ack; validation rejections as 4xx error frames), SSE /v1/events (outbox catch-up with id=cursor, event: hello, 15s heartbeat, bounded-queue backpressure), long-poll /v1/poll (25s hold). Bearer token + X-Iris-Device (same allowlist as WS hello), 64 KiB body cap, per-device rate limit, optional TLS, non-fatal bind failure. - ws_server.py: inbound dispatch chain extracted to shared dispatch_frame() used by both transports. - adapter.py: ANDROID_HTTP_PORT/CERT/KEY config; start/stop next to the WS; delivery counting in _broadcast_or_log (an SSE subscriber is a live subscriber -> no push, docs/19 19.8); _reply() routes point-to-point replies into the in-flight HTTP response (reply sink) or broadcasts when the device has no live WS (19.7); status/typing/ channel events fan out to both transports. - ws_probe.py: --http mode (health + POST + SSE turn drive, same assertion flags); tests/README updated. - Tests: hermes-agent/tests/gateway/test_android_http.py (23 tests, incl. the 19.8 delivery-counting regression); test_android.py (74) still green.
This commit is contained in:
1 parent
524ed8ce53
commit
e5c7d690b8
7 files changed
+1315
-89
No files matched your search
+127
-49
@@ -62,6 +62,7 @@ import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
@@ -117,6 +118,7 @@ from . import protocol # noqa: E402
|
||||
from . import purge as purge_bridge # noqa: E402
|
||||
from . import search as search_bridge # noqa: E402
|
||||
from .channels import get_directory # noqa: E402
|
||||
from .http_server import HttpServer # noqa: E402
|
||||
from .outbox import Outbox # noqa: E402
|
||||
from .pairing import ( # noqa: E402
|
||||
DeviceRegistry,
|
||||
@@ -450,6 +452,7 @@ async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8790
|
||||
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
|
||||
DEFAULT_HOME_CHANNEL = "android:default"
|
||||
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
||||
DEFAULT_PUSH_BACKEND = "fcm"
|
||||
@@ -1090,6 +1093,10 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self.port = _parse_port(
|
||||
os.getenv("ANDROID_WS_PORT", "") or str(extra.get("port", DEFAULT_PORT))
|
||||
)
|
||||
# docs/19: HTTP fallback leg (same bind host as the WS; optional TLS).
|
||||
self.http_port = _parse_port(
|
||||
os.getenv("ANDROID_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
|
||||
)
|
||||
self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
||||
self.push_backend = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or extra.get(
|
||||
"push_backend", DEFAULT_PUSH_BACKEND
|
||||
@@ -1122,6 +1129,8 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# TLS (optional)
|
||||
self.ws_cert = _get_scoped_secret("ANDROID_WS_CERT") or extra.get("ws_cert", "")
|
||||
self.ws_key = _get_scoped_secret("ANDROID_WS_KEY") or extra.get("ws_key", "")
|
||||
self.http_cert = _get_scoped_secret("ANDROID_HTTP_CERT") or extra.get("http_cert", "")
|
||||
self.http_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "")
|
||||
|
||||
# Auth
|
||||
allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip()
|
||||
@@ -1133,6 +1142,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# Runtime state
|
||||
self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db")
|
||||
self._ws_server = WsServer(self, self._devices)
|
||||
# docs/19: HTTP fallback leg (inert until the app uses it; a bind
|
||||
# failure disables it without affecting the WS).
|
||||
self._http_server = HttpServer(self, self._devices)
|
||||
# docs/19 §19.7: reply sinks for in-flight HTTP requests — while a
|
||||
# POST /v1/frame is being dispatched, the handler's point-to-point
|
||||
# replies are captured here and returned as the HTTP response.
|
||||
self._http_reply_sinks: dict[str, tuple[queue.Queue, threading.Event]] = {}
|
||||
self._connected = False
|
||||
# M2: per-chat turn state for outbound frame classification.
|
||||
self._turns: dict[str, _TurnState] = {}
|
||||
@@ -1210,12 +1226,17 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self._connected = False
|
||||
return False
|
||||
|
||||
# docs/19: start the HTTP fallback leg next to the WS. Bind failure
|
||||
# is NON-fatal (unlike the WS): the plugin keeps working WS-only.
|
||||
await self._http_server.start()
|
||||
|
||||
# M5: announce gateway health to connected clients (none yet at
|
||||
# startup; the frame + plumbing exist for future transitions).
|
||||
# Reset in case this adapter instance previously went down (the
|
||||
# gateway may reconnect the same adapter after a fatal error).
|
||||
self._gateway_status = protocol.STATUS_ONLINE
|
||||
await self._ws_server.broadcast(protocol.status(self._gateway_status))
|
||||
await self._http_server.fanout(protocol.status(self._gateway_status), cursor=None)
|
||||
|
||||
# M3: ensure the default (home) channel exists in the directory so the
|
||||
# app's channel list and cron home delivery have a stable anchor.
|
||||
@@ -1248,6 +1269,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self._gateway_status = protocol.STATUS_RESTARTING
|
||||
with contextlib.suppress(Exception):
|
||||
await self._ws_server.broadcast(protocol.status(self._gateway_status))
|
||||
await self._http_server.fanout(protocol.status(self._gateway_status), cursor=None)
|
||||
with contextlib.suppress(ImportError):
|
||||
from gateway.status import release_scoped_lock
|
||||
|
||||
@@ -1258,6 +1280,10 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
await self._ws_server.stop()
|
||||
except Exception:
|
||||
logger.warning("android: WS server stop failed", exc_info=True)
|
||||
try:
|
||||
await self._http_server.stop()
|
||||
except Exception:
|
||||
logger.warning("android: HTTP server stop failed", exc_info=True)
|
||||
# Best-effort shutdown: a close failure on an already-closed store is
|
||||
# not actionable at disconnect time.
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -1642,6 +1668,50 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# (e.g. tool_progress off) so they can't leak into the next turn.
|
||||
_reset_tool_results()
|
||||
|
||||
# ── docs/19: HTTP-leg reply routing ───────────────────────────────────
|
||||
|
||||
def _http_register_sink(
|
||||
self, device_id: str, entry: tuple[queue.Queue, threading.Event]
|
||||
) -> None:
|
||||
self._http_reply_sinks[device_id] = entry
|
||||
|
||||
def _http_pop_sink(self, device_id: str) -> tuple[queue.Queue, threading.Event] | None:
|
||||
return self._http_reply_sinks.pop(device_id, None)
|
||||
|
||||
def _http_pop_sink_if(
|
||||
self, device_id: str, sink: queue.Queue
|
||||
) -> tuple[queue.Queue, threading.Event] | None:
|
||||
"""Pop the sink entry only if it is still ours (a newer request from
|
||||
the same device may have replaced it)."""
|
||||
entry = self._http_reply_sinks.get(device_id)
|
||||
if entry is None or entry[0] is not sink:
|
||||
return None
|
||||
return self._http_reply_sinks.pop(device_id, None)
|
||||
|
||||
async def _broadcast_both(self, frame: "protocol.Frame") -> None:
|
||||
"""Bare (non-outbox) broadcast to both transports (docs/19): the
|
||||
frame reaches WS devices and live SSE/long-poll subscribers."""
|
||||
await self._ws_server.broadcast(frame)
|
||||
await self._http_server.fanout(frame, cursor=None)
|
||||
|
||||
async def _reply(self, device_id: str, frame: "protocol.Frame") -> None:
|
||||
"""Point-to-point reply with HTTP-leg fallback (docs/19 §19.7).
|
||||
|
||||
WS-originated requests keep point-to-point delivery. For an
|
||||
in-flight HTTP request (a reply sink is registered) the frame goes
|
||||
into the HTTP response. If the device has no live WS and no sink
|
||||
(e.g. it dropped mid-request), the frame is broadcast so the SSE
|
||||
stream delivers it (single-user model).
|
||||
"""
|
||||
entry = self._http_reply_sinks.get(device_id)
|
||||
if entry is not None:
|
||||
entry[0].put(frame)
|
||||
return
|
||||
if await self._ws_server.send_to(device_id, frame):
|
||||
return
|
||||
await self._ws_server.broadcast(frame)
|
||||
await self._http_server.fanout(frame, cursor=None)
|
||||
|
||||
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
|
||||
delivered = await self._ws_server.broadcast(frame)
|
||||
# M3/M5: always append to the outbox so a reconnecting app can catch
|
||||
@@ -1656,6 +1726,10 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
except Exception:
|
||||
logger.warning("android: outbox append failed", exc_info=True)
|
||||
return
|
||||
# docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber
|
||||
# — count it in the delivery total or every message would push AND
|
||||
# stream to a device that is already receiving it.
|
||||
delivered += await self._http_server.fanout(frame, cursor)
|
||||
if delivered == 0:
|
||||
logger.info(
|
||||
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
||||
@@ -1819,11 +1893,15 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
tid = metadata.get("thread_id")
|
||||
if isinstance(tid, str) and tid:
|
||||
thread_id = tid
|
||||
await self._ws_server.broadcast(protocol.typing(chat_id, True, thread_id=thread_id))
|
||||
frame = protocol.typing(chat_id, True, thread_id=thread_id)
|
||||
await self._ws_server.broadcast(frame)
|
||||
await self._http_server.fanout(frame, cursor=None)
|
||||
|
||||
async def stop_typing(self, chat_id: str) -> None:
|
||||
"""Clear the typing indicator (``typing`` frame, on=false)."""
|
||||
await self._ws_server.broadcast(protocol.typing(chat_id, False))
|
||||
frame = protocol.typing(chat_id, False)
|
||||
await self._ws_server.broadcast(frame)
|
||||
await self._http_server.fanout(frame, cursor=None)
|
||||
|
||||
# ── M4: outbound media (agent -> app) ─────────────────────────────────
|
||||
#
|
||||
@@ -1959,7 +2037,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
if not text.strip() and not media_refs:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, "message.send requires non-empty text", id=frame.id
|
||||
@@ -1978,7 +2056,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# enforcement).
|
||||
target = self._channels.get(chat_id)
|
||||
if target is not None and target.get("automation"):
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED,
|
||||
@@ -2025,7 +2103,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
thread_id = entry["chat_id"]
|
||||
# Bare broadcast (like channel.create): the directory is
|
||||
# re-served on hello.ack, so no outbox entry is needed.
|
||||
await self._ws_server.broadcast(protocol.channel_created(entry, auto=True))
|
||||
await self._broadcast_both(protocol.channel_created(entry, auto=True))
|
||||
self._schedule_thread_title_upgrade(entry["chat_id"], text)
|
||||
|
||||
# M4: resolve media refs (single-use; unknown ref -> error).
|
||||
@@ -2035,7 +2113,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
for ref in media_refs:
|
||||
entry = self._media.get_inbound(ref)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, f"unknown media_ref {ref}", id=frame.id
|
||||
@@ -2120,7 +2198,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
await self.handle_message(event)
|
||||
# M5: acknowledge the user message to the originating device (the
|
||||
# app shows ✓✓) at the moment it is handed to the agent.
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.read_receipt(chat_id, message_id),
|
||||
)
|
||||
@@ -2157,7 +2235,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
return
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._ws_server.broadcast(protocol.channel_renamed(renamed)),
|
||||
self._broadcast_both(protocol.channel_renamed(renamed)),
|
||||
loop,
|
||||
)
|
||||
except Exception:
|
||||
@@ -2178,7 +2256,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
payload = frame.payload
|
||||
media_ref = str(payload.get("media_ref") or "").strip()
|
||||
if not media_ref or len(media_ref) > MAX_MEDIA_REF_LEN:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, "media.upload.start requires media_ref", id=frame.id
|
||||
@@ -2187,7 +2265,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
return
|
||||
kind = payload.get("kind")
|
||||
if kind not in media_bridge.KINDS:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, f"unsupported media kind {kind!r}", id=frame.id
|
||||
@@ -2202,7 +2280,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
except (TypeError, ValueError):
|
||||
size = -1
|
||||
if size <= 0:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED,
|
||||
@@ -2212,7 +2290,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return
|
||||
if size > self.max_upload_bytes:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_MEDIA_TOO_LARGE,
|
||||
@@ -2233,7 +2311,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
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))
|
||||
await self._reply(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.
|
||||
@@ -2244,7 +2322,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
return # stray binary frame: ignore (forward-compat)
|
||||
session.feed(chunk)
|
||||
if session.failed:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(session.error_code, session.error_message, id=session.request_id),
|
||||
)
|
||||
@@ -2255,7 +2333,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
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(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, "media.upload.end requires media_ref", id=frame.id
|
||||
@@ -2265,9 +2343,9 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
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))
|
||||
await self._reply(device_id, protocol.error(e.code, e.message, id=frame.id))
|
||||
return
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id, protocol.media_upload_ack(True, entry.media_id, id=frame.id)
|
||||
)
|
||||
|
||||
@@ -2276,7 +2354,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
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(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, f"unknown media_id {media_id!r}", id=frame.id
|
||||
@@ -2287,7 +2365,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# moved / been replaced since the offer).
|
||||
safe = validate_media_delivery_path(entry.path)
|
||||
if safe is None:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, "media no longer deliverable", id=frame.id),
|
||||
)
|
||||
@@ -2299,11 +2377,11 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
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(
|
||||
await self._reply(
|
||||
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))
|
||||
await self._reply(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."""
|
||||
@@ -2320,7 +2398,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
payload = frame.payload
|
||||
name = payload.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, "channel.create requires a name", id=frame.id
|
||||
@@ -2337,7 +2415,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
entry = self._channels.create(name=name, kind=kind, parent_chat_id=parent_chat_id)
|
||||
except ValueError as e:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
||||
)
|
||||
return
|
||||
@@ -2358,7 +2436,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.rename requires chat_id", id=frame.id
|
||||
@@ -2367,7 +2445,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
return
|
||||
name = frame.payload.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, "channel.rename requires a name", id=frame.id
|
||||
@@ -2377,12 +2455,12 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
entry = self._channels.rename(chat_id, name)
|
||||
except ValueError as e:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
||||
)
|
||||
return
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
@@ -2404,7 +2482,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.set_default requires chat_id", id=frame.id
|
||||
@@ -2413,7 +2491,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
return
|
||||
entry = self._channels.set_default(chat_id)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
@@ -2427,7 +2505,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.favorite requires chat_id", id=frame.id
|
||||
@@ -2437,7 +2515,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
on = bool(frame.payload.get("on"))
|
||||
entry = self._channels.set_favorite(chat_id, on)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
@@ -2451,7 +2529,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.icon requires chat_id", id=frame.id
|
||||
@@ -2465,14 +2543,14 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
color = color if isinstance(color, str) and color else None
|
||||
# Guard against a runaway base64 blob (a channel icon is small).
|
||||
if icon is not None and len(icon) > 512 * 1024:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "channel icon too large", id=frame.id),
|
||||
)
|
||||
return
|
||||
entry = self._channels.set_icon(chat_id, icon, color)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
@@ -2484,7 +2562,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
async def on_channel_set_automation(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.set_automation requires chat_id", id=frame.id
|
||||
@@ -2494,7 +2572,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
on = bool(frame.payload.get("on"))
|
||||
entry = self._channels.set_automation(chat_id, on)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND,
|
||||
@@ -2512,7 +2590,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.delete requires chat_id", id=frame.id
|
||||
@@ -2521,7 +2599,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
return
|
||||
entry = self._channels.delete(chat_id)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND,
|
||||
@@ -2570,7 +2648,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
channels = self._channels.list(include_archived=False)
|
||||
resp = protocol.channel_list(channels)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.send_to(device_id, resp)
|
||||
await self._reply(device_id, resp)
|
||||
|
||||
# ── Slash-command catalog (app's "/" drawer) ──────────────────────────
|
||||
|
||||
@@ -2581,7 +2659,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
the typed prefix client-side; the catalog is static per gateway run,
|
||||
so no caching is needed here."""
|
||||
resp = protocol.commands_catalog(_slash_command_catalog(), id=frame.id)
|
||||
await self._ws_server.send_to(device_id, resp)
|
||||
await self._reply(device_id, resp)
|
||||
|
||||
# ── M3: search (app -> agent) ─────────────────────────────────────────
|
||||
|
||||
@@ -2589,7 +2667,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
payload = frame.payload
|
||||
query = payload.get("query")
|
||||
if not isinstance(query, str) or not query.strip():
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "search requires a query", id=frame.id),
|
||||
)
|
||||
@@ -2612,7 +2690,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
db_path, query, scope=scope, chat_id=chat_id, thread_id=thread_id, limit=limit
|
||||
)
|
||||
resp = protocol.search_results(query, scope, hits, id=frame.id)
|
||||
await self._ws_server.send_to(device_id, resp)
|
||||
await self._reply(device_id, resp)
|
||||
|
||||
# ── M3: sync (reconnect catch-up) ─────────────────────────────────────
|
||||
|
||||
@@ -2639,9 +2717,9 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
cursor=e.get("cursor"),
|
||||
v=raw.get("v") if isinstance(raw.get("v"), int) else protocol.PROTOCOL_VERSION,
|
||||
)
|
||||
await self._ws_server.send_to(device_id, replayed)
|
||||
await self._reply(device_id, replayed)
|
||||
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
|
||||
await self._ws_server.send_to(device_id, done)
|
||||
await self._reply(device_id, done)
|
||||
|
||||
# ── Full message history (initial channel open / scroll-up) ───────────
|
||||
|
||||
@@ -2658,7 +2736,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
chat_id = frame.chat_id or payload.get("chat_id")
|
||||
logger.info("android: history request from %s chat_id=%r", device_id, chat_id)
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "history requires a chat_id", id=frame.id),
|
||||
)
|
||||
@@ -2689,7 +2767,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
oldest_message_id=page["oldest_message_id"],
|
||||
id=frame.id,
|
||||
)
|
||||
await self._ws_server.send_to(device_id, resp)
|
||||
await self._reply(device_id, resp)
|
||||
|
||||
# ── Message deletion (app -> agent) ───────────────────────────────────
|
||||
|
||||
@@ -2708,7 +2786,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
payload = frame.payload
|
||||
chat_id = frame.chat_id or payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "message.delete requires chat_id", id=frame.id
|
||||
@@ -2724,7 +2802,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
message_ids = [payload.get("message_id")] if payload.get("message_id") else []
|
||||
message_ids = [m for m in message_ids if isinstance(m, str) and m.strip()]
|
||||
if not message_ids:
|
||||
await self._ws_server.send_to(
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, "message.delete requires message_ids", id=frame.id
|
||||
@@ -2987,7 +3065,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
except Exception:
|
||||
logger.warning("android: create_handoff_thread failed", exc_info=True)
|
||||
return None
|
||||
await self._ws_server.broadcast(protocol.channel_created(entry))
|
||||
await self._broadcast_both(protocol.channel_created(entry))
|
||||
return entry["chat_id"]
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user