"""nmbr platform adapter for Hermes Agent (plugin). Give your Hermes agent a number: it gets an ``800-xxx-xxx`` nmbr, and you talk to it from the nmbr app on your phone. Inbound messages arrive over the Agent API's long-poll endpoint (works from a laptop or Mac mini behind NAT — no public URL), replies and typing go out over the same API. Only httpx, which Hermes already depends on. Install:: ~/.hermes/plugins/nmbr/{plugin.yaml,adapter.py} (see install.sh) hermes gateway setup # or set NMBR_AGENT_TOKEN and NMBR_ALLOWED_USERS Configuration (config.yaml):: platforms: nmbr: enabled: true extra: token: "agent:…" # or NMBR_AGENT_TOKEN base_url: "https://nmbr.ai/api" home_channel: { chat_id: "123-456-789", name: "Ahmad" } Environment (env wins over config.yaml ``extra``):: NMBR_AGENT_TOKEN agent token (required) NMBR_BASE_URL API base (default https://nmbr.ai/api) NMBR_ALLOWED_USERS comma-separated nmbrs allowed to talk to the agent NMBR_ALLOW_ALL_USERS true = every contact who added the agent may talk NMBR_HOME_CHANNEL nmbr or conversation id for cron delivery NMBR_HOME_CHANNEL_NAME label for the home channel Identity model: nmbr authenticates every sender; ``user_id`` is the sender's nmbr, so ``NMBR_ALLOWED_USERS`` is a list of nmbrs. nmbr itself only lets people who ADDED the agent as a contact message it at all. Approvals: Hermes' text fallback applies — dangerous-command prompts arrive as a message in the nmbr chat and you reply ``/approve`` / ``/always`` / ``/cancel``. Native nmbr approval cards are a later version. """ from __future__ import annotations import asyncio import logging import os import re import time from datetime import datetime, timezone from typing import Any, Dict, List, Optional try: import httpx HTTPX_AVAILABLE = True except ImportError: # pragma: no cover - Hermes always ships httpx HTTPX_AVAILABLE = False httpx = None # type: ignore[assignment] from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, MessageType, SendResult, ) try: from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError from agent.secret_scope import get_secret as _scoped_get_secret except ImportError: # pragma: no cover - older Hermes _UnscopedSecretError = Exception # type: ignore[assignment,misc] def _scoped_get_secret(name, default=None): # type: ignore[no-redef] return os.getenv(name, default) def _get_scoped_secret(name: str, default: Optional[str] = None) -> Optional[str]: """Profile-scope-aware secret read with the default-profile env fallback.""" try: val = _scoped_get_secret(name, default) except _UnscopedSecretError: val = os.getenv(name) return val if val is not None else default logger = logging.getLogger(__name__) PLATFORM_NAME = "nmbr" DEFAULT_BASE_URL = "https://nmbr.ai/api" MAX_MESSAGE_LENGTH = 10000 # nmbr text message limit LONG_POLL_WAIT_SECONDS = 25 # server caps at 25 RECONNECT_BACKOFF = [1, 2, 5, 10, 30] TYPING_MIN_INTERVAL_SECONDS = 4.0 # Hermes pings every 2 s; nmbr needs far less DEDUP_MAX_SIZE = 2000 _NMBR_RE = re.compile(r"^\d{3}-\d{3}-\d{3}$") class _FatalError(Exception): """Unrecoverable (token revoked, owner suspended, …): stop polling.""" def normalize_nmbr(raw: Any) -> Optional[str]: """``123456789`` / ``nmbr:123-456-789`` / ``123-456-789`` → ``123-456-789``; else None.""" if raw is None: return None digits = re.sub(r"\D", "", str(raw).strip().removeprefix("nmbr:")) if len(digits) != 9: return None return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}" def _target(chat_id: str) -> Dict[str, str]: """A send target is a nmbr (``to``) or a conversation id (``conversationId``).""" n = normalize_nmbr(chat_id) return {"to": n} if n else {"conversationId": str(chat_id).strip()} def describe_message(m: Dict[str, Any]) -> str: """What the agent sees for a message; non-text types are tagged.""" content = (m.get("content") or "").strip() t = m.get("type") or "text" if t == "text": return content if t == "voice": tr = (m.get("transcript") or "").strip() return f"[voice note] {tr}" if tr else "[voice note — no transcript]" if t == "image": return f"[image] {content}" if content else "[image]" if t == "video": return f"[video] {content}" if content else "[video]" if t == "document": name = (m.get("document") or {}).get("name") head = f"[document: {name}]" if name else "[document]" return f"{head} {content}" if content else head if t == "location": loc = m.get("location") or {} head = f"[location {loc.get('latitude')},{loc.get('longitude')}]" return f"{head} {content}" if content else head if t == "contact": sc = m.get("sharedContact") or {} head = f"[shared contact {sc.get('nmbr', '')}]".replace(" ]", "]") return f"{head} {content}" if content else head return content or f"[{t}]" def _resolve_token(extra: Dict[str, Any]) -> str: return (_get_scoped_secret("NMBR_AGENT_TOKEN", "") or extra.get("token") or "").strip() def _resolve_base_url(extra: Dict[str, Any]) -> str: return (os.getenv("NMBR_BASE_URL", "").strip() or extra.get("base_url") or DEFAULT_BASE_URL).rstrip("/") def check_requirements() -> bool: """Installable and minimally configured? (cheap: env only)""" return HTTPX_AVAILABLE and bool(_get_scoped_secret("NMBR_AGENT_TOKEN", "").strip()) def validate_config(config) -> bool: extra = getattr(config, "extra", {}) or {} return bool(_resolve_token(extra)) def is_connected(config) -> bool: extra = getattr(config, "extra", {}) or {} return bool(_resolve_token(extra)) class NmbrAdapter(BasePlatformAdapter): """Long-poll the nmbr Agent API; reply over it.""" MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH def __init__(self, config: PlatformConfig): super().__init__(config=config, platform=Platform(PLATFORM_NAME)) extra = config.extra or {} self._token: str = _resolve_token(extra) self._base_url: str = _resolve_base_url(extra) self._client: Optional["httpx.AsyncClient"] = None self._poll_task: Optional[asyncio.Task] = None self._seq: int = 0 self._me: Dict[str, Any] = {} self._seen: Dict[str, float] = {} self._last_typing: Dict[str, float] = {} # -- HTTP ---------------------------------------------------------------- def _headers(self) -> Dict[str, str]: return { "Authorization": f"Bearer {self._token}", "Content-Type": "application/json", "Accept": "application/json", "User-Agent": "hermes-nmbr/0.1", } async def _api(self, method: str, path: str, *, json: Any = None, params: Any = None, timeout: float = 15.0) -> Any: assert self._client is not None resp = await self._client.request(method, f"{self._base_url}{path}", json=json, params=params, headers=self._headers(), timeout=timeout) if resp.status_code >= 400: try: err = (resp.json() or {}).get("error") or {} except Exception: err = {} code = err.get("code") or f"http_{resp.status_code}" msg = err.get("message") or resp.text[:200] if resp.status_code in (401, 403): raise _FatalError(f"{code}: {msg}") raise RuntimeError(f"{code}: {msg}") if resp.status_code == 204 or not resp.content: return None return resp.json() # -- Lifecycle ----------------------------------------------------------- async def connect(self, *, is_reconnect: bool = False) -> bool: if not HTTPX_AVAILABLE: logger.warning("[%s] httpx not installed", self.name) return False if not self._token: logger.warning("[%s] NMBR_AGENT_TOKEN not configured — create an agent in the nmbr app and paste its token", self.name) return False self._client = httpx.AsyncClient(timeout=None) try: self._me = await self._api("GET", "/agent/v1/me") or {} if not is_reconnect: cursor = await self._api("GET", "/agent/v1/updates/cursor") or {} self._seq = int(cursor.get("seq") or 0) except _FatalError as e: logger.error("[%s] Agent API refused the token: %s", self.name, e) self._set_fatal_error("nmbr_unauthorized", f"nmbr rejected the agent token ({e}). Check NMBR_AGENT_TOKEN.", retryable=False) await self._client.aclose() self._client = None return False except Exception as e: logger.error("[%s] Cannot reach the Agent API at %s: %s", self.name, self._base_url, e) await self._client.aclose() self._client = None return False self._poll_task = asyncio.create_task(self._poll_loop()) self._mark_connected() logger.info("[%s] Connected as %s (%s); long-polling from seq %d", self.name, self._me.get("displayName") or "agent", self._me.get("nmbr"), self._seq) try: self._wire_plugin_handlers(None) except Exception: # pragma: no cover - older Hermes pass return True async def disconnect(self) -> None: self._running = False self._mark_disconnected() if self._poll_task: self._poll_task.cancel() try: await self._poll_task except (asyncio.CancelledError, Exception): pass self._poll_task = None if self._client: await self._client.aclose() self._client = None self._seen.clear() logger.info("[%s] Disconnected", self.name) async def _poll_loop(self) -> None: backoff_idx = 0 while self._running: started = time.monotonic() try: page = await self._api( "GET", "/agent/v1/updates", params={"afterSeq": self._seq, "wait": LONG_POLL_WAIT_SECONDS, "limit": 100}, timeout=LONG_POLL_WAIT_SECONDS + 15, ) backoff_idx = 0 except asyncio.CancelledError: return except _FatalError as e: logger.error("[%s] Stopping: %s", self.name, e) self._set_fatal_error("nmbr_unauthorized", f"nmbr Agent API: {e}", retryable=False) return except Exception as e: if not self._running: return delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] backoff_idx += 1 logger.warning("[%s] Long-poll error (%s); retrying in %ds", self.name, e, delay) await asyncio.sleep(delay) continue events = (page or {}).get("events") or [] for event in events: try: self._seq = max(self._seq, int(event.get("seq") or 0)) await self._on_event(event) except asyncio.CancelledError: return except Exception as e: logger.error("[%s] Event %s failed: %s", self.name, event.get("id"), e) self._seq = max(self._seq, int((page or {}).get("nextSeq") or 0)) # A real long-poll holds the request up to 25 s. If an empty page # came back instantly (proxy, misbehaving server), don't spin. if not events and time.monotonic() - started < 1.0: await asyncio.sleep(1.0) # -- Inbound ------------------------------------------------------------- async def _on_event(self, event: Dict[str, Any]) -> None: etype = event.get("type") payload = event.get("payload") or {} if etype == "contact.added": user = payload.get("user") or {} logger.info("[%s] %s added the agent as a contact", self.name, user.get("nmbr")) return if etype != "message.received": return message = payload.get("message") or {} sender = payload.get("from") or {} conversation_id = payload.get("conversationId") sender_nmbr = normalize_nmbr(sender.get("nmbr")) msg_id = message.get("id") or event.get("id") if not conversation_id or not sender_nmbr or not msg_id: return if self._is_duplicate(msg_id): return text = describe_message(message) if not text: return source = self.build_source( chat_id=conversation_id, chat_name=sender.get("displayName") or sender_nmbr, chat_type="dm", user_id=sender_nmbr, user_name=sender.get("displayName") or sender_nmbr, message_id=msg_id, ) ts = _parse_ts(message.get("createdAt")) msg_event = MessageEvent( text=text, message_type=MessageType.TEXT, source=source, message_id=msg_id, raw_message=event, reply_to_message_id=message.get("replyToId") or None, timestamp=ts, ) logger.debug("[%s] %s → %s: %s", self.name, sender_nmbr, conversation_id, text[:80]) await self.handle_message(msg_event) def _is_duplicate(self, msg_id: str) -> bool: now = time.time() if len(self._seen) > DEDUP_MAX_SIZE: cutoff = now - 600 self._seen = {k: v for k, v in self._seen.items() if v > cutoff} if msg_id in self._seen: return True self._seen[msg_id] = now return False # -- Outbound ------------------------------------------------------------ async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult: if not self._client: return SendResult(success=False, error="not connected") body: Dict[str, Any] = {**_target(chat_id), "type": "text", "content": content[: self.MAX_MESSAGE_LENGTH]} if reply_to: body["replyToId"] = reply_to try: res = await self._api("POST", "/agent/v1/messages", json=body) or {} return SendResult(success=True, message_id=(res.get("message") or {}).get("id"), raw_response=res) except _FatalError as e: return SendResult(success=False, error=str(e)) except Exception as e: logger.warning("[%s] Send failed: %s", self.name, e) return SendResult(success=False, error=str(e)) async def send_typing(self, chat_id: str, metadata=None) -> None: """Typing indicator; throttled — Hermes pings every 2 s, nmbr shows it for ~5.""" if not self._client: return target = _target(chat_id) conv = target.get("conversationId") if not conv: return # typing needs a conversation id; nmbr-addressed targets have none yet now = time.monotonic() if now - self._last_typing.get(conv, 0.0) < TYPING_MIN_INTERVAL_SECONDS: return self._last_typing[conv] = now try: await self._api("POST", f"/agent/v1/conversations/{conv}/typing", json={"typing": True}, timeout=5.0) except Exception as e: logger.debug("[%s] typing failed: %s", self.name, e) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: return {"name": chat_id, "type": "dm", "chat_id": chat_id} def _parse_ts(raw: Any) -> datetime: try: if isinstance(raw, str) and raw: return datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: pass return datetime.now(tz=timezone.utc) # -- Plugin hooks -------------------------------------------------------------- def _env_enablement() -> Optional[dict]: token = _get_scoped_secret("NMBR_AGENT_TOKEN", "").strip() if not token: return None seed: dict = {"token": token, "base_url": _resolve_base_url({})} home = os.getenv("NMBR_HOME_CHANNEL", "").strip() if home: seed["home_channel"] = {"chat_id": normalize_nmbr(home) or home, "name": os.getenv("NMBR_HOME_CHANNEL_NAME", "").strip() or home} return seed 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 send for cron / send_message when the gateway isn't in this process.""" if not HTTPX_AVAILABLE: return {"error": "nmbr standalone send: httpx not installed"} extra = getattr(pconfig, "extra", {}) or {} token = _resolve_token(extra) if not token: return {"error": "nmbr standalone send: NMBR_AGENT_TOKEN not configured"} base = _resolve_base_url(extra) body = {**_target(chat_id), "type": "text", "content": message[:MAX_MESSAGE_LENGTH]} try: async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.post(f"{base}/agent/v1/messages", json=body, headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json", "User-Agent": "hermes-nmbr/0.1"}) if resp.status_code >= 300: return {"error": f"nmbr HTTP {resp.status_code}: {resp.text[:200]}"} data = resp.json() return {"success": True, "platform": PLATFORM_NAME, "chat_id": data.get("conversationId") or chat_id, "message_id": (data.get("message") or {}).get("id")} except Exception as e: return {"error": f"nmbr standalone send failed: {e}"} def register(ctx) -> None: """Plugin entry point — called by the Hermes plugin system at startup.""" ctx.register_platform( name=PLATFORM_NAME, label="nmbr", adapter_factory=lambda cfg: NmbrAdapter(cfg), check_fn=check_requirements, validate_config=validate_config, is_connected=is_connected, required_env=["NMBR_AGENT_TOKEN"], install_hint="pip install httpx # already a Hermes dependency", env_enablement_fn=_env_enablement, cron_deliver_env_var="NMBR_HOME_CHANNEL", standalone_sender_fn=_standalone_send, allowed_users_env="NMBR_ALLOWED_USERS", allow_all_env="NMBR_ALLOW_ALL_USERS", max_message_length=MAX_MESSAGE_LENGTH, emoji="📱", pii_safe=False, allow_update_command=True, platform_hint=( "You are chatting over nmbr, a private messaging app, in a 1:1 with the person who owns you. " "Plain text only — no markdown. Keep replies conversational and phone-sized. " "Approval prompts are answered in this chat with /approve, /always or /cancel." ), )