/ plugins / google_meet / node / protocol.py
protocol.py
  1  """Wire protocol for gateway ↔ node RPC.
  2  
  3  Everything is a JSON object with the same envelope shape:
  4  
  5      Request:   {"type": <str>, "id": <str>, "token": <str>, "payload": <dict>}
  6      Response:  {"type": "<req-type>_res", "id": <req-id>, "payload": <dict>}
  7      Error:     {"type": "error", "id": <req-id>, "error": <str>}
  8  
  9  Requests must carry the shared bearer token (set up via
 10  ``hermes meet node approve`` on the gateway and read off disk on the
 11  server). Mismatched tokens are rejected before dispatch.
 12  """
 13  
 14  from __future__ import annotations
 15  
 16  import json
 17  import uuid
 18  from typing import Any, Dict, Tuple
 19  
 20  
 21  VALID_REQUEST_TYPES = frozenset({
 22      "start_bot",
 23      "stop",
 24      "status",
 25      "transcript",
 26      "say",
 27      "ping",
 28  })
 29  
 30  
 31  def make_request(
 32      type: str,
 33      token: str,
 34      payload: Dict[str, Any],
 35      req_id: str | None = None,
 36  ) -> Dict[str, Any]:
 37      """Construct a request envelope.
 38  
 39      ``req_id`` is auto-generated (uuid4 hex) when not supplied so callers
 40      can correlate async responses.
 41      """
 42      if not isinstance(type, str) or not type:
 43          raise ValueError("type must be a non-empty string")
 44      if type not in VALID_REQUEST_TYPES:
 45          raise ValueError(f"unknown request type: {type!r}")
 46      if not isinstance(token, str):
 47          raise ValueError("token must be a string")
 48      if not isinstance(payload, dict):
 49          raise ValueError("payload must be a dict")
 50      return {
 51          "type": type,
 52          "id": req_id or uuid.uuid4().hex,
 53          "token": token,
 54          "payload": payload,
 55      }
 56  
 57  
 58  def make_response(req_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
 59      """Build a success response. The caller supplies the *request* type;
 60      we suffix it with ``_res`` so clients can assert they got the right
 61      reply.
 62  
 63      For simplicity we don't require the type here — clients usually just
 64      key off ``id``. But we still emit a generic ``*_res`` envelope.
 65      """
 66      if not isinstance(payload, dict):
 67          raise ValueError("payload must be a dict")
 68      return {"type": "response", "id": req_id, "payload": payload}
 69  
 70  
 71  def make_error(req_id: str, error: str) -> Dict[str, Any]:
 72      return {"type": "error", "id": req_id, "error": str(error)}
 73  
 74  
 75  def encode(msg: Dict[str, Any]) -> str:
 76      """Serialize a message envelope to a JSON string."""
 77      return json.dumps(msg, separators=(",", ":"), ensure_ascii=False)
 78  
 79  
 80  def decode(raw: str) -> Dict[str, Any]:
 81      """Parse a JSON envelope, raising ValueError on anything malformed.
 82  
 83      Minimal type validation: must be an object, must contain ``type`` and
 84      ``id``. Heavier validation (token match, payload shape) happens in
 85      :func:`validate_request` on the server side.
 86      """
 87      try:
 88          obj = json.loads(raw)
 89      except (TypeError, json.JSONDecodeError) as exc:
 90          raise ValueError(f"malformed JSON: {exc}") from exc
 91      if not isinstance(obj, dict):
 92          raise ValueError("envelope must be a JSON object")
 93      if "type" not in obj or not isinstance(obj["type"], str):
 94          raise ValueError("envelope missing string 'type'")
 95      if "id" not in obj or not isinstance(obj["id"], str):
 96          raise ValueError("envelope missing string 'id'")
 97      return obj
 98  
 99  
100  def validate_request(msg: Dict[str, Any], expected_token: str) -> Tuple[bool, str]:
101      """Check a decoded request against the server's shared token.
102  
103      Returns ``(True, "")`` when the envelope is acceptable or
104      ``(False, <reason>)`` otherwise. Reason strings are safe to surface
105      back to the client in an error envelope.
106      """
107      if not isinstance(msg, dict):
108          return False, "envelope must be a dict"
109      t = msg.get("type")
110      if not isinstance(t, str) or not t:
111          return False, "missing or non-string 'type'"
112      if t not in VALID_REQUEST_TYPES:
113          return False, f"unknown request type: {t!r}"
114      if not isinstance(msg.get("id"), str) or not msg.get("id"):
115          return False, "missing or non-string 'id'"
116      token = msg.get("token")
117      if not isinstance(token, str) or not token:
118          return False, "missing token"
119      if token != expected_token:
120          return False, "token mismatch"
121      payload = msg.get("payload")
122      if not isinstance(payload, dict):
123          return False, "payload must be a dict"
124      return True, ""