"""openbridge — a localhost shim so the dashboard can reuse warm tabs. The dashboard is a static page served off a plain file server; it cannot speak MCP. This exposes exactly one capability of the socialmag server — `open_link` — over 127.0.0.1, so clicking a card navigates the platform's existing tab instead of spawning a new one on every click. It does not reimplement open_link. It imports `tool_open_link` and calls it, so validation (unknown platform, missing url, non-http(s) scheme) and tab matching stay in one place: change server.py and this follows. Run: python3 -m socialmag.openbridge # 127.0.0.1:8322 python3 -m socialmag.openbridge --port N Endpoints: GET /health -> {"ok": true, "platforms": [...]} GET /open?platform=X&url=Y -> the open_link result, or {"error": ...} The dashboard treats an unreachable bridge as "just open a new tab" — it falls back to the anchor's normal behaviour, so the page works with this not running. """ import argparse import asyncio import json import sys import threading import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from .queue import PostQueue from .server import PLATFORM_DOMAINS, tool_open_link # The dashboard's clicks are human-paced and must feel instant. The posting # queue's 3-6s inter-job spacing exists to look human while publishing; applied # to a click it would just look broken. CLICK_DELAY = (0.0, 0.0) class _Loop: """One asyncio loop on a background thread, with a started PostQueue. Clicks still go through a queue: two fast clicks must not open two CDP connections racing for the same tab. """ def __init__(self): self.loop = asyncio.new_event_loop() self.queue = PostQueue(inter_platform_delay=CLICK_DELAY) self._ready = threading.Event() self.thread = threading.Thread(target=self._run, name="openbridge", daemon=True) self.thread.start() if not self._ready.wait(timeout=10): raise RuntimeError("openbridge: event loop failed to start within 10s") def _run(self): asyncio.set_event_loop(self.loop) self.loop.run_until_complete(self.queue.start()) self.loop.call_soon(self._ready.set) self.loop.run_forever() def call(self, coro, timeout=120): return asyncio.run_coroutine_threadsafe(coro, self.loop).result(timeout) class Handler(BaseHTTPRequestHandler): server_version = "socialmag-openbridge" bridge = None # set in serve() def _send(self, code, payload): body = json.dumps(payload).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) # The dashboard is served from a different port, so this is cross-origin. # The listener is bound to 127.0.0.1, so the reachable surface is local. self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def do_GET(self): parts = urllib.parse.urlsplit(self.path) query = urllib.parse.parse_qs(parts.query) if parts.path == "/health": return self._send(200, {"ok": True, "platforms": sorted(PLATFORM_DOMAINS)}) if parts.path != "/open": return self._send(404, {"error": f"no such endpoint: {parts.path}"}) platform = (query.get("platform") or [None])[0] url = (query.get("url") or [None])[0] try: result = self.bridge.call( tool_open_link({"platform": platform, "url": url}, self.bridge.queue) ) except ValueError as e: # open_link's own validation — a bad request, not a broken bridge. return self._send(400, {"error": str(e)}) except Exception as e: return self._send(502, {"error": f"{type(e).__name__}: {e}"}) return self._send(200, result) def log_message(self, fmt, *args): sys.stderr.write("[openbridge] %s\n" % (fmt % args)) def serve(port): Handler.bridge = _Loop() # No port fallback on purpose: if 8322 is taken, something else is already # answering there and silently moving would point the dashboard at nothing. httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler) print( f"[openbridge] listening on http://127.0.0.1:{port} " f"(/health, /open?platform=&url=)", file=sys.stderr, flush=True, ) try: httpd.serve_forever() except KeyboardInterrupt: print("[openbridge] stopping", file=sys.stderr) def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--port", type=int, default=8322) serve(ap.parse_args().port) if __name__ == "__main__": main()