Spaces:
Sleeping
Sleeping
| """ | |
| RELAY β Agent Communication Hub | |
| Multi-channel message bus for AI agent collaboration. | |
| Channels: | |
| internal β direct agent-to-agent (always on) | |
| telegram β Telegram Bot API (TELEGRAM_BOT_TOKEN env) | |
| browser β Server-Sent Events push to UI | |
| websocket β ws:// bidirectional | |
| webhook β outbound HTTP POST to any URL | |
| irc β IRC via socket (RELAY_IRC_* env vars) | |
| email β SMTP (RELAY_SMTP_* env vars) | |
| MCP tools: relay_send, relay_inbox, relay_broadcast, | |
| relay_ack, relay_subscribe, relay_stats, relay_history | |
| """ | |
| import os, uuid, json, asyncio, time, socket, smtplib | |
| from pathlib import Path | |
| from datetime import datetime, timezone | |
| from typing import Optional | |
| from email.mime.text import MIMEText | |
| from collections import defaultdict | |
| import httpx | |
| from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect | |
| from fastapi.responses import JSONResponse, HTMLResponse, StreamingResponse | |
| BASE = Path(__file__).parent | |
| MSG_DIR = BASE / "messages" | |
| AGT_DIR = BASE / "agents" | |
| MSG_DIR.mkdir(exist_ok=True) | |
| AGT_DIR.mkdir(exist_ok=True) | |
| # ββ Env config ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| TG_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") | |
| TG_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") # default chat for /api/notify | |
| APPROVE_URL= os.environ.get("APPROVE_URL", "") # agent-approve base URL | |
| SMTP_HOST = os.environ.get("RELAY_SMTP_HOST", "") | |
| SMTP_PORT = int(os.environ.get("RELAY_SMTP_PORT", "587")) | |
| SMTP_USER = os.environ.get("RELAY_SMTP_USER", "") | |
| SMTP_PASS = os.environ.get("RELAY_SMTP_PASS", "") | |
| IRC_HOST = os.environ.get("RELAY_IRC_HOST", "irc.libera.chat") | |
| IRC_PORT = int(os.environ.get("RELAY_IRC_PORT", "6667")) | |
| IRC_NICK = os.environ.get("RELAY_IRC_NICK", "relay-bot") | |
| IRC_CHAN = os.environ.get("RELAY_IRC_CHANNEL", "#agents") | |
| CHANNELS = ["internal", "telegram", "browser", "websocket", "webhook", "irc", "email"] | |
| PRIORITIES = ["low", "normal", "high", "urgent"] | |
| STATUSES = ["queued", "delivered", "read", "failed"] | |
| # ββ Live subscribers ββββββββββββββββββββββββββββββββββββββββββββββ | |
| sse_queues: dict[str, asyncio.Queue] = defaultdict(asyncio.Queue) | |
| ws_clients: dict[str, list[WebSocket]] = defaultdict(list) | |
| # ββ Message utils βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def now_ts(): return int(time.time()) | |
| def now_iso(): return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") | |
| def msg_path(mid): return MSG_DIR / f"{mid}.json" | |
| def agt_path(name): return AGT_DIR / f"{name.lower()}.json" | |
| def read_msg(mid): | |
| p = msg_path(mid) | |
| return json.loads(p.read_text()) if p.exists() else None | |
| def write_msg(m): | |
| m["updated_at"] = now_ts() | |
| msg_path(m["id"]).write_text(json.dumps(m, indent=2, ensure_ascii=False)) | |
| def read_agent(name): | |
| p = agt_path(name) | |
| return json.loads(p.read_text()) if p.exists() else None | |
| def write_agent(a): | |
| agt_path(a["name"]).write_text(json.dumps(a, indent=2, ensure_ascii=False)) | |
| def register_agent(name: str, meta: dict = {}): | |
| a = read_agent(name) or { | |
| "name": name.lower(), "registered_at": now_ts(), | |
| "channels": [], "telegram_chat_id": "", "webhook_url": "", | |
| "email": "", "irc_nick": "", "last_seen": None, "msg_count": 0 | |
| } | |
| for k, v in meta.items(): | |
| a[k] = v | |
| write_agent(a) | |
| return a | |
| def all_messages(limit=200): | |
| out = [] | |
| for p in sorted(MSG_DIR.glob("*.json"), reverse=True): | |
| try: out.append(json.loads(p.read_text())) | |
| except: pass | |
| if len(out) >= limit: break | |
| return out | |
| def agent_inbox(agent: str, unread_only=False, limit=50): | |
| msgs = [] | |
| for p in sorted(MSG_DIR.glob("*.json"), reverse=True): | |
| try: | |
| m = json.loads(p.read_text()) | |
| to = m.get("to", "") | |
| if to == agent.lower() or to == "broadcast": | |
| if unread_only and m.get("status") == "read": | |
| continue | |
| msgs.append(m) | |
| except: pass | |
| if len(msgs) >= limit: break | |
| return msgs | |
| def new_message(data: dict) -> dict: | |
| mid = uuid.uuid4().hex[:10] | |
| m = { | |
| "id": mid, | |
| "from": (data.get("from") or data.get("sender") or "unknown").strip().lower(), | |
| "to": (data.get("to") or data.get("recipient") or "broadcast").strip().lower(), | |
| "channel": data.get("channel", "internal"), | |
| "subject": (data.get("subject") or "").strip(), | |
| "body": (data.get("body") or data.get("message") or "").strip(), | |
| "priority": data.get("priority", "normal"), | |
| "status": "queued", | |
| "tags": data.get("tags", []), | |
| "reply_to": data.get("reply_to", None), | |
| "metadata": data.get("metadata", {}), | |
| "created_at": now_ts(), | |
| "updated_at": now_ts(), | |
| "delivered_at": None, | |
| "read_at": None, | |
| } | |
| write_msg(m) | |
| return m | |
| # ββ Channel dispatchers βββββββββββββββββββββββββββββββββββββββββββ | |
| async def dispatch_telegram(m: dict): | |
| if not TG_TOKEN: | |
| return False, "TELEGRAM_BOT_TOKEN not set" | |
| # Find recipient's chat_id | |
| agent = read_agent(m["to"]) | |
| chat_id = (agent or {}).get("telegram_chat_id") or m.get("metadata", {}).get("telegram_chat_id") | |
| if not chat_id: | |
| return False, "no telegram_chat_id for recipient" | |
| pri_emoji = {"urgent": "🚨", "high": "⚠", "normal": "💬", "low": "📌"} | |
| text = f"{pri_emoji.get(m['priority'],'💬')} *[{m['priority'].upper()}]* {m['subject']}\n\n{m['body']}\n\n_from: {m['from']} | id: {m['id']}_" | |
| try: | |
| async with httpx.AsyncClient(timeout=8) as client: | |
| r = await client.post( | |
| f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", | |
| json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"} | |
| ) | |
| if r.status_code == 200: | |
| return True, "delivered" | |
| return False, r.text[:200] | |
| except Exception as e: | |
| return False, str(e) | |
| async def dispatch_webhook(m: dict): | |
| agent = read_agent(m["to"]) | |
| url = (agent or {}).get("webhook_url") or m.get("metadata", {}).get("webhook_url") | |
| if not url: | |
| return False, "no webhook_url for recipient" | |
| payload = { | |
| "event": "relay_message", | |
| "message": m, | |
| "timestamp": now_iso() | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| r = await client.post(url, json=payload, | |
| headers={"Content-Type":"application/json","X-Relay-Source":"relay-mcp"}) | |
| return r.status_code < 300, f"HTTP {r.status_code}" | |
| except Exception as e: | |
| return False, str(e) | |
| async def dispatch_browser(m: dict): | |
| payload = json.dumps({"type": "message", "message": m}) | |
| # Push to recipient's queue and broadcast queue | |
| for target in [m["to"], "broadcast", "__all__"]: | |
| if target in sse_queues: | |
| try: | |
| sse_queues[target].put_nowait(payload) | |
| except: pass | |
| # Also push to websocket clients | |
| for target in [m["to"], "__all__"]: | |
| for ws in list(ws_clients.get(target, [])): | |
| try: | |
| await ws.send_text(payload) | |
| except: pass | |
| return True, "pushed to SSE/WS" | |
| async def dispatch_irc(m: dict): | |
| try: | |
| channel = m.get("metadata", {}).get("irc_channel") or IRC_CHAN | |
| nick_target = m.get("metadata", {}).get("irc_nick") or channel | |
| text = f"[{m['from']}->{m['to']}] {m['subject']}: {m['body'][:200]}" | |
| loop = asyncio.get_event_loop() | |
| def _send(): | |
| s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| s.settimeout(8) | |
| s.connect((IRC_HOST, IRC_PORT)) | |
| def send_raw(cmd): s.send((cmd + "\r\n").encode("utf-8", errors="replace")) | |
| send_raw(f"NICK {IRC_NICK}") | |
| send_raw(f"USER relay 0 * :RELAY bot") | |
| time.sleep(1.5) | |
| if channel.startswith("#"): | |
| send_raw(f"JOIN {channel}") | |
| time.sleep(0.5) | |
| send_raw(f"PRIVMSG {nick_target} :{text}") | |
| time.sleep(0.3) | |
| send_raw("QUIT :bye") | |
| s.close() | |
| await loop.run_in_executor(None, _send) | |
| return True, f"sent to IRC {nick_target}" | |
| except Exception as e: | |
| return False, str(e) | |
| async def dispatch_email(m: dict): | |
| if not SMTP_HOST: | |
| return False, "RELAY_SMTP_HOST not set" | |
| agent = read_agent(m["to"]) | |
| to_email = (agent or {}).get("email") or m.get("metadata", {}).get("email") | |
| if not to_email: | |
| return False, "no email for recipient" | |
| try: | |
| msg_obj = MIMEText( | |
| f"From: {m['from']}\nTo: {m['to']}\nPriority: {m['priority']}\n\n{m['body']}\n\n---\nMessage ID: {m['id']}", | |
| "plain", "utf-8" | |
| ) | |
| msg_obj["Subject"] = f"[RELAY] {m['subject']}" | |
| msg_obj["From"] = SMTP_USER or "relay@ki-fusion-labs.de" | |
| msg_obj["To"] = to_email | |
| def _send(): | |
| with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as srv: | |
| srv.starttls() | |
| if SMTP_USER: srv.login(SMTP_USER, SMTP_PASS) | |
| srv.sendmail(msg_obj["From"], [to_email], msg_obj.as_string()) | |
| import concurrent.futures | |
| loop = asyncio.get_event_loop() | |
| await loop.run_in_executor(None, _send) | |
| return True, f"sent to {to_email}" | |
| except Exception as e: | |
| return False, str(e) | |
| async def dispatch_message(m: dict): | |
| channel = m.get("channel", "internal") | |
| ok, note = True, "internal only" | |
| if channel == "telegram": | |
| ok, note = await dispatch_telegram(m) | |
| elif channel == "webhook": | |
| ok, note = await dispatch_webhook(m) | |
| elif channel == "browser": | |
| ok, note = await dispatch_browser(m) | |
| elif channel == "irc": | |
| ok, note = await dispatch_irc(m) | |
| elif channel == "email": | |
| ok, note = await dispatch_email(m) | |
| elif channel == "internal": | |
| # always push to browser too | |
| await dispatch_browser(m) | |
| ok, note = True, "internal + browser push" | |
| m["status"] = "delivered" if ok else "failed" | |
| m["delivered_at"] = now_ts() if ok else None | |
| m["metadata"]["dispatch_note"] = note | |
| write_msg(m) | |
| return m | |
| # ββ Seed agents βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def seed(): | |
| defaults = [ | |
| {"name":"researcher","channels":["internal","browser"],"telegram_chat_id":""}, | |
| {"name":"coder", "channels":["internal","browser"],"telegram_chat_id":""}, | |
| {"name":"planner", "channels":["internal","browser"],"telegram_chat_id":""}, | |
| {"name":"monitor", "channels":["internal","browser","webhook"],"webhook_url":""}, | |
| {"name":"christof", "channels":["internal","telegram","browser"],"telegram_chat_id":""}, | |
| ] | |
| for d in defaults: | |
| if not agt_path(d["name"]).exists(): | |
| register_agent(d["name"], d) | |
| # Seed messages if empty | |
| if not list(MSG_DIR.glob("*.json")): | |
| seeds = [ | |
| {"from":"researcher","to":"coder","channel":"internal","subject":"Analyze embedding dataset", | |
| "body":"Please run dimensionality analysis on the MTEB dataset. Focus on clustering quality for e5-large-v2 vs MiniLM. Output: scatter plot + stats JSON.","priority":"high","tags":["ml","task"]}, | |
| {"from":"planner","to":"broadcast","channel":"internal","subject":"Sprint kickoff", | |
| "body":"New sprint starting. Priorities: (1) BitNet stability, (2) FORGE MCP validation, (3) GDPR deletion PoD certs. Stand-up in 30min.","priority":"normal","tags":["sprint","broadcast"]}, | |
| {"from":"coder","to":"researcher","channel":"internal","subject":"Re: Analyze embedding dataset", | |
| "body":"On it. ETA 15min. Will post results to /api/messages. FYI: found a potential issue with tokenizer padding β investigating.","priority":"normal","tags":["ml","reply"]}, | |
| {"from":"monitor","to":"christof","channel":"internal","subject":"ALERT: GPU memory spike", | |
| "body":"RTX 5090 VRAM usage hit 94% during BitNet training run #47. FlipRate also spiked to 0.82. Auto-paused job. Awaiting instruction.","priority":"urgent","tags":["alert","bitnet","monitor"]}, | |
| {"from":"planner","to":"coder","channel":"internal","subject":"JARVIS routing upgrade", | |
| "body":"TurnClassifier needs to handle ambiguous intents. DST context window set to 5 turns. See TheCore v3 spec in agent-memory.","priority":"high","tags":["jarvis","task"]}, | |
| ] | |
| for s in seeds: | |
| m = new_message(s) | |
| m["status"] = "delivered" | |
| m["delivered_at"] = now_ts() | |
| write_msg(m) | |
| seed() | |
| # ββ FastAPI βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="RELAY β Agent Communication Hub") | |
| def jresp(data, status=200): return JSONResponse(content=data, status_code=status) | |
| # ββ REST API ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def list_messages(limit: int = 100, channel: str = "", priority: str = ""): | |
| msgs = all_messages(limit) | |
| if channel: msgs = [m for m in msgs if m["channel"] == channel] | |
| if priority: msgs = [m for m in msgs if m["priority"] == priority] | |
| return jresp(msgs) | |
| async def get_inbox(agent: str, unread: bool = False): | |
| return jresp(agent_inbox(agent, unread)) | |
| async def send_message(request: Request): | |
| data = await request.json() | |
| if not data.get("body","").strip(): | |
| raise HTTPException(400, "body required") | |
| m = new_message(data) | |
| m = await dispatch_message(m) | |
| return jresp({"status":"sent","id":m["id"],"dispatch_status":m["status"],"message":m}, 201) | |
| async def broadcast(request: Request): | |
| data = await request.json() | |
| data["to"] = "broadcast" | |
| if not data.get("body","").strip(): | |
| raise HTTPException(400, "body required") | |
| m = new_message(data) | |
| m = await dispatch_message(m) | |
| return jresp({"status":"broadcast","id":m["id"],"message":m}, 201) | |
| async def ack_message(mid: str): | |
| m = read_msg(mid) | |
| if not m: raise HTTPException(404) | |
| m["status"] = "read" | |
| m["read_at"] = now_ts() | |
| write_msg(m) | |
| return jresp({"status":"acked","message":m}) | |
| async def delete_message(mid: str): | |
| p = msg_path(mid) | |
| if not p.exists(): raise HTTPException(404) | |
| p.unlink() | |
| return jresp({"status":"deleted"}) | |
| async def list_agents(): | |
| agents = [] | |
| for p in AGT_DIR.glob("*.json"): | |
| try: agents.append(json.loads(p.read_text())) | |
| except: pass | |
| return jresp(sorted(agents, key=lambda a: a["name"])) | |
| async def upsert_agent(request: Request): | |
| data = await request.json() | |
| if not data.get("name","").strip(): | |
| raise HTTPException(400, "name required") | |
| a = register_agent(data["name"], data) | |
| return jresp({"status":"registered","agent":a}) | |
| async def stats(): | |
| msgs = all_messages(500) | |
| by_ch = {c: 0 for c in CHANNELS} | |
| by_pri = {p: 0 for p in PRIORITIES} | |
| by_st = {s: 0 for s in STATUSES} | |
| by_from: dict = {} | |
| for m in msgs: | |
| by_ch[m.get("channel","internal")] = by_ch.get(m.get("channel","internal"), 0) + 1 | |
| by_pri[m.get("priority","normal")] = by_pri.get(m.get("priority","normal"), 0) + 1 | |
| by_st[m.get("status","queued")] = by_st.get(m.get("status","queued"), 0) + 1 | |
| f = m.get("from","?") | |
| by_from[f] = by_from.get(f, 0) + 1 | |
| return jresp({"total": len(msgs), "by_channel": by_ch, | |
| "by_priority": by_pri, "by_status": by_st, "by_sender": by_from, | |
| "channels_active": {"telegram": bool(TG_TOKEN), "smtp": bool(SMTP_HOST), | |
| "irc": True, "webhook": True, "browser": True}}) | |
| # ββ SSE (Browser push) ββββββββββββββββββββββββββββββββββββββββββββ | |
| async def sse_subscribe(agent: str): | |
| q = sse_queues[agent.lower()] | |
| async def stream(): | |
| # Send last 5 unread on connect | |
| inbox = agent_inbox(agent, unread_only=True, limit=5) | |
| for m in inbox: | |
| yield f"data: {json.dumps({'type':'message','message':m})}\n\n" | |
| yield f"data: {json.dumps({'type':'connected','agent':agent})}\n\n" | |
| while True: | |
| try: | |
| payload = await asyncio.wait_for(q.get(), timeout=25) | |
| yield f"data: {payload}\n\n" | |
| except asyncio.TimeoutError: | |
| yield f"data: {json.dumps({'type':'ping'})}\n\n" | |
| return StreamingResponse(stream(), media_type="text/event-stream", | |
| headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"}) | |
| # ββ WebSocket βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def ws_endpoint(websocket: WebSocket, agent: str): | |
| await websocket.accept() | |
| ws_clients[agent.lower()].append(websocket) | |
| ws_clients["__all__"].append(websocket) | |
| try: | |
| await websocket.send_text(json.dumps({"type":"connected","agent":agent})) | |
| while True: | |
| raw = await websocket.receive_text() | |
| try: | |
| data = json.loads(raw) | |
| data["from"] = agent.lower() | |
| m = new_message(data) | |
| m = await dispatch_message(m) | |
| await websocket.send_text(json.dumps({"type":"sent","message":m})) | |
| except Exception as e: | |
| await websocket.send_text(json.dumps({"type":"error","error":str(e)})) | |
| except WebSocketDisconnect: | |
| ws_clients[agent.lower()].remove(websocket) | |
| if websocket in ws_clients["__all__"]: | |
| ws_clients["__all__"].remove(websocket) | |
| # ββ Telegram webhook ββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def telegram_webhook(request: Request): | |
| data = await request.json() | |
| update = data.get("message") or data.get("edited_message", {}) | |
| if not update: return jresp({"ok": True}) | |
| chat_id = str(update.get("chat", {}).get("id", "")) | |
| text = update.get("text", "") | |
| username = update.get("from", {}).get("username") or update.get("from", {}).get("first_name", "tg_user") | |
| if not text: return jresp({"ok": True}) | |
| # Register/update agent with chat_id | |
| register_agent(username, {"telegram_chat_id": chat_id, "channels": ["telegram", "internal"]}) | |
| # Parse command or treat as message to broadcast | |
| if text.startswith("/send "): | |
| # /send @agent message body | |
| parts = text[6:].split(" ", 2) | |
| to = parts[0].lstrip("@") if parts else "broadcast" | |
| body = parts[1] if len(parts) > 1 else text | |
| m = new_message({"from": username, "to": to, "body": body, | |
| "subject": body[:60], "channel": "telegram"}) | |
| else: | |
| m = new_message({"from": username, "to": "broadcast", "body": text, | |
| "subject": text[:60], "channel": "telegram", | |
| "metadata": {"telegram_chat_id": chat_id}}) | |
| await dispatch_message(m) | |
| return jresp({"ok": True}) | |
| # ββ /api/notify β Telegram with optional inline keyboard βββββββββ | |
| # | |
| # POST /api/notify | |
| # { | |
| # "text": "Alert: vault exec requested", # required | |
| # "chat_id": "optional β falls back to TG_CHAT_ID env", | |
| # "parse_mode": "Markdown", # optional, default Markdown | |
| # "buttons": [ # optional inline keyboard rows | |
| # [{"text": "β Approve", "callback_data": "approve:abc123"}, | |
| # {"text": "β Reject", "callback_data": "reject:abc123"}] | |
| # ], | |
| # "approve_url": "https://...hf.space" # optional β override APPROVE_URL | |
| # } | |
| # | |
| # Used by: agent-approve, agent-harness, agent-compliance, PULSE | |
| # Returns: { "ok": bool, "message_id": int|null, "error": str|null } | |
| async def api_notify(request: Request): | |
| """Send a Telegram message with optional inline keyboard buttons.""" | |
| if not TG_TOKEN: | |
| return jresp({"ok": False, "error": "TELEGRAM_BOT_TOKEN not configured"}, 503) | |
| data = await request.json() | |
| text = str(data.get("text", "")).strip() | |
| if not text: | |
| raise HTTPException(400, "text required") | |
| chat_id = str(data.get("chat_id", "") or TG_CHAT_ID).strip() | |
| parse_mode = str(data.get("parse_mode", "Markdown")) | |
| buttons = data.get("buttons", None) # list[list[{text,callback_data|url}]] | |
| base_url = str(data.get("approve_url", "") or APPROVE_URL).strip() | |
| if not chat_id: | |
| return jresp({"ok": False, "error": "No chat_id β set TELEGRAM_CHAT_ID env or pass chat_id"}, 400) | |
| payload: dict = {"chat_id": chat_id, "text": text, "parse_mode": parse_mode} | |
| # Build inline_keyboard if buttons provided | |
| if buttons: | |
| keyboard_rows = [] | |
| for row in buttons: | |
| kb_row = [] | |
| for btn in row: | |
| kb_btn: dict = {"text": str(btn.get("text", "?"))} | |
| if "url" in btn: | |
| kb_btn["url"] = str(btn["url"]) | |
| elif "callback_data" in btn: | |
| kb_btn["callback_data"] = str(btn["callback_data"])[:64] | |
| kb_row.append(kb_btn) | |
| keyboard_rows.append(kb_row) | |
| payload["reply_markup"] = {"inline_keyboard": keyboard_rows} | |
| elif base_url and data.get("approval_id"): | |
| # Auto-build approve/reject keyboard when approval_id is in payload | |
| aid = str(data["approval_id"]) | |
| payload["reply_markup"] = {"inline_keyboard": [[ | |
| {"text": "✅ Approve", | |
| "url": f"{base_url}/api/approval/{aid}/approve?source=relay"}, | |
| {"text": "❌ Reject", | |
| "url": f"{base_url}/api/approval/{aid}/reject?source=relay"}, | |
| ]]} | |
| try: | |
| async with httpx.AsyncClient(timeout=8) as client: | |
| r = await client.post( | |
| f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", | |
| json=payload | |
| ) | |
| rd = r.json() | |
| if r.status_code == 200 and rd.get("ok"): | |
| msg_id = rd.get("result", {}).get("message_id") | |
| # Log internally as a relay message | |
| m = new_message({ | |
| "from": "relay_notify", | |
| "to": "christof", | |
| "channel": "telegram", | |
| "subject": text[:80], | |
| "body": text, | |
| "priority": data.get("priority", "high"), | |
| "tags": data.get("tags", ["notify"]), | |
| "metadata": {"telegram_chat_id": chat_id, "message_id": msg_id} | |
| }) | |
| m["status"] = "delivered" | |
| m["delivered_at"] = now_ts() | |
| write_msg(m) | |
| return jresp({"ok": True, "message_id": msg_id, "relay_id": m["id"]}) | |
| else: | |
| return jresp({"ok": False, "error": rd.get("description", r.text[:200])}) | |
| except Exception as e: | |
| return jresp({"ok": False, "error": str(e)}, 500) | |
| # ββ MCP βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MCP_TOOLS = [ | |
| {"name":"relay_send","description":"Send a message via a specific channel to an agent or broadcast", | |
| "inputSchema":{"type":"object","required":["body"],"properties":{ | |
| "from": {"type":"string","description":"Sender agent ID"}, | |
| "to": {"type":"string","description":"Recipient agent ID or 'broadcast'"}, | |
| "subject": {"type":"string"}, | |
| "body": {"type":"string"}, | |
| "channel": {"type":"string","enum":["internal","telegram","browser","webhook","irc","email"]}, | |
| "priority": {"type":"string","enum":["low","normal","high","urgent"]}, | |
| "tags": {"type":"array","items":{"type":"string"}}, | |
| }}}, | |
| {"name":"relay_inbox","description":"Get inbox for an agent", | |
| "inputSchema":{"type":"object","required":["agent"],"properties":{ | |
| "agent": {"type":"string"}, | |
| "unread": {"type":"boolean","default":False}, | |
| "limit": {"type":"integer","default":20}, | |
| }}}, | |
| {"name":"relay_broadcast","description":"Broadcast a message to all agents", | |
| "inputSchema":{"type":"object","required":["body"],"properties":{ | |
| "from": {"type":"string"}, | |
| "subject": {"type":"string"}, | |
| "body": {"type":"string"}, | |
| "channel": {"type":"string"}, | |
| "priority":{"type":"string"}, | |
| }}}, | |
| {"name":"relay_ack","description":"Mark a message as read", | |
| "inputSchema":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}}}, | |
| {"name":"relay_subscribe","description":"Register an agent with channel config", | |
| "inputSchema":{"type":"object","required":["name"],"properties":{ | |
| "name": {"type":"string"}, | |
| "channels": {"type":"array","items":{"type":"string"}}, | |
| "telegram_chat_id": {"type":"string"}, | |
| "webhook_url": {"type":"string"}, | |
| "email": {"type":"string"}, | |
| "irc_nick": {"type":"string"}, | |
| }}}, | |
| {"name":"relay_stats","description":"Get message bus statistics", | |
| "inputSchema":{"type":"object","properties":{}}}, | |
| {"name":"relay_history","description":"Get recent message history", | |
| "inputSchema":{"type":"object","properties":{ | |
| "limit": {"type":"integer","default":20}, | |
| "channel": {"type":"string"}, | |
| }}}, | |
| {"name":"relay_notify","description":"Send a Telegram notification with optional inline keyboard buttons. Falls back to TELEGRAM_CHAT_ID env if no chat_id given.", | |
| "inputSchema":{"type":"object","required":["text"],"properties":{ | |
| "text": {"type":"string","description":"Message text (Markdown supported)"}, | |
| "chat_id": {"type":"string","description":"Telegram chat ID (optional β uses env default)"}, | |
| "parse_mode": {"type":"string","default":"Markdown"}, | |
| "priority": {"type":"string","enum":["low","normal","high","urgent"],"default":"high"}, | |
| "approval_id": {"type":"string","description":"If set, auto-adds Approve/Reject buttons linking to agent-approve"}, | |
| "buttons": {"type":"array","description":"Custom inline keyboard rows: [[{text, callback_data|url}]]"}, | |
| "tags": {"type":"array","items":{"type":"string"}}, | |
| }}}, | |
| ] | |
| async def mcp_call(name, args): | |
| if name == "relay_send": | |
| if not args.get("body","").strip(): return json.dumps({"error":"body required"}) | |
| m = new_message(args) | |
| m = await dispatch_message(m) | |
| return json.dumps({"sent":m["id"],"status":m["status"],"channel":m["channel"]}) | |
| if name == "relay_inbox": | |
| msgs = agent_inbox(args["agent"], args.get("unread",False), args.get("limit",20)) | |
| return json.dumps({"count":len(msgs),"messages":msgs}) | |
| if name == "relay_broadcast": | |
| args["to"] = "broadcast" | |
| m = new_message(args) | |
| m = await dispatch_message(m) | |
| return json.dumps({"broadcast":m["id"],"status":m["status"]}) | |
| if name == "relay_ack": | |
| m = read_msg(args["id"]) | |
| if not m: return json.dumps({"error":"not found"}) | |
| m["status"]="read"; m["read_at"]=now_ts(); write_msg(m) | |
| return json.dumps({"acked":args["id"]}) | |
| if name == "relay_subscribe": | |
| a = register_agent(args["name"], args) | |
| return json.dumps({"registered":a["name"],"agent":a}) | |
| if name == "relay_stats": | |
| msgs = all_messages(500) | |
| by_ch = {} | |
| for m in msgs: by_ch[m.get("channel","internal")] = by_ch.get(m.get("channel","internal"),0)+1 | |
| return json.dumps({"total":len(msgs),"by_channel":by_ch, | |
| "telegram_active":bool(TG_TOKEN),"smtp_active":bool(SMTP_HOST)}) | |
| if name == "relay_history": | |
| msgs = all_messages(args.get("limit",20)) | |
| ch = args.get("channel","") | |
| if ch: msgs = [m for m in msgs if m["channel"]==ch] | |
| return json.dumps({"count":len(msgs),"messages":msgs}) | |
| if name == "relay_notify": | |
| # Proxy to /api/notify logic directly | |
| if not TG_TOKEN: return json.dumps({"ok":False,"error":"TELEGRAM_BOT_TOKEN not configured"}) | |
| chat_id = str(args.get("chat_id","") or TG_CHAT_ID).strip() | |
| if not chat_id: return json.dumps({"ok":False,"error":"No chat_id β set TELEGRAM_CHAT_ID env or pass chat_id"}) | |
| payload: dict = {"chat_id":chat_id,"text":args["text"],"parse_mode":args.get("parse_mode","Markdown")} | |
| if args.get("buttons"): | |
| payload["reply_markup"] = {"inline_keyboard": args["buttons"]} | |
| elif args.get("approval_id") and APPROVE_URL: | |
| aid = args["approval_id"] | |
| payload["reply_markup"] = {"inline_keyboard":[[ | |
| {"text":"✅ Approve","url":f"{APPROVE_URL}/api/approval/{aid}/approve"}, | |
| {"text":"❌ Reject", "url":f"{APPROVE_URL}/api/approval/{aid}/reject"}, | |
| ]]} | |
| try: | |
| async with httpx.AsyncClient(timeout=8) as client: | |
| r = await client.post(f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", json=payload) | |
| rd = r.json() | |
| if r.status_code==200 and rd.get("ok"): | |
| return json.dumps({"ok":True,"message_id":rd.get("result",{}).get("message_id")}) | |
| return json.dumps({"ok":False,"error":rd.get("description","unknown")}) | |
| except Exception as e: | |
| return json.dumps({"ok":False,"error":str(e)}) | |
| return json.dumps({"error":f"unknown: {name}"}) | |
| async def mcp_sse(): | |
| async def stream(): | |
| init = {"jsonrpc":"2.0","method":"notifications/initialized", | |
| "params":{"serverInfo":{"name":"relay-mcp","version":"1.0"},"capabilities":{"tools":{}}}} | |
| yield f"data: {json.dumps(init)}\n\n" | |
| await asyncio.sleep(0.1) | |
| yield f"data: {json.dumps({'jsonrpc':'2.0','method':'notifications/tools/list_changed','params':{}})}\n\n" | |
| while True: | |
| await asyncio.sleep(25) | |
| yield f"data: {json.dumps({'jsonrpc':'2.0','method':'ping'})}\n\n" | |
| return StreamingResponse(stream(), media_type="text/event-stream", | |
| headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"}) | |
| async def mcp_rpc(request: Request): | |
| body = await request.json() | |
| method = body.get("method",""); rid = body.get("id",1) | |
| if method == "initialize": | |
| return jresp({"jsonrpc":"2.0","id":rid,"result":{ | |
| "serverInfo":{"name":"relay-mcp","version":"1.0"},"capabilities":{"tools":{}}}}) | |
| if method == "tools/list": | |
| return jresp({"jsonrpc":"2.0","id":rid,"result":{"tools":MCP_TOOLS}}) | |
| if method == "tools/call": | |
| p = body.get("params",{}) | |
| res = await mcp_call(p.get("name",""), p.get("arguments",{})) | |
| return jresp({"jsonrpc":"2.0","id":rid,"result":{"content":[{"type":"text","text":res}]}}) | |
| return jresp({"jsonrpc":"2.0","id":rid,"error":{"code":-32601,"message":"Method not found"}}) | |
| # ββ SPA βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def ui(): | |
| return HTMLResponse(content=SPA, media_type="text/html; charset=utf-8") | |
| SPA = """<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>RELAY — Agent Communication Hub</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --bg:#0a0a0f;--s1:#111118;--s2:#161625;--bd:#1e1e2e;--bd2:#252540; | |
| --acc:#ff6b00;--acc2:#ff9500;--txt:#d8d8f0;--sub:#5a5a80;--dim:#2a2a50; | |
| --int:#0ea5e9;--tg:#229ED9;--br:#7c3aed;--wh:#2ed573;--irc:#f0c040;--em:#ff6b9d;--ws:#ff9500; | |
| --cr:#ff2244;--lo:#2ed573;--font:'Space Mono',monospace; | |
| } | |
| *{box-sizing:border-box;margin:0;padding:0;} | |
| html,body{height:100%;overflow:hidden;} | |
| body{font-family:var(--font);background:var(--bg);color:var(--txt); | |
| display:flex;flex-direction:column;height:100vh;} | |
| body::after{content:'';position:fixed;inset:0;pointer-events:none; | |
| background-image:repeating-linear-gradient(0deg,transparent,transparent 3px, | |
| rgba(255,107,0,.006) 3px,rgba(255,107,0,.006) 4px);} | |
| /* HEADER */ | |
| #hdr{flex-shrink:0;display:flex;align-items:center;padding:.85rem 1.8rem;gap:1.2rem; | |
| border-bottom:1px solid var(--bd);background:linear-gradient(180deg,#0e0e1e,var(--bg));z-index:10;} | |
| #logo{font-size:1.3rem;font-weight:700;letter-spacing:2px; | |
| background:linear-gradient(90deg,var(--acc),var(--acc2)); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;} | |
| #logo-sub{font-size:.52rem;color:var(--sub);letter-spacing:.28em;text-transform:uppercase;margin-top:2px;} | |
| #stat-bar{display:flex;gap:.5rem;flex:1;flex-wrap:wrap;} | |
| .sp{display:flex;align-items:center;gap:.38rem;background:var(--s1);border:1px solid var(--bd); | |
| border-radius:5px;padding:.24rem .55rem;font-size:.56rem;color:var(--sub);} | |
| .sp-n{font-size:.88rem;font-weight:700;line-height:1;} | |
| .ch-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;} | |
| .live{animation:pulse 2s infinite;} | |
| @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}} | |
| #btn-compose{background:var(--acc);color:#000;border:none;padding:.42rem 1rem; | |
| font-family:var(--font);font-size:.68rem;font-weight:700;letter-spacing:.1em; | |
| text-transform:uppercase;border-radius:4px;cursor:pointer;white-space:nowrap;flex-shrink:0; | |
| transition:background .12s,transform .1s;} | |
| #btn-compose:hover{background:var(--acc2);transform:translateY(-1px);} | |
| /* TOOLBAR */ | |
| #toolbar{flex-shrink:0;display:flex;gap:.45rem;align-items:center; | |
| padding:.48rem 1.8rem;border-bottom:1px solid var(--bd);background:var(--s1);flex-wrap:wrap;} | |
| .ch-pill{display:flex;align-items:center;gap:.3rem;background:var(--s2);border:1px solid var(--bd2); | |
| border-radius:20px;padding:2px 8px;cursor:pointer;font-size:.54rem;color:var(--sub); | |
| font-family:var(--font);transition:all .12s;user-select:none;} | |
| .ch-pill.on{border-color:var(--acc);color:var(--acc);background:#160b00;} | |
| .ch-pill .dot{width:5px;height:5px;border-radius:50%;} | |
| .ch-pill.int-on{border-color:var(--int);color:var(--int);background:#050c18;} | |
| .ch-pill.tg-on{border-color:var(--tg);color:var(--tg);background:#040e15;} | |
| .ch-pill.br-on{border-color:var(--br);color:var(--br);background:#110820;} | |
| .ch-pill.wh-on{border-color:var(--wh);color:var(--wh);background:#02130a;} | |
| .ch-pill.irc-on{border-color:var(--irc);color:var(--irc);background:#181400;} | |
| .ch-pill.em-on{border-color:var(--em);color:var(--em);background:#180510;} | |
| .fsep{color:var(--bd2);} | |
| #agent-sel{background:var(--s2);border:1px solid var(--bd2);border-radius:5px; | |
| padding:.3rem .55rem;font-family:var(--font);font-size:.62rem;color:var(--txt);outline:none;} | |
| #agent-sel:focus{border-color:var(--acc);} | |
| /* MAIN */ | |
| #main{flex:1;display:flex;min-height:0;overflow:hidden;} | |
| /* LEFT: message feed */ | |
| #feed{width:420px;flex-shrink:0;border-right:1px solid var(--bd); | |
| display:flex;flex-direction:column;overflow:hidden;} | |
| #feed-hdr{flex-shrink:0;display:flex;align-items:center;justify-content:space-between; | |
| padding:.5rem .9rem;border-bottom:1px solid var(--bd);font-size:.58rem;color:var(--sub);} | |
| #live-dot{width:7px;height:7px;border-radius:50%;background:var(--lo); | |
| box-shadow:0 0 5px var(--lo);animation:pulse 2s infinite;} | |
| #feed-scroll{flex:1;overflow-y:auto;padding:.55rem;} | |
| #feed-scroll::-webkit-scrollbar{width:3px;} | |
| #feed-scroll::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px;} | |
| /* MESSAGE CARD */ | |
| .mc{background:var(--s1);border:1px solid var(--bd);border-radius:8px; | |
| padding:.6rem .75rem .6rem .95rem;margin-bottom:.38rem;cursor:pointer; | |
| position:relative;animation:cin .15s ease;transition:border-color .12s,transform .08s;} | |
| @keyframes cin{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}} | |
| .mc:hover{border-color:var(--bd2);transform:translateY(-1px);} | |
| .mc.active{border-color:var(--acc);background:var(--s2);} | |
| .mc.unread{border-color:var(--bd2);} | |
| .mc.unread::after{content:'';position:absolute;top:.55rem;right:.55rem; | |
| width:6px;height:6px;border-radius:50%;background:var(--acc);box-shadow:0 0 4px var(--acc);} | |
| .mc::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:8px 0 0 8px;} | |
| .mc.ch-internal::before{background:var(--int);} | |
| .mc.ch-telegram::before{background:var(--tg);} | |
| .mc.ch-browser::before{background:var(--br);} | |
| .mc.ch-webhook::before{background:var(--wh);} | |
| .mc.ch-irc::before{background:var(--irc);} | |
| .mc.ch-email::before{background:var(--em);} | |
| .mc.ch-websocket::before{background:var(--ws);} | |
| .mc-top{display:flex;align-items:center;gap:.38rem;margin-bottom:.25rem;} | |
| .mc-from{font-size:.65rem;font-weight:700;color:var(--txt);} | |
| .mc-arrow{font-size:.5rem;color:var(--sub);} | |
| .mc-to{font-size:.6rem;color:var(--sub);} | |
| .mc-ch{font-size:.46rem;padding:1px 5px;border-radius:3px;text-transform:uppercase; | |
| letter-spacing:.08em;font-weight:700;flex-shrink:0;margin-left:auto;} | |
| .mc-pri{font-size:.46rem;padding:1px 4px;border-radius:3px;font-weight:700;flex-shrink:0;} | |
| .pri-urgent .mc-pri{background:#1a0308;color:var(--cr);border:1px solid rgba(255,34,68,.15);} | |
| .pri-high .mc-pri{background:#180900;color:var(--acc);border:1px solid rgba(255,107,0,.15);} | |
| .pri-normal .mc-pri{background:var(--bd2);color:var(--sub);} | |
| .pri-low .mc-pri{background:var(--bd2);color:var(--dim);} | |
| .mc-subject{font-size:.7rem;font-weight:700;color:var(--txt);margin-bottom:.22rem; | |
| white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} | |
| .mc-body-prev{font-size:.6rem;color:var(--sub); | |
| white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} | |
| .mc-foot{display:flex;align-items:center;gap:.3rem;margin-top:.25rem;} | |
| .mc-tag{font-size:.48rem;background:var(--bd2);padding:0 4px;border-radius:3px;color:var(--sub);} | |
| .mc-time{font-size:.48rem;color:var(--dim);margin-left:auto;} | |
| .mc-st{font-size:.46rem;padding:0 4px;border-radius:3px;} | |
| .st-delivered .mc-st{color:var(--lo);}.st-read .mc-st{color:var(--sub);} | |
| .st-failed .mc-st{color:var(--cr);}.st-queued .mc-st{color:var(--irc);} | |
| /* RIGHT: detail + agents */ | |
| #right{flex:1;display:flex;flex-direction:column;overflow:hidden;} | |
| #tabs{flex-shrink:0;display:flex;border-bottom:1px solid var(--bd);background:var(--s1);} | |
| .tab{padding:.55rem 1.2rem;font-size:.62rem;font-weight:700;letter-spacing:.08em; | |
| cursor:pointer;color:var(--sub);border-bottom:2px solid transparent; | |
| transition:color .12s,border-color .12s;} | |
| .tab.on{color:var(--acc);border-bottom-color:var(--acc);} | |
| /* DETAIL */ | |
| #detail{flex:1;overflow-y:auto;padding:1.3rem 1.6rem;display:none;} | |
| #detail::-webkit-scrollbar{width:4px;} | |
| #detail::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px;} | |
| #detail.on{display:block;} | |
| #d-empty{display:flex;align-items:center;justify-content:center; | |
| height:100%;font-size:.65rem;color:var(--dim);letter-spacing:.1em;text-transform:uppercase;} | |
| .d-ch-badge{display:inline-flex;align-items:center;gap:.4rem;font-size:.56rem;font-weight:700; | |
| text-transform:uppercase;letter-spacing:.12em;padding:.28rem .65rem;border-radius:4px;margin-bottom:.75rem;} | |
| #d-subject{font-size:1rem;font-weight:700;color:var(--txt);margin-bottom:.55rem;word-break:break-word;} | |
| .d-route{display:flex;align-items:center;gap:.5rem;margin-bottom:.7rem;} | |
| .d-agent{font-size:.7rem;font-weight:700;background:var(--s1);border:1px solid var(--bd2); | |
| border-radius:5px;padding:.25rem .55rem;} | |
| .d-arrow{font-size:.7rem;color:var(--sub);} | |
| #d-body{font-size:.76rem;color:var(--txt);line-height:1.7; | |
| background:var(--s1);border:1px solid var(--bd);border-radius:7px;padding:.9rem 1rem; | |
| white-space:pre-wrap;margin-bottom:.9rem;} | |
| .d-meta{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem .8rem;margin-bottom:.8rem;} | |
| .dml{font-size:.5rem;color:var(--sub);text-transform:uppercase;letter-spacing:.1em;margin-bottom:.15rem;} | |
| .dmv{font-size:.62rem;color:var(--txt);} | |
| .d-tags{display:flex;flex-wrap:wrap;gap:.28rem;margin-bottom:.8rem;} | |
| .d-tag{background:var(--s2);border:1px solid var(--bd2);border-radius:4px; | |
| padding:1px 7px;font-size:.56rem;color:var(--sub);} | |
| .d-acts{display:flex;gap:.45rem;} | |
| .d-btn{background:var(--s2);border:1px solid var(--bd2);color:var(--sub); | |
| padding:.35rem .7rem;font-family:var(--font);font-size:.6rem;border-radius:4px; | |
| cursor:pointer;transition:all .1s;} | |
| .d-btn:hover{background:var(--bd2);color:var(--txt);} | |
| .d-btn.danger:hover{background:#1e0508;color:var(--cr);} | |
| .d-btn.acc{background:var(--acc);color:#000;border-color:var(--acc);} | |
| .d-btn.acc:hover{background:var(--acc2);} | |
| /* AGENTS PANEL */ | |
| #agents-panel{flex:1;overflow-y:auto;padding:1rem 1.5rem;display:none;} | |
| #agents-panel::-webkit-scrollbar{width:4px;} | |
| #agents-panel::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px;} | |
| #agents-panel.on{display:block;} | |
| .ag-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.6rem;margin-bottom:1rem;} | |
| .ag-card{background:var(--s1);border:1px solid var(--bd);border-radius:8px;padding:.75rem .9rem;} | |
| .ag-name{font-size:.78rem;font-weight:700;color:var(--txt);margin-bottom:.4rem;} | |
| .ag-chs{display:flex;flex-wrap:wrap;gap:.22rem;margin-bottom:.35rem;} | |
| .ag-ch{font-size:.48rem;padding:1px 5px;border-radius:3px;background:var(--s2);color:var(--sub);} | |
| .ag-meta{font-size:.55rem;color:var(--sub);} | |
| #btn-add-agent{background:var(--s2);border:1px dashed var(--bd2);color:var(--sub); | |
| padding:.4rem .8rem;font-family:var(--font);font-size:.62rem;border-radius:4px; | |
| cursor:pointer;transition:all .1s;} | |
| #btn-add-agent:hover{border-color:var(--acc);color:var(--acc);} | |
| /* CHANNEL CONFIG PANEL */ | |
| #config-panel{flex:1;overflow-y:auto;padding:1rem 1.5rem;display:none;} | |
| #config-panel.on{display:block;} | |
| .cfg-section{margin-bottom:1.2rem;} | |
| .cfg-title{font-size:.65rem;font-weight:700;letter-spacing:.15em;text-transform:uppercase; | |
| color:var(--acc);margin-bottom:.6rem;display:flex;align-items:center;gap:.5rem;} | |
| .cfg-title .dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;} | |
| .cfg-grid{display:grid;grid-template-columns:1fr 1fr;gap:.5rem;} | |
| .ci{display:flex;flex-direction:column;gap:.2rem;} | |
| .ci label{font-size:.5rem;color:var(--sub);text-transform:uppercase;letter-spacing:.1em;} | |
| .ci input{background:var(--s2);border:1px solid var(--bd2);border-radius:4px; | |
| padding:.38rem .55rem;font-family:var(--font);font-size:.68rem;color:var(--txt);outline:none; | |
| transition:border-color .12s;} | |
| .ci input:focus{border-color:var(--acc);} | |
| .ci .status{font-size:.52rem;padding:1px 6px;border-radius:3px;display:inline-block;margin-top:.15rem;} | |
| .ci .status.on{background:#02130a;color:var(--lo);}.ci .status.off{background:var(--bd2);color:var(--sub);} | |
| /* COMPOSE MODAL */ | |
| #modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.82);z-index:100; | |
| backdrop-filter:blur(4px);align-items:center;justify-content:center;} | |
| #modal.open{display:flex;} | |
| .mdl{background:var(--s1);border:1px solid var(--bd2);border-top:2px solid var(--acc); | |
| border-radius:12px;padding:1.4rem;width:560px;max-width:97vw;max-height:92vh; | |
| overflow-y:auto;animation:mdin .17s ease;position:relative;} | |
| @keyframes mdin{from{opacity:0;transform:scale(.96) translateY(-8px)}to{opacity:1;transform:none}} | |
| #mdl-title{font-size:.85rem;font-weight:700;letter-spacing:3px;color:var(--acc);margin-bottom:.9rem;} | |
| #mdl-close{position:absolute;top:.85rem;right:.85rem;background:none;border:none;color:var(--sub); | |
| width:26px;height:26px;border-radius:4px;cursor:pointer;font-size:.85rem; | |
| display:flex;align-items:center;justify-content:center;transition:all .1s;} | |
| #mdl-close:hover{background:var(--bd2);color:var(--txt);} | |
| .fg{display:grid;grid-template-columns:1fr 1fr;gap:.6rem;} | |
| .fl{margin-bottom:.65rem;} | |
| .fl label{display:block;font-size:.5rem;color:var(--sub);text-transform:uppercase; | |
| letter-spacing:.12em;margin-bottom:.22rem;} | |
| .fl input,.fl textarea,.fl select{width:100%;background:var(--s2);border:1px solid var(--bd2); | |
| border-radius:5px;padding:.42rem .6rem;font-family:var(--font);font-size:.7rem;color:var(--txt); | |
| outline:none;transition:border-color .12s;} | |
| .fl input:focus,.fl textarea:focus,.fl select:focus{border-color:var(--acc);} | |
| .fl textarea{min-height:100px;line-height:1.6;resize:vertical;} | |
| .fl select option{background:var(--s2);} | |
| #mdl-actions{display:flex;gap:.45rem;margin-top:.9rem;} | |
| #btn-send{flex:1;background:var(--acc);color:#000;border:none;padding:.48rem 1rem; | |
| font-family:var(--font);font-size:.66rem;font-weight:700;letter-spacing:.1em; | |
| text-transform:uppercase;border-radius:5px;cursor:pointer;transition:background .1s;} | |
| #btn-send:hover{background:var(--acc2);} | |
| #btn-mcancel{background:var(--s2);color:var(--sub);border:1px solid var(--bd2);padding:.48rem 1rem; | |
| font-family:var(--font);font-size:.66rem;letter-spacing:.1em;text-transform:uppercase; | |
| border-radius:5px;cursor:pointer;transition:all .1s;} | |
| #btn-mcancel:hover{background:var(--bd2);color:var(--txt);} | |
| /* TOAST */ | |
| #toasts{position:fixed;bottom:1rem;right:1rem;z-index:200;display:flex;flex-direction:column;gap:.35rem;} | |
| .tst{background:var(--s1);border:1px solid var(--bd2);border-left:3px solid var(--acc); | |
| padding:.42rem .78rem;font-size:.61rem;border-radius:6px;animation:tin .15s ease; | |
| color:var(--txt);max-width:280px;} | |
| .tst.err{border-left-color:var(--cr);}.tst.ok{border-left-color:var(--lo);} | |
| @keyframes tin{from{opacity:0;transform:translateX(12px)}to{opacity:1;transform:none}} | |
| #mcp-hint{position:fixed;bottom:1rem;left:1rem;z-index:10;background:var(--s1); | |
| border:1px solid var(--bd2);border-left:3px solid var(--tg);border-radius:6px; | |
| padding:.4rem .78rem;font-size:.54rem;color:var(--sub);} | |
| #mcp-hint code{color:var(--tg);} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="hdr"> | |
| <div> | |
| <div id="logo">RELAY</div> | |
| <div id="logo-sub">Agent Communication Hub · ki-fusion-labs.de</div> | |
| </div> | |
| <div id="stat-bar"> | |
| <div class="sp"><span class="sp-n" id="s-total" style="color:var(--txt)">0</span>MSGS</div> | |
| <div class="sp"><span class="sp-n" id="s-unread" style="color:var(--acc)">0</span>UNREAD</div> | |
| <div class="sp"><div class="ch-dot live" style="background:var(--int)"></div>INTERNAL</div> | |
| <div class="sp" id="sp-tg"><div class="ch-dot" id="dot-tg" style="background:var(--sub)"></div>TELEGRAM</div> | |
| <div class="sp"><div class="ch-dot live" style="background:var(--br)"></div>BROWSER</div> | |
| <div class="sp" id="sp-irc"><div class="ch-dot" style="background:var(--sub)"></div>IRC</div> | |
| </div> | |
| <button id="btn-compose">+ Compose</button> | |
| </div> | |
| <div id="toolbar"> | |
| <span class="ch-pill on" id="f-all">ALL</span> | |
| <span class="ch-pill" id="f-internal"><span class="dot" style="background:var(--int)"></span>Internal</span> | |
| <span class="ch-pill" id="f-telegram"><span class="dot" style="background:var(--tg)"></span>Telegram</span> | |
| <span class="ch-pill" id="f-browser"><span class="dot" style="background:var(--br)"></span>Browser</span> | |
| <span class="ch-pill" id="f-webhook"><span class="dot" style="background:var(--wh)"></span>Webhook</span> | |
| <span class="ch-pill" id="f-irc"><span class="dot" style="background:var(--irc)"></span>IRC</span> | |
| <span class="ch-pill" id="f-email"><span class="dot" style="background:var(--em)"></span>Email</span> | |
| <span class="fsep">|</span> | |
| <select id="agent-sel"><option value="">All agents</option></select> | |
| </div> | |
| <div id="main"> | |
| <div id="feed"> | |
| <div id="feed-hdr"> | |
| <span>MESSAGE FEED</span> | |
| <div style="display:flex;align-items:center;gap:.45rem;"> | |
| <div id="live-dot"></div> | |
| <span style="font-size:.5rem;">LIVE</span> | |
| </div> | |
| </div> | |
| <div id="feed-scroll"></div> | |
| </div> | |
| <div id="right"> | |
| <div id="tabs"> | |
| <div class="tab on" id="tab-detail" >MESSAGE</div> | |
| <div class="tab" id="tab-agents" >AGENTS</div> | |
| <div class="tab" id="tab-config" >CHANNELS</div> | |
| </div> | |
| <div id="detail"><div id="d-empty">Select a message</div></div> | |
| <div id="agents-panel"></div> | |
| <div id="config-panel"></div> | |
| </div> | |
| </div> | |
| <div id="modal"> | |
| <div class="mdl"> | |
| <button id="mdl-close">✕</button> | |
| <div id="mdl-title">COMPOSE MESSAGE</div> | |
| <div class="fg"> | |
| <div class="fl"><label>From</label> | |
| <input type="text" id="mfrom" placeholder="researcher" list="ag-list-c"></div> | |
| <div class="fl"><label>To (agent or 'broadcast')</label> | |
| <input type="text" id="mto" placeholder="coder" list="ag-list-c"> | |
| <datalist id="ag-list-c"><option value="broadcast"></datalist></div> | |
| <div class="fl"><label>Channel</label> | |
| <select id="mchannel"> | |
| <option value="internal">Internal (always on)</option> | |
| <option value="telegram">Telegram</option> | |
| <option value="browser">Browser Push</option> | |
| <option value="webhook">Webhook</option> | |
| <option value="irc">IRC</option> | |
| <option value="email">Email</option> | |
| </select> | |
| </div> | |
| <div class="fl"><label>Priority</label> | |
| <select id="mpri"> | |
| <option value="low">Low</option> | |
| <option value="normal" selected>Normal</option> | |
| <option value="high">High</option> | |
| <option value="urgent">Urgent</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="fl"><label>Subject</label> | |
| <input type="text" id="msubject" placeholder="What is this about?"></div> | |
| <div class="fl"><label>Body</label> | |
| <textarea id="mbody" placeholder="Message content..."></textarea></div> | |
| <div class="fl"><label>Tags (comma separated)</label> | |
| <input type="text" id="mtags" placeholder="task, ml, urgent"></div> | |
| <div id="mdl-actions"> | |
| <button id="btn-send">→ Send Message</button> | |
| <button id="btn-mcancel">Cancel</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="toasts"></div> | |
| <div id="mcp-hint">MCP: <code>relay_send</code> | <code>GET /mcp/sse</code> | <code>relay_broadcast</code></div> | |
| <script> | |
| var MSGS=[], AGENTS=[], FILTER='all', AGENT_FILTER='', ACTIVE_ID=null; | |
| var TAB='detail'; | |
| var CH_COLORS={internal:'#0ea5e9',telegram:'#229ED9',browser:'#7c3aed', | |
| webhook:'#2ed573',irc:'#f0c040',email:'#ff6b9d',websocket:'#ff9500'}; | |
| function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} | |
| function tsAgo(ts){ | |
| if(!ts)return ''; | |
| var d=Math.floor((Date.now()/1000)-ts); | |
| if(d<60)return d+'s ago'; | |
| if(d<3600)return Math.floor(d/60)+'m ago'; | |
| if(d<86400)return Math.floor(d/3600)+'h ago'; | |
| return Math.floor(d/86400)+'d ago'; | |
| } | |
| function post(url,data){return fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});} | |
| function toast(msg,type){ | |
| var el=document.createElement('div'); | |
| el.className='tst'+(type?' '+type:''); | |
| el.textContent=msg; | |
| document.getElementById('toasts').appendChild(el); | |
| setTimeout(function(){el.remove();},2800); | |
| } | |
| // SSE live feed | |
| var sseConn=null; | |
| function connectSSE(){ | |
| if(sseConn)sseConn.close(); | |
| sseConn=new EventSource('/api/subscribe/__all__'); | |
| sseConn.onmessage=function(e){ | |
| try{ | |
| var d=JSON.parse(e.data); | |
| if(d.type==='message'){ | |
| MSGS.unshift(d.message); | |
| renderFeed(); | |
| showBrowserNotif(d.message); | |
| updateStats(); | |
| toast('['+esc(d.message.from)+'→'+esc(d.message.to)+'] '+esc(d.message.subject)); | |
| } | |
| }catch(err){} | |
| }; | |
| sseConn.onerror=function(){setTimeout(connectSSE,3000);}; | |
| } | |
| connectSSE(); | |
| // Browser notifications | |
| function requestNotifPerms(){ | |
| if('Notification' in window && Notification.permission==='default'){ | |
| Notification.requestPermission(); | |
| } | |
| } | |
| function showBrowserNotif(m){ | |
| if(!('Notification' in window)||Notification.permission!=='granted')return; | |
| new Notification('RELAY: '+m.subject,{ | |
| body:m.from+' -> '+m.to+': '+m.body.substring(0,80), | |
| icon:'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="16" fill="%23ff6b00"/><text x="16" y="21" text-anchor="middle" font-size="16" fill="black">R</text></svg>' | |
| }); | |
| } | |
| requestNotifPerms(); | |
| function loadData(){ | |
| Promise.all([ | |
| fetch('/api/messages?limit=100').then(function(r){return r.json();}), | |
| fetch('/api/agents').then(function(r){return r.json();}), | |
| fetch('/api/stats').then(function(r){return r.json();}) | |
| ]).then(function(results){ | |
| MSGS=results[0]; AGENTS=results[1]; | |
| var stats=results[2]; | |
| document.getElementById('s-total').textContent=stats.total; | |
| var unread=MSGS.filter(function(m){return m.status!='read';}).length; | |
| document.getElementById('s-unread').textContent=unread; | |
| // Channel active status | |
| if(stats.channels_active&&stats.channels_active.telegram){ | |
| document.getElementById('dot-tg').style.background='var(--tg)'; | |
| document.getElementById('dot-tg').classList.add('live'); | |
| } | |
| populateAgentSel(); | |
| renderFeed(); | |
| renderAgentsPanel(); | |
| renderConfigPanel(stats.channels_active||{}); | |
| }).catch(function(){toast('Error loading','err');}); | |
| } | |
| function updateStats(){ | |
| fetch('/api/stats').then(function(r){return r.json();}).then(function(s){ | |
| document.getElementById('s-total').textContent=s.total; | |
| var unread=MSGS.filter(function(m){return m.status!='read';}).length; | |
| document.getElementById('s-unread').textContent=unread; | |
| }).catch(function(){}); | |
| } | |
| function populateAgentSel(){ | |
| var sel=document.getElementById('agent-sel'); | |
| var cur=sel.value; | |
| sel.innerHTML='<option value="">All agents</option>'; | |
| AGENTS.forEach(function(a){ | |
| var o=document.createElement('option');o.value=a.name;o.textContent=a.name;sel.appendChild(o); | |
| }); | |
| sel.value=cur; | |
| // Also populate compose datalist | |
| var dl=document.getElementById('ag-list-c'); | |
| dl.innerHTML='<option value="broadcast">'; | |
| AGENTS.forEach(function(a){var o=document.createElement('option');o.value=a.name;dl.appendChild(o);}); | |
| } | |
| function matchFilter(m){ | |
| if(FILTER!='all'&&m.channel!=FILTER)return false; | |
| if(AGENT_FILTER&&m.from!=AGENT_FILTER&&m.to!=AGENT_FILTER)return false; | |
| return true; | |
| } | |
| function renderFeed(){ | |
| var scroll=document.getElementById('feed-scroll'); | |
| scroll.innerHTML=''; | |
| var visible=MSGS.filter(matchFilter); | |
| if(!visible.length){ | |
| var e=document.createElement('div'); | |
| e.style.cssText='font-size:.6rem;color:var(--dim);text-align:center;padding:2rem;'; | |
| e.textContent='No messages';scroll.appendChild(e);return; | |
| } | |
| visible.forEach(function(m){scroll.appendChild(makeCard(m));}); | |
| } | |
| function makeCard(m){ | |
| var cls=['mc','ch-'+m.channel,'pri-'+m.priority,'st-'+m.status]; | |
| if(m.id==ACTIVE_ID)cls.push('active'); | |
| if(m.status=='queued'||m.status=='delivered')cls.push('unread'); | |
| var card=document.createElement('div'); | |
| card.className=cls.join(' '); | |
| var chCol=CH_COLORS[m.channel]||'#aaa'; | |
| var chLabel=m.channel.substring(0,4).toUpperCase(); | |
| var tags=(m.tags||[]).slice(0,2).map(function(t){return '<span class="mc-tag">'+esc(t)+'</span>';}).join(''); | |
| card.innerHTML= | |
| '<div class="mc-top">' | |
| +'<span class="mc-from">'+esc(m.from)+'</span>' | |
| +'<span class="mc-arrow">→</span>' | |
| +'<span class="mc-to">'+esc(m.to)+'</span>' | |
| +'<span class="mc-ch" style="background:'+chCol+'22;color:'+chCol+';border:1px solid '+chCol+'33">'+chLabel+'</span>' | |
| +'<span class="mc-pri">'+m.priority.substring(0,4).toUpperCase()+'</span>' | |
| +'</div>' | |
| +'<div class="mc-subject">'+esc(m.subject||'(no subject)')+'</div>' | |
| +'<div class="mc-body-prev">'+esc((m.body||'').substring(0,80))+'</div>' | |
| +'<div class="mc-foot">'+tags | |
| +'<span class="mc-st">'+m.status+'</span>' | |
| +'<span class="mc-time">'+tsAgo(m.created_at)+'</span>' | |
| +'</div>'; | |
| card.addEventListener('click',function(){selectMsg(m.id);}); | |
| return card; | |
| } | |
| function selectMsg(id){ | |
| ACTIVE_ID=id; | |
| document.querySelectorAll('.mc').forEach(function(c){ | |
| c.classList.toggle('active',c.id=='mc'+id);}); | |
| var m=MSGS.find(function(x){return x.id==id;}); | |
| if(!m){ | |
| fetch('/api/messages').then(function(r){return r.json();}).then(function(msgs){ | |
| var found=msgs.find(function(x){return x.id==id;}); | |
| if(found){MSGS=msgs;renderDetail(found);} | |
| }); | |
| return; | |
| } | |
| renderDetail(m); | |
| showTab('detail'); | |
| } | |
| function renderDetail(m){ | |
| var d=document.getElementById('detail'); | |
| document.getElementById('d-empty').style.display='none'; | |
| var chCol=CH_COLORS[m.channel]||'#aaa'; | |
| var tags=(m.tags||[]).map(function(t){return '<span class="d-tag">'+esc(t)+'</span>';}).join(''); | |
| var meta=m.metadata||{}; | |
| d.innerHTML='<div id="d-empty" style="display:none"></div>' | |
| +'<div class="d-ch-badge" style="background:'+chCol+'18;color:'+chCol+';border:1px solid '+chCol+'30">' | |
| +'<span style="width:7px;height:7px;border-radius:50%;background:'+chCol+';display:inline-block"></span>' | |
| +esc(m.channel.toUpperCase())+'</div>' | |
| +'<div id="d-subject">'+esc(m.subject||'(no subject)')+'</div>' | |
| +'<div class="d-route">' | |
| +'<div class="d-agent">'+esc(m.from)+'</div>' | |
| +'<div class="d-arrow">→</div>' | |
| +'<div class="d-agent">'+esc(m.to)+'</div>' | |
| +'</div>' | |
| +'<div id="d-body">'+esc(m.body||'')+'</div>' | |
| +(tags?'<div class="d-tags">'+tags+'</div>':'') | |
| +'<div class="d-meta">' | |
| +'<div><div class="dml">Priority</div><div class="dmv">'+esc(m.priority)+'</div></div>' | |
| +'<div><div class="dml">Status</div><div class="dmv">'+esc(m.status)+'</div></div>' | |
| +'<div><div class="dml">Sent</div><div class="dmv">'+tsAgo(m.created_at)+'</div></div>' | |
| +'<div><div class="dml">Delivered</div><div class="dmv">'+(m.delivered_at?tsAgo(m.delivered_at):'β')+'</div></div>' | |
| +'<div><div class="dml">Read</div><div class="dmv">'+(m.read_at?tsAgo(m.read_at):'β')+'</div></div>' | |
| +'<div><div class="dml">ID</div><div class="dmv" style="font-size:.46rem;opacity:.45">'+esc(m.id)+'</div></div>' | |
| +(meta.dispatch_note?'<div><div class="dml">Note</div><div class="dmv" style="font-size:.55rem;opacity:.6">'+esc(meta.dispatch_note)+'</div></div>':'') | |
| +'</div>' | |
| +'<div class="d-acts">' | |
| +(m.status!='read'?'<button class="d-btn acc" id="d-ack">✓ Mark Read</button>':'') | |
| +'<button class="d-btn" id="d-reply">↩ Reply</button>' | |
| +'<button class="d-btn danger" id="d-del">🗑 Delete</button>' | |
| +'</div>'; | |
| var ackBtn=document.getElementById('d-ack'); | |
| if(ackBtn) ackBtn.addEventListener('click',function(){ | |
| fetch('/api/messages/'+m.id+'/ack',{method:'POST'}).then(function(){ | |
| m.status='read';toast('Marked read','ok');loadData(); | |
| }); | |
| }); | |
| var replyBtn=document.getElementById('d-reply'); | |
| if(replyBtn) replyBtn.addEventListener('click',function(){ | |
| document.getElementById('mfrom').value=m.to; | |
| document.getElementById('mto').value=m.from; | |
| document.getElementById('msubject').value='Re: '+m.subject; | |
| document.getElementById('mchannel').value=m.channel; | |
| openModal(); | |
| }); | |
| document.getElementById('d-del').addEventListener('click',function(){ | |
| if(!confirm('Delete this message?'))return; | |
| fetch('/api/messages/'+m.id,{method:'DELETE'}).then(function(){ | |
| toast('Deleted');ACTIVE_ID=null;loadData(); | |
| document.getElementById('detail').innerHTML='<div id="d-empty">Select a message</div>'; | |
| }); | |
| }); | |
| } | |
| function renderAgentsPanel(){ | |
| var panel=document.getElementById('agents-panel'); | |
| var html='<div class="ag-grid">'; | |
| AGENTS.forEach(function(a){ | |
| var chs=(a.channels||[]).map(function(c){ | |
| return '<span class="ag-ch" style="border:1px solid '+(CH_COLORS[c]||'#333')+'22;color:'+(CH_COLORS[c]||'#666')+'">'+c+'</span>'; | |
| }).join(''); | |
| html+='<div class="ag-card">' | |
| +'<div class="ag-name">'+esc(a.name)+'</div>' | |
| +'<div class="ag-chs">'+chs+'</div>' | |
| +'<div class="ag-meta">' | |
| +(a.telegram_chat_id?'TG: '+esc(a.telegram_chat_id.substring(0,8))+'...<br>':'') | |
| +(a.webhook_url?'WH: configured<br>':'') | |
| +(a.email?'✉: '+esc(a.email)+'<br>':'') | |
| +(a.irc_nick?'IRC: '+esc(a.irc_nick):'') | |
| +'</div></div>'; | |
| }); | |
| html+='</div><button id="btn-add-agent">+ Register Agent</button>'; | |
| panel.innerHTML=html; | |
| document.getElementById('btn-add-agent').addEventListener('click',function(){ | |
| var name=prompt('Agent name:');if(!name)return; | |
| fetch('/api/agents',{method:'POST',headers:{'Content-Type':'application/json'}, | |
| body:JSON.stringify({name:name,channels:['internal','browser']})}) | |
| .then(function(){toast('Agent registered','ok');loadData();}); | |
| }); | |
| } | |
| function renderConfigPanel(active){ | |
| var panel=document.getElementById('config-panel'); | |
| function statusBadge(on){return '<span class="status '+(on?'on':'off')+'">'+(on?'ACTIVE':'not configured')+'</span>';} | |
| panel.innerHTML= | |
| '<div class="cfg-section">' | |
| +'<div class="cfg-title"><span class="dot" style="background:var(--tg)"></span>TELEGRAM'+statusBadge(active.telegram)+'</div>' | |
| +'<div class="cfg-grid"><div class="ci"><label>TELEGRAM_BOT_TOKEN (env var)</label>' | |
| +'<input type="text" id="cfg-tg-note" placeholder="Set as HF Space secret" readonly style="opacity:.5">' | |
| +'<span class="status '+(active.telegram?'on':'off')+'">'+(active.telegram?'ACTIVE β bot running':'Set TELEGRAM_BOT_TOKEN as HF Space secret')+'</span></div>' | |
| +'<div class="ci"><label>Set webhook URL after deploy</label>' | |
| +'<input type="text" value="https://YOUR_SPACE.hf.space/api/telegram/webhook" readonly style="opacity:.5"></div>' | |
| +'</div></div>' | |
| +'<div class="cfg-section">' | |
| +'<div class="cfg-title"><span class="dot" style="background:var(--wh)"></span>WEBHOOK</div>' | |
| +'<div class="cfg-grid"><div class="ci"><label>Per-agent webhook URL</label>' | |
| +'<input type="text" placeholder="Configured per agent in Agents tab"></div></div></div>' | |
| +'<div class="cfg-section">' | |
| +'<div class="cfg-title"><span class="dot" style="background:var(--irc)"></span>IRC'+statusBadge(true)+'</div>' | |
| +'<div class="cfg-grid">' | |
| +'<div class="ci"><label>RELAY_IRC_HOST env var</label><input type="text" placeholder="irc.libera.chat (default)" readonly style="opacity:.5"></div>' | |
| +'<div class="ci"><label>RELAY_IRC_CHANNEL env var</label><input type="text" placeholder="#agents (default)" readonly style="opacity:.5"></div>' | |
| +'<div class="ci"><label>RELAY_IRC_NICK env var</label><input type="text" placeholder="relay-bot (default)" readonly style="opacity:.5"></div>' | |
| +'</div></div>' | |
| +'<div class="cfg-section">' | |
| +'<div class="cfg-title"><span class="dot" style="background:var(--em)"></span>EMAIL (SMTP)'+statusBadge(active.smtp)+'</div>' | |
| +'<div class="cfg-grid">' | |
| +'<div class="ci"><label>RELAY_SMTP_HOST</label><input type="text" placeholder="smtp.gmail.com" readonly style="opacity:.5"></div>' | |
| +'<div class="ci"><label>RELAY_SMTP_USER</label><input type="text" placeholder="user@gmail.com" readonly style="opacity:.5"></div>' | |
| +'</div></div>' | |
| +'<div class="cfg-section">' | |
| +'<div class="cfg-title"><span class="dot" style="background:var(--br)"></span>BROWSER PUSH (SSE)</div>' | |
| +'<div style="font-size:.62rem;color:var(--sub);line-height:1.6">' | |
| +'Always active. Subscribe: <code style="color:var(--br)">GET /api/subscribe/{agent}</code><br>' | |
| +'WebSocket: <code style="color:var(--ws)">ws://YOUR_SPACE.hf.space/ws/{agent}</code><br>' | |
| +'Browser notifications: click Allow when prompted.</div></div>' | |
| +'<div class="cfg-section">' | |
| +'<div class="cfg-title"><span class="dot" style="background:var(--acc)"></span>MCP CONFIG</div>' | |
| +'<div style="font-size:.62rem;color:var(--sub);line-height:1.8;background:var(--s1);border:1px solid var(--bd);border-radius:6px;padding:.8rem 1rem;font-family:var(--font)">' | |
| +'{ "mcpServers": { "relay": {<br>' | |
| +' "command": "npx",<br>' | |
| +' "args": ["-y", "mcp-remote",<br>' | |
| +' "https://YOUR_SPACE.hf.space/mcp/sse"]<br>' | |
| +'}}}</div></div>'; | |
| } | |
| // Tabs | |
| document.getElementById('tab-detail').addEventListener('click',function(){showTab('detail');}); | |
| document.getElementById('tab-agents').addEventListener('click',function(){showTab('agents');}); | |
| document.getElementById('tab-config').addEventListener('click',function(){showTab('config');}); | |
| function showTab(t){ | |
| TAB=t; | |
| document.getElementById('tab-detail').className='tab'+(t=='detail'?' on':''); | |
| document.getElementById('tab-agents').className='tab'+(t=='agents'?' on':''); | |
| document.getElementById('tab-config').className='tab'+(t=='config'?' on':''); | |
| document.getElementById('detail').className='tab-panel'+(t=='detail'?' on':''); | |
| document.getElementById('agents-panel').className='tab-panel'+(t=='agents'?' on':''); | |
| document.getElementById('config-panel').className='tab-panel'+(t=='config'?' on':''); | |
| // Fix display | |
| document.getElementById('detail').style.display=t=='detail'?'block':'none'; | |
| document.getElementById('agents-panel').style.display=t=='agents'?'block':'none'; | |
| document.getElementById('config-panel').style.display=t=='config'?'block':'none'; | |
| } | |
| showTab('detail'); | |
| // Filters | |
| ['all','internal','telegram','browser','webhook','irc','email'].forEach(function(f){ | |
| document.getElementById('f-'+f).addEventListener('click',function(){ | |
| FILTER=f; | |
| ['all','internal','telegram','browser','webhook','irc','email'].forEach(function(x){ | |
| var el=document.getElementById('f-'+x); | |
| if(!el)return; | |
| el.className='ch-pill'+(x==f?' '+(f=='all'?'on':f+'-on'):''); | |
| }); | |
| renderFeed(); | |
| }); | |
| }); | |
| document.getElementById('agent-sel').addEventListener('change',function(){ | |
| AGENT_FILTER=this.value;renderFeed();}); | |
| // Modal | |
| function openModal(){ | |
| document.getElementById('modal').classList.add('open'); | |
| setTimeout(function(){document.getElementById('mfrom').focus();},80); | |
| } | |
| function closeModal(){document.getElementById('modal').classList.remove('open');} | |
| document.getElementById('btn-compose').addEventListener('click',openModal); | |
| document.getElementById('mdl-close').addEventListener('click',closeModal); | |
| document.getElementById('btn-mcancel').addEventListener('click',closeModal); | |
| document.getElementById('modal').addEventListener('click',function(e){if(e.target===this)closeModal();}); | |
| document.getElementById('btn-send').addEventListener('click',function(){ | |
| var body=document.getElementById('mbody').value.trim(); | |
| if(!body){document.getElementById('mbody').focus();toast('Body required','err');return;} | |
| var to=document.getElementById('mto').value.trim()||'broadcast'; | |
| var tags=document.getElementById('mtags').value.split(',').map(function(t){return t.trim();}).filter(Boolean); | |
| var pay={ | |
| from:document.getElementById('mfrom').value.trim()||'user', | |
| to:to, | |
| channel:document.getElementById('mchannel').value, | |
| priority:document.getElementById('mpri').value, | |
| subject:document.getElementById('msubject').value.trim(), | |
| body:body,tags:tags | |
| }; | |
| post('/api/messages',pay).then(function(r){return r.json();}) | |
| .then(function(d){ | |
| toast('Sent via '+pay.channel+(d.dispatch_status=='failed'?' (dispatch failed)':''),'ok'); | |
| closeModal();loadData(); | |
| // Clear | |
| ['mfrom','mto','msubject','mbody','mtags'].forEach(function(id){document.getElementById(id).value='';}); | |
| }).catch(function(e){toast('Error: '+e.message,'err');}); | |
| }); | |
| document.addEventListener('keydown',function(e){ | |
| if(e.key=='Escape')closeModal(); | |
| var active=document.activeElement; | |
| var typing=active&&(active.tagName=='INPUT'||active.tagName=='TEXTAREA'||active.tagName=='SELECT'); | |
| if(e.key=='n'&&!typing&&!e.ctrlKey&&!e.metaKey)openModal(); | |
| if((e.ctrlKey||e.metaKey)&&e.key=='Enter'&&document.getElementById('modal').classList.contains('open')) | |
| document.getElementById('btn-send').click(); | |
| }); | |
| loadData(); | |
| </script> | |
| </body> | |
| </html>""" |