#!/usr/bin/env python3 """mesh-bot notify forwarder: POST /notify {text, from?} -> opencode session message (basic auth). The opencode workspace UUID is regenerated on host restarts, so the target workspace + session are resolved dynamically per request via the opencode API (no caching — messages are rare): list workspaces, pick the mesh-bot one by path (fallback: name), then its most recently updated session. Note on semantics: the opencode API accepts a session message immediately but holds the HTTP response until the agent turn completes — so a read timeout here means DELIVERED, not failed. """ import json, base64, urllib.request from http.server import BaseHTTPRequestHandler, HTTPServer import os API = os.environ.get("FORWARDER_API", "http://127.0.0.1:9898") WORKSPACE_PATH = os.environ.get("FORWARDER_WORKSPACE_PATH", "/home/krzyw/projects/sandbox/mesh-bot") SESSION_OVERRIDE = os.environ.get("FORWARDER_SESSION", "") # when set, skips session discovery USER = os.environ["FORWARDER_USER"] # required; fail fast if missing PASS = os.environ["FORWARDER_PASS"] # required; fail fast if missing def _auth_headers(): return {"Authorization": "Basic " + base64.b64encode(f"{USER}:{PASS}".encode()).decode()} def _get_json(url): req = urllib.request.Request(url, headers=_auth_headers()) try: with urllib.request.urlopen(req, timeout=15) as r: return json.loads(r.read().decode()) except Exception as e: raise RuntimeError(f"opencode API unreachable at {url}: {e!r}") from None def resolve_target(): """Resolve (base_url, session_id) fresh per request; raises RuntimeError with a clear reason.""" workspaces = _get_json(API + "/api/workspaces") matches = [w for w in workspaces if w.get("path") == WORKSPACE_PATH] or \ [w for w in workspaces if w.get("name") == "mesh-bot"] pool = [w for w in matches if w.get("status") == "ready"] or matches if not pool: raise RuntimeError(f"workspace not found: path={WORKSPACE_PATH} name=mesh-bot") base = API + pool[0]["proxyPath"] sid = SESSION_OVERRIDE if not sid: sessions = _get_json(base + "/session") if not sessions: raise RuntimeError(f"no sessions in workspace {pool[0]['id']} ({WORKSPACE_PATH})") def updated(s): return (s.get("time") or {}).get("updated") or 0 sid = max(sessions, key=updated)["id"] if any(updated(s) for s in sessions) else sessions[0]["id"] return base, sid class H(BaseHTTPRequestHandler): def do_POST(self): if self.path != "/notify": self.send_response(404); self.end_headers(); return n = int(self.headers.get("Content-Length", 0)) try: body = json.loads(self.rfile.read(n) or b"{}") text, frm = body.get("text"), body.get("from") msg = 'New mesh message' + (f' from node {frm}' if frm is not None else '') + f': "{text}" — please react to it.' base, sid = resolve_target() # fresh per request; RuntimeError on API/workspace/session failure req = urllib.request.Request( f"{base}/session/{sid}/message", data=json.dumps({"parts": [{"type": "text", "text": msg}]}).encode(), headers={"Content-Type": "application/json", **_auth_headers()}, method="POST") try: r = urllib.request.urlopen(req, timeout=15) code = r.status # headers received => opencode accepted the message try: out = r.read(200).decode(errors='replace') except Exception as e2: out = f'(body read failed after delivery: {e2!r})' print(f"forwarded to opencode session {sid}: HTTP {code} {out[:150]!r}", flush=True) self._reply(code if 200 <= code < 300 else 502, b'{"ok":true}' if 200 <= code < 300 else str(code).encode()) except TimeoutError: # request was sent; opencode holds the response until the agent turn finishes => delivered print(f"forwarded to opencode session {sid}: accepted (response still pending after 15 s)", flush=True) self._reply(202, b'{"ok":true,"note":"accepted; opencode response pending"}') except RuntimeError as e: # resolve_target() failure modes: API unreachable / workspace not found / no sessions in workspace print(f"target resolution failed: {e!r}", flush=True) self._reply(502, repr(e).encode()) except Exception as e: print(f"opencode call failed: {e!r}", flush=True) self._reply(502, repr(e).encode()) def _reply(self, code, payload): try: self.send_response(code); self.end_headers(); self.wfile.write(payload) except (BrokenPipeError, ConnectionResetError): pass # client went away; delivery state already logged above def log_message(self, fmt, *args): print("forwarder:", fmt % args, flush=True) HTTPServer(("0.0.0.0", 4097), H).serve_forever()