Co-authored-by: Jack Hunzicker <JackHunzicker@users.noreply.github.com>
775 lines
31 KiB
Python
775 lines
31 KiB
Python
"""Phone platform adapter (Hermes-Relay plugin).
|
|
|
|
Makes the paired Hermes-Relay phone a first-class Hermes *platform* — a
|
|
peer of Discord / Telegram / ntfy that the agent can **proactively push
|
|
to** via ``send_message(target="phone", ...)``, cron ``deliver=phone``,
|
|
and channel-directory presence.
|
|
|
|
This is an additive relay capability delivered through the upstream plugin
|
|
API (``ctx.register_platform``) — **NO fork, NO upstream core change.**
|
|
The upstream platform-plugin registry (``gateway/platform_registry.py``)
|
|
consults plugin-registered adapters first, and the ``Platform`` enum
|
|
auto-extends for unknown names via ``_missing_``. The registration shape
|
|
mirrors the bundled ntfy adapter (``plugins/platforms/ntfy/adapter.py``),
|
|
the canonical "push platform" template.
|
|
|
|
Delivery model (two-way):
|
|
Outbound (agent → phone): ``send()`` does NOT open a socket — it POSTs
|
|
**loopback** to the already-running relay server (``:8767``), exactly like
|
|
``plugin/tools/android_tool.py``. The relay's ProactiveChannel forwards the
|
|
message over the live phone WSS (the same connection that carries bridge
|
|
commands), and the app surfaces it as a notification / inbox entry /
|
|
session injection.
|
|
|
|
Inbound (phone → agent, Phase 2c): ``connect()`` starts a background loop
|
|
that long-polls the relay's loopback ``GET /phone/replies``. The relay and
|
|
this adapter run in *different processes*, so a reply the phone sends over
|
|
its WSS (``proactive.reply``) is buffered by the relay and drained here.
|
|
Each reply is turned into an inbound ``MessageEvent`` and dispatched via
|
|
``handle_message`` — exactly how the bundled platform adapters feed inbound
|
|
messages to the agent. The agent's answer rides the existing ``send()``
|
|
back to the phone, so the conversation continues in the same thread
|
|
(keyed by ``chat_id`` / ``reply_to`` carried on the original message).
|
|
|
|
"Is a phone actually reachable?" is answered per-send by the relay (HTTP
|
|
503 when no phone is subscribed), not at connect time.
|
|
|
|
Off by default:
|
|
Nothing is pushed unless ``PHONE_ENABLED`` is truthy **and** a phone is
|
|
paired + connected to the relay. ``check_fn`` enforces the env gate so
|
|
an un-configured install never advertises the platform.
|
|
|
|
Environment variables (env wins over config.yaml ``extra``):
|
|
PHONE_ENABLED "1"/"true"/"yes"/"on" enables the platform (required)
|
|
PHONE_RELAY_URL Relay base URL. Default: reuse ANDROID_BRIDGE_URL,
|
|
else http://127.0.0.1:{ANDROID_RELAY_PORT|RELAY_PORT|8767}
|
|
PHONE_RELAY_TOKEN Optional bearer for the relay POST (loopback is
|
|
unauthenticated by default; sent only if set)
|
|
PHONE_HOME_CHANNEL Default chat_id for cron / home-channel delivery
|
|
(default: "phone")
|
|
PHONE_HOME_CHANNEL_NAME Human label for the home channel (default: "Phone")
|
|
PHONE_ALLOWED_USERS Allowlist (gateway _is_user_authorized integration)
|
|
PHONE_ALLOW_ALL_USERS Allow any user — dev only
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import httpx
|
|
|
|
HTTPX_AVAILABLE = True
|
|
except ImportError: # pragma: no cover - httpx is a Hermes dependency
|
|
HTTPX_AVAILABLE = False
|
|
httpx = None # type: ignore[assignment]
|
|
|
|
# ``gateway.*`` is only importable inside the live hermes-agent (gateway)
|
|
# process. The plugin's own ``python -m unittest`` environment does not have
|
|
# it, so we guard the import and fall back to a minimal local ``SendResult``
|
|
# / sentinel base class. This keeps the pure helpers (URL resolution, payload
|
|
# building, the standalone sender) unit-testable without the gateway, while
|
|
# the real adapter binds to the real base class on the live box.
|
|
try:
|
|
from gateway.config import Platform, PlatformConfig # type: ignore
|
|
from gateway.platforms.base import ( # type: ignore
|
|
BasePlatformAdapter,
|
|
MessageEvent,
|
|
MessageType,
|
|
SendResult,
|
|
)
|
|
|
|
GATEWAY_AVAILABLE = True
|
|
except Exception: # pragma: no cover - exercised only off the live box
|
|
GATEWAY_AVAILABLE = False
|
|
Platform = None # type: ignore[assignment]
|
|
PlatformConfig = Any # type: ignore[assignment,misc]
|
|
BasePlatformAdapter = object # type: ignore[assignment,misc]
|
|
MessageEvent = None # type: ignore[assignment,misc]
|
|
MessageType = None # type: ignore[assignment,misc]
|
|
|
|
@dataclass
|
|
class SendResult: # type: ignore[no-redef]
|
|
"""Minimal stand-in mirroring the gateway's SendResult fields.
|
|
|
|
Only the fields the phone adapter actually returns are modelled.
|
|
Used solely when the gateway package is absent (tests).
|
|
"""
|
|
|
|
success: bool
|
|
message_id: Optional[str] = None
|
|
error: Optional[str] = None
|
|
retryable: bool = False
|
|
|
|
|
|
# Stable identity for the single paired phone "chat". A relay serves one
|
|
# phone at a time (per the bridge single-client model), so one chat_id is
|
|
# sufficient; the value is opaque and only used for routing/labels.
|
|
DEFAULT_CHAT_ID = "phone"
|
|
|
|
# Generous cap — the app can render long text in the inbox / injected
|
|
# session even though a system notification will visually truncate. Picked
|
|
# to match the ntfy/Telegram family so cross-platform agents behave
|
|
# consistently when a message is also fanned out elsewhere.
|
|
MAX_MESSAGE_LENGTH = 4096
|
|
|
|
# Truthy spellings accepted for boolean env flags.
|
|
_TRUTHY = ("1", "true", "yes", "on")
|
|
|
|
# Relay routes that the ProactiveChannel serves (see plugin/relay/server.py).
|
|
_RELAY_MESSAGE_PATH = "/phone/message" # outbound: agent → phone
|
|
_RELAY_REPLIES_PATH = "/phone/replies" # inbound long-poll: phone → agent
|
|
|
|
# Inbound reply long-poll tuning. The adapter asks the relay to hold the poll
|
|
# open for ``_REPLY_POLL_TIMEOUT`` seconds; the HTTP read timeout adds margin
|
|
# so a normal empty poll returns from the server, not a client timeout.
|
|
_REPLY_POLL_TIMEOUT = 25.0
|
|
_REPLY_READ_TIMEOUT = _REPLY_POLL_TIMEOUT + 10.0
|
|
# Backoff (seconds) when the relay is unreachable / erroring, so a down relay
|
|
# doesn't spin the loop. Resets to fast polling once a poll succeeds.
|
|
_REPLY_BACKOFF = (2.0, 5.0, 10.0, 30.0)
|
|
_MAX_METADATA_KEYS = 24
|
|
_MAX_METADATA_STRING_LENGTH = 512
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure helpers (no gateway dependency — unit-testable in isolation)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _phone_enabled() -> bool:
|
|
"""Return True when the operator has opted the phone platform in."""
|
|
return os.getenv("PHONE_ENABLED", "").strip().lower() in _TRUTHY
|
|
|
|
|
|
def _relay_base_url() -> str:
|
|
"""Resolve the loopback relay base URL.
|
|
|
|
Honors ``PHONE_RELAY_URL`` first, then reuses the same convention as
|
|
``plugin/tools/android_tool.py`` (``ANDROID_BRIDGE_URL`` /
|
|
``ANDROID_RELAY_PORT`` / ``RELAY_PORT``) so a single override flips both
|
|
the android tools and this adapter. Defaults to ``http://127.0.0.1:8767``.
|
|
"""
|
|
explicit = os.getenv("PHONE_RELAY_URL", "").strip()
|
|
if explicit:
|
|
return explicit.rstrip("/")
|
|
bridge = os.getenv("ANDROID_BRIDGE_URL", "").strip()
|
|
if bridge:
|
|
return bridge.rstrip("/")
|
|
port = os.getenv("ANDROID_RELAY_PORT", os.getenv("RELAY_PORT", "8767")).strip() or "8767"
|
|
return f"http://127.0.0.1:{port}"
|
|
|
|
|
|
def _home_channel() -> str:
|
|
"""chat_id used for cron / home-channel delivery."""
|
|
return os.getenv("PHONE_HOME_CHANNEL", "").strip() or DEFAULT_CHAT_ID
|
|
|
|
|
|
def _home_channel_name() -> str:
|
|
"""Human label for the home channel / default notification title."""
|
|
return os.getenv("PHONE_HOME_CHANNEL_NAME", "").strip() or "Phone"
|
|
|
|
|
|
def _relay_url_and_headers() -> tuple[str, Dict[str, str]]:
|
|
"""Return the ``/phone/message`` URL and request headers.
|
|
|
|
An ``Authorization`` header is only attached when ``PHONE_RELAY_TOKEN``
|
|
is set — the relay route is loopback-unauthenticated by default, exactly
|
|
like the bridge HTTP routes.
|
|
"""
|
|
headers = {"Content-Type": "application/json"}
|
|
token = os.getenv("PHONE_RELAY_TOKEN", "").strip()
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
return f"{_relay_base_url()}{_RELAY_MESSAGE_PATH}", headers
|
|
|
|
|
|
def _build_message_payload(
|
|
chat_id: Optional[str],
|
|
content: str,
|
|
*,
|
|
title: Optional[str] = None,
|
|
surfacing: Optional[str] = None,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Build the JSON body POSTed to the relay's ``/phone/message`` route.
|
|
|
|
``surfacing`` is an optional route hint (``"notification"`` /
|
|
``"inbox"`` / ``"session"``); ``None`` uses the app default (persist in
|
|
its Thread cache and notify). ``inbox`` is silent. ``session`` targets an
|
|
available active session/Thread and falls back to a notification. It can
|
|
also be supplied inside
|
|
``metadata["surfacing"]`` — send_message callers pass arbitrary
|
|
metadata, so we lift it out if present.
|
|
"""
|
|
md = dict(metadata or {})
|
|
surf = surfacing or md.pop("surfacing", None)
|
|
resolved_title = title or md.pop("title", None) or _home_channel_name()
|
|
return {
|
|
"chat_id": chat_id or _home_channel(),
|
|
"text": (content or "")[:MAX_MESSAGE_LENGTH],
|
|
"title": resolved_title,
|
|
"surfacing": surf,
|
|
"reply_to": reply_to,
|
|
"metadata": md,
|
|
}
|
|
|
|
|
|
def _replies_url_and_headers() -> tuple[str, Dict[str, str]]:
|
|
"""Return the ``/phone/replies`` long-poll URL and request headers.
|
|
|
|
GET has no body, so unlike :func:`_relay_url_and_headers` no
|
|
``Content-Type`` is set. ``Authorization`` is attached only when
|
|
``PHONE_RELAY_TOKEN`` is configured (loopback is unauthenticated by
|
|
default).
|
|
"""
|
|
headers: Dict[str, str] = {}
|
|
token = os.getenv("PHONE_RELAY_TOKEN", "").strip()
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
return f"{_relay_base_url()}{_RELAY_REPLIES_PATH}", headers
|
|
|
|
|
|
def _safe_metadata(value: Any) -> Dict[str, Any]:
|
|
"""Return a small JSON-safe metadata dict for normalized gateway events."""
|
|
if not isinstance(value, dict):
|
|
return {}
|
|
safe: Dict[str, Any] = {}
|
|
for raw_key, raw_value in value.items():
|
|
if len(safe) >= _MAX_METADATA_KEYS:
|
|
break
|
|
if not isinstance(raw_key, str) or not raw_key:
|
|
continue
|
|
key = raw_key[:80]
|
|
if isinstance(raw_value, str):
|
|
safe[key] = raw_value[:_MAX_METADATA_STRING_LENGTH]
|
|
elif isinstance(raw_value, (bool, int, float)) or raw_value is None:
|
|
safe[key] = raw_value
|
|
elif isinstance(raw_value, list):
|
|
items: List[Any] = []
|
|
for item in raw_value[:16]:
|
|
if isinstance(item, str):
|
|
items.append(item[:_MAX_METADATA_STRING_LENGTH])
|
|
elif isinstance(item, (bool, int, float)) or item is None:
|
|
items.append(item)
|
|
if items:
|
|
safe[key] = items
|
|
return safe
|
|
|
|
|
|
def _normalize_reply(
|
|
reply: Any, default_chat_id: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Normalize one raw reply record from ``/phone/replies`` for dispatch.
|
|
|
|
The relay already validates/normalizes on the way in, but the adapter is
|
|
defensive against a malformed or older relay: a non-dict or empty-text
|
|
record yields ``None`` (skipped). ``chat_id`` falls back to the home
|
|
channel so a reply with no thread still continues the default
|
|
conversation; ``message_id`` is synthesized when absent. Pure + gateway-
|
|
independent so it is unit-testable without the gateway package.
|
|
"""
|
|
if not isinstance(reply, dict):
|
|
return None
|
|
text = reply.get("text")
|
|
if not isinstance(text, str) or not text.strip():
|
|
return None
|
|
return {
|
|
"text": text,
|
|
"chat_id": reply.get("chat_id") or default_chat_id,
|
|
"reply_to": reply.get("reply_to"),
|
|
"message_id": str(reply.get("message_id") or uuid.uuid4().hex[:12]),
|
|
"metadata": _safe_metadata(reply.get("metadata")),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class PhoneAdapter(BasePlatformAdapter): # type: ignore[misc,valid-type]
|
|
"""Two-way platform adapter for the paired Hermes-Relay phone.
|
|
|
|
The four abstract methods of :class:`BasePlatformAdapter` are
|
|
implemented; everything else uses the base defaults. ``send()`` forwards
|
|
to the relay over loopback (outbound); ``connect()`` additionally starts
|
|
a background long-poll loop that drains the user's replies from the relay
|
|
and dispatches them inbound via ``handle_message`` (Phase 2c).
|
|
"""
|
|
|
|
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
|
|
|
|
def __init__(self, config: "PlatformConfig"):
|
|
super().__init__(config=config, platform=Platform("phone"))
|
|
extra = getattr(config, "extra", {}) or {}
|
|
# Cached for get_chat_info(); send-time URL/title always re-read env
|
|
# so a live `.env` edit takes effect without a reconnect.
|
|
self._home_channel_name: str = extra.get("home_channel_name") or _home_channel_name()
|
|
self._http_client: Optional["httpx.AsyncClient"] = None
|
|
# Inbound reply long-poll task (Phase 2c). Spawned in connect(),
|
|
# cancelled in disconnect().
|
|
self._poll_task: Optional["asyncio.Task[Any]"] = None
|
|
|
|
# -- Connection lifecycle ----------------------------------------------
|
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
"""Open the outbound HTTP client and start the inbound reply loop.
|
|
|
|
Outbound is loopback POSTs (no socket held). Inbound is a background
|
|
long-poll against the relay's ``/phone/replies`` — the phone's replies
|
|
are buffered by the relay (different process) and drained here.
|
|
|
|
The gateway's platform supervisor invokes ``connect(is_reconnect=...)``
|
|
(the ``BasePlatformAdapter.connect`` contract, forwarded from
|
|
``gateway/run.py``), so the keyword MUST be accepted. A bare
|
|
``connect(self)`` raises ``TypeError`` at connect time — the adapter
|
|
never comes up and the inbound reply loop never starts, so the relay
|
|
buffers replies that nothing drains (the Phase 2c round-trip silently
|
|
dies here). ``is_reconnect`` does not change behavior today: we always
|
|
drain the relay's small bounded reply buffer. Using it to drop stale
|
|
replies on a cold boot is a possible future refinement.
|
|
"""
|
|
if not HTTPX_AVAILABLE:
|
|
logger.warning("[%s] httpx not installed — cannot push to phone", self.name)
|
|
return False
|
|
if not _phone_enabled():
|
|
logger.info("[%s] PHONE_ENABLED not set — platform stays offline", self.name)
|
|
return False
|
|
try:
|
|
self._http_client = httpx.AsyncClient(timeout=15.0)
|
|
self._mark_connected()
|
|
self._poll_task = asyncio.create_task(self._run_reply_loop())
|
|
logger.info(
|
|
"[%s] Connected (two-way) — relay=%s", self.name, _relay_base_url()
|
|
)
|
|
return True
|
|
except Exception as e: # pragma: no cover - defensive
|
|
logger.error("[%s] Failed to connect: %s", self.name, e)
|
|
return False
|
|
|
|
async def disconnect(self) -> None:
|
|
"""Stop the reply loop and tear down the HTTP client."""
|
|
self._running = False
|
|
self._mark_disconnected()
|
|
if self._poll_task is not None:
|
|
self._poll_task.cancel()
|
|
try:
|
|
await self._poll_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception: # pragma: no cover - defensive
|
|
pass
|
|
self._poll_task = None
|
|
if self._http_client is not None:
|
|
await self._http_client.aclose()
|
|
self._http_client = None
|
|
logger.info("[%s] Disconnected", self.name)
|
|
|
|
# -- Inbound reply loop (phone → agent) --------------------------------
|
|
|
|
async def _run_reply_loop(self) -> None:
|
|
"""Long-poll the relay for phone replies and dispatch them inbound.
|
|
|
|
Mirrors the streaming-receive loop pattern of the bundled adapters
|
|
(e.g. ntfy ``_run_stream``): a ``self._running``-guarded loop with
|
|
backoff on transient failures. Each drained reply becomes a
|
|
``MessageEvent`` handed to ``handle_message`` — the agent then
|
|
processes it on the ``phone`` platform and answers via ``send()``.
|
|
"""
|
|
url, headers = _replies_url_and_headers()
|
|
params = {"timeout": str(int(_REPLY_POLL_TIMEOUT))}
|
|
backoff_idx = 0
|
|
|
|
while self._running:
|
|
client = self._http_client
|
|
if client is None:
|
|
return
|
|
try:
|
|
resp = await client.get(
|
|
url, headers=headers, params=params, timeout=_REPLY_READ_TIMEOUT
|
|
)
|
|
except asyncio.CancelledError:
|
|
return
|
|
except Exception as e:
|
|
if not self._running:
|
|
return
|
|
delay = _REPLY_BACKOFF[min(backoff_idx, len(_REPLY_BACKOFF) - 1)]
|
|
logger.debug(
|
|
"[%s] reply poll failed: %s — retrying in %.0fs",
|
|
self.name, e, delay,
|
|
)
|
|
try:
|
|
await asyncio.sleep(delay)
|
|
except asyncio.CancelledError:
|
|
return
|
|
backoff_idx += 1
|
|
continue
|
|
|
|
# A successful poll resets backoff; a non-200 is transient — pause
|
|
# briefly then re-poll rather than hammering.
|
|
backoff_idx = 0
|
|
if resp.status_code != 200:
|
|
logger.debug("[%s] /phone/replies HTTP %d", self.name, resp.status_code)
|
|
try:
|
|
await asyncio.sleep(_REPLY_BACKOFF[0])
|
|
except asyncio.CancelledError:
|
|
return
|
|
continue
|
|
|
|
try:
|
|
data = resp.json()
|
|
replies = data.get("replies", []) if isinstance(data, dict) else []
|
|
except Exception:
|
|
replies = []
|
|
|
|
for reply in replies:
|
|
if not self._running:
|
|
return
|
|
try:
|
|
await self._dispatch_reply(reply)
|
|
except Exception as e:
|
|
logger.warning("[%s] failed to dispatch reply: %s", self.name, e)
|
|
|
|
async def _dispatch_reply(self, reply: Dict[str, Any]) -> None:
|
|
"""Turn one relay reply record into an inbound agent message.
|
|
|
|
The reply continues the originating conversation: ``chat_id`` keys the
|
|
session and ``reply_to`` anchors it to the answered message. The phone
|
|
is the single paired user already authenticated by the relay's pairing
|
|
layer, so the source is built ``role_authorized=True`` — it bypasses
|
|
the gateway's per-platform user allowlist without requiring the
|
|
operator to set ``PHONE_ALLOW_ALL_USERS`` (the relay session token IS
|
|
the auth check the gateway is asking about).
|
|
"""
|
|
normalized = _normalize_reply(reply, _home_channel())
|
|
if normalized is None:
|
|
return
|
|
chat_id = normalized["chat_id"]
|
|
source = self.build_source(
|
|
chat_id=chat_id,
|
|
chat_name=self._home_channel_name,
|
|
chat_type="dm",
|
|
user_id=chat_id,
|
|
user_name=self._home_channel_name,
|
|
message_id=normalized["reply_to"],
|
|
role_authorized=True,
|
|
)
|
|
event_kwargs = {
|
|
"text": normalized["text"],
|
|
"message_type": MessageType.TEXT,
|
|
"source": source,
|
|
"message_id": normalized["message_id"],
|
|
"reply_to_message_id": normalized["reply_to"],
|
|
"raw_message": reply,
|
|
"metadata": normalized["metadata"],
|
|
}
|
|
try:
|
|
event = MessageEvent(**event_kwargs)
|
|
except TypeError:
|
|
# Older upstream MessageEvent builds did not expose metadata yet.
|
|
event_kwargs.pop("metadata", None)
|
|
event = MessageEvent(**event_kwargs)
|
|
logger.info(
|
|
"[%s] inbound reply chat=%s reply_to=%s len=%d",
|
|
self.name, chat_id, normalized["reply_to"], len(normalized["text"]),
|
|
)
|
|
await self.handle_message(event)
|
|
|
|
# -- Outbound messaging -------------------------------------------------
|
|
|
|
async def send(
|
|
self,
|
|
chat_id: str,
|
|
content: str,
|
|
reply_to: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
) -> "SendResult":
|
|
"""Forward a proactive message to the phone via the relay."""
|
|
if not content or not content.strip():
|
|
return SendResult(success=False, error="Refusing to send empty message")
|
|
|
|
client = self._http_client
|
|
if client is None:
|
|
return SendResult(
|
|
success=False, error="HTTP client not initialized", retryable=True
|
|
)
|
|
|
|
payload = _build_message_payload(
|
|
chat_id, content, reply_to=reply_to, metadata=metadata
|
|
)
|
|
url, headers = _relay_url_and_headers()
|
|
|
|
try:
|
|
resp = await client.post(url, json=payload, headers=headers, timeout=15.0)
|
|
except Exception as e:
|
|
logger.warning("[%s] relay POST failed: %s", self.name, e)
|
|
return SendResult(
|
|
success=False, error=f"relay unreachable: {e}", retryable=True
|
|
)
|
|
|
|
if resp.status_code == 503:
|
|
return SendResult(
|
|
success=False,
|
|
error="No phone connected. Open the Hermes app on your phone and connect.",
|
|
retryable=True,
|
|
)
|
|
if resp.status_code >= 300:
|
|
body = resp.text[:200]
|
|
logger.warning("[%s] send failed HTTP %d: %s", self.name, resp.status_code, body)
|
|
return SendResult(success=False, error=f"relay HTTP {resp.status_code}: {body}")
|
|
|
|
message_id = None
|
|
try:
|
|
data = resp.json()
|
|
if isinstance(data, dict):
|
|
message_id = data.get("message_id")
|
|
except Exception:
|
|
pass
|
|
return SendResult(success=True, message_id=message_id or uuid.uuid4().hex[:12])
|
|
|
|
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
|
"""The phone has no typing-indicator primitive for proactive pushes."""
|
|
return None
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
"""Return basic info about the phone "chat"."""
|
|
return {"name": self._home_channel_name or "Phone", "type": "dm"}
|
|
|
|
async def list_channels(self) -> List[Dict[str, str]]:
|
|
"""Enumerate the adapter's single canonical phone destination.
|
|
|
|
The upstream channel directory calls this optional adapter hook during
|
|
its normal startup and periodic rebuilds. Returning the configured home
|
|
channel here makes the phone discoverable to ``send_message`` listings
|
|
without creating a Relay-owned target registry or relying on historical
|
|
sessions to have been written first.
|
|
"""
|
|
return [
|
|
{
|
|
"id": _home_channel(),
|
|
"name": _home_channel_name(),
|
|
"type": "dm",
|
|
}
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry hooks (mirror ntfy)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def check_requirements() -> bool:
|
|
"""Return True when the phone platform is installable + opted in.
|
|
|
|
Reads env directly to stay cheap (no full config load). httpx is a hard
|
|
requirement for the outbound POST.
|
|
"""
|
|
return HTTPX_AVAILABLE and _phone_enabled()
|
|
|
|
|
|
def validate_config(config) -> bool:
|
|
"""Validate that the phone platform is enabled (env or config.yaml)."""
|
|
if _phone_enabled():
|
|
return True
|
|
extra = getattr(config, "extra", {}) or {}
|
|
return bool(extra.get("enabled"))
|
|
|
|
|
|
def is_connected(config) -> bool:
|
|
"""Reflect env-only configuration in ``hermes gateway status``."""
|
|
return _phone_enabled()
|
|
|
|
|
|
def _env_enablement() -> Optional[dict]:
|
|
"""Seed ``PlatformConfig.extra`` from env during gateway config load.
|
|
|
|
Returns ``None`` when the platform isn't opted in so the registry skips
|
|
auto-enabling. The special ``home_channel`` key is promoted to a proper
|
|
``HomeChannel`` by the core hook (same contract ntfy relies on).
|
|
"""
|
|
if not _phone_enabled():
|
|
return None
|
|
seed: dict = {
|
|
"enabled": True,
|
|
"relay_url": _relay_base_url(),
|
|
"home_channel_name": _home_channel_name(),
|
|
"typing_indicator": False,
|
|
}
|
|
home = _home_channel()
|
|
seed["home_channel"] = {"chat_id": home, "name": _home_channel_name()}
|
|
return seed
|
|
|
|
|
|
def parse_target_ref(target_ref: str) -> Optional[tuple[str, Optional[str]]]:
|
|
"""Resolve only the single canonical Phone destination."""
|
|
if not isinstance(target_ref, str):
|
|
return None
|
|
candidate = target_ref.strip()
|
|
canonical = _home_channel()
|
|
if candidate == canonical:
|
|
return canonical, None
|
|
return None
|
|
|
|
|
|
def validate_target_ref(chat_id: str) -> bool | str:
|
|
"""Accept only the configured/default canonical Phone chat ID."""
|
|
canonical = _home_channel()
|
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
|
return "Phone target cannot be empty"
|
|
if chat_id != canonical:
|
|
return f"Phone target must be the configured home channel '{canonical}'"
|
|
return True
|
|
|
|
|
|
def _supports_typed_target_hooks(ctx: Any) -> bool:
|
|
"""Return whether this Hermes host accepts the typed-target kwargs.
|
|
|
|
Current plugin contexts forward arbitrary keywords into ``PlatformEntry``;
|
|
older contexts may do the same even though their dataclass does not define
|
|
these fields. Prefer inspecting that destination contract. The signature
|
|
fallback supports explicit host/test shims without treating ``**kwargs``
|
|
alone as proof of support.
|
|
"""
|
|
try:
|
|
from gateway.platform_registry import PlatformEntry # type: ignore
|
|
|
|
fields = getattr(PlatformEntry, "__dataclass_fields__", {})
|
|
return all(
|
|
name in fields
|
|
for name in ("parse_target_ref_fn", "validate_target_ref_fn")
|
|
)
|
|
except (ImportError, AttributeError):
|
|
try:
|
|
parameters = inspect.signature(ctx.register_platform).parameters
|
|
except (TypeError, ValueError, AttributeError):
|
|
return False
|
|
return all(
|
|
name in parameters
|
|
for name in ("parse_target_ref_fn", "validate_target_ref_fn")
|
|
)
|
|
|
|
|
|
async def _standalone_send(
|
|
pconfig,
|
|
chat_id: str,
|
|
message: str,
|
|
*,
|
|
thread_id: Optional[str] = None,
|
|
media_files: Optional[List[str]] = None,
|
|
force_document: bool = False,
|
|
) -> Dict[str, Any]:
|
|
"""Out-of-process push for cron / send_message_tool fallbacks.
|
|
|
|
Used by ``tools/send_message_tool`` and the cron scheduler when the
|
|
gateway runner is not in this process (e.g. ``hermes cron`` running
|
|
standalone). Without this hook, ``deliver=phone`` cron jobs fail with
|
|
"No live adapter for platform".
|
|
|
|
``thread_id`` / ``media_files`` / ``force_document`` are accepted for
|
|
signature parity; the proactive push is text-first in v1.
|
|
"""
|
|
if not HTTPX_AVAILABLE:
|
|
return {"error": "phone standalone send: httpx not installed"}
|
|
if not _phone_enabled():
|
|
return {"error": "phone platform disabled (set PHONE_ENABLED=1)"}
|
|
|
|
payload = _build_message_payload(chat_id, message or "")
|
|
url, headers = _relay_url_and_headers()
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
resp = await client.post(url, json=payload, headers=headers)
|
|
except Exception as e:
|
|
return {"error": f"phone standalone send failed: relay unreachable: {e}"}
|
|
|
|
if resp.status_code == 503:
|
|
return {"error": "phone standalone send: no phone connected"}
|
|
if resp.status_code >= 300:
|
|
return {"error": f"phone HTTP {resp.status_code}: {resp.text[:200]}"}
|
|
|
|
message_id = None
|
|
try:
|
|
data = resp.json()
|
|
if isinstance(data, dict):
|
|
message_id = data.get("message_id")
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"success": True,
|
|
"platform": "phone",
|
|
"chat_id": payload["chat_id"],
|
|
"message_id": message_id or uuid.uuid4().hex[:12],
|
|
}
|
|
|
|
|
|
def register_phone_platform(ctx) -> None:
|
|
"""Register the ``phone`` platform with the Hermes plugin system.
|
|
|
|
Called from ``plugin/__init__.py:register`` (guarded). Safe to call on
|
|
hosts without ``register_platform`` — the caller swallows AttributeError.
|
|
"""
|
|
# The phone is a single paired device, so its home channel is
|
|
# unambiguous — there is exactly one logical destination, unlike
|
|
# Telegram/Discord where the operator must pick *which* chat is "home".
|
|
# Upstream's first-message onboarding nudge (``gateway/run.py`` — "📬 No
|
|
# home channel is set for Phone … /sethome") checks the env var directly
|
|
# (``os.getenv("PHONE_HOME_CHANNEL")``), not our seeded config, so without
|
|
# this it fires on the first message of every new Thread. Pre-fill the only
|
|
# sensible value — exactly what ``/sethome`` would persist — while
|
|
# respecting an explicit (non-blank) operator override.
|
|
if _phone_enabled() and not os.environ.get("PHONE_HOME_CHANNEL", "").strip():
|
|
os.environ["PHONE_HOME_CHANNEL"] = DEFAULT_CHAT_ID
|
|
|
|
registration_kwargs: Dict[str, Any] = {
|
|
"name": "phone",
|
|
"label": "Phone",
|
|
"adapter_factory": lambda cfg: PhoneAdapter(cfg),
|
|
"check_fn": check_requirements,
|
|
"validate_config": validate_config,
|
|
"is_connected": is_connected,
|
|
"required_env": ["PHONE_ENABLED"],
|
|
"install_hint": (
|
|
"Set PHONE_ENABLED=1 in ~/.hermes/.env, run the relay, and pair "
|
|
"the Hermes-Relay app with 'Let Hermes message me' enabled."
|
|
),
|
|
"env_enablement_fn": _env_enablement,
|
|
# Cron home-channel delivery — `deliver=phone` routes to
|
|
# PHONE_HOME_CHANNEL when set.
|
|
"cron_deliver_env_var": "PHONE_HOME_CHANNEL",
|
|
# Out-of-process cron / send_message delivery. Without this hook,
|
|
# deliver=phone cron jobs fail with "No live adapter".
|
|
"standalone_sender_fn": _standalone_send,
|
|
# Auth env vars for _is_user_authorized() integration.
|
|
"allowed_users_env": "PHONE_ALLOWED_USERS",
|
|
"allow_all_env": "PHONE_ALLOW_ALL_USERS",
|
|
"max_message_length": MAX_MESSAGE_LENGTH,
|
|
"emoji": "📱",
|
|
# No publisher-controlled identity to redact — the phone is the
|
|
# paired single user.
|
|
"pii_safe": True,
|
|
"allow_update_command": True,
|
|
"platform_hint": (
|
|
"You are messaging the user's paired phone via Hermes-Relay. This "
|
|
"is a proactive push channel — the user may not be looking at the "
|
|
"screen. Keep messages concise and high-signal; lead with what "
|
|
"matters. Plain text and light markdown render fine."
|
|
),
|
|
}
|
|
if _supports_typed_target_hooks(ctx):
|
|
registration_kwargs.update(
|
|
parse_target_ref_fn=parse_target_ref,
|
|
validate_target_ref_fn=validate_target_ref,
|
|
)
|
|
|
|
ctx.register_platform(**registration_kwargs)
|
|
logger.info("Registered 'phone' platform (proactive push via relay)")
|