agent-relay / main.py
Chris4K's picture
Update main.py
564ebba verified
"""
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 ──────────────────────────────────────────────────────
@app.get("/api/messages")
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)
@app.get("/api/inbox/{agent}")
async def get_inbox(agent: str, unread: bool = False):
return jresp(agent_inbox(agent, unread))
@app.post("/api/messages")
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)
@app.post("/api/broadcast")
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)
@app.post("/api/messages/{mid}/ack")
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})
@app.delete("/api/messages/{mid}")
async def delete_message(mid: str):
p = msg_path(mid)
if not p.exists(): raise HTTPException(404)
p.unlink()
return jresp({"status":"deleted"})
@app.get("/api/agents")
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"]))
@app.post("/api/agents")
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})
@app.get("/api/stats")
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) ────────────────────────────────────────────
@app.get("/api/subscribe/{agent}")
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 ─────────────────────────────────────────────────────
@app.websocket("/ws/{agent}")
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 ──────────────────────────────────────────────
@app.post("/api/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 }
@app.post("/api/notify")
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": "&#9989; Approve",
"url": f"{base_url}/api/approval/{aid}/approve?source=relay"},
{"text": "&#10060; 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":"&#9989; Approve","url":f"{APPROVE_URL}/api/approval/{aid}/approve"},
{"text":"&#10060; 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}"})
@app.get("/mcp/sse")
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"})
@app.post("/mcp")
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 ───────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
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 &mdash; 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 &middot; 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">&#x2715;</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">&#8594; 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> &nbsp;|&nbsp; <code>GET /mcp/sse</code> &nbsp;|&nbsp; <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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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)+'&#8594;'+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">&#8594;</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">&#8594;</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">&#10003; Mark Read</button>':'')
+'<button class="d-btn" id="d-reply">&#8617; Reply</button>'
+'<button class="d-btn danger" id="d-del">&#128465; 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?'&#9993;: '+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>'
+'&nbsp;&nbsp;"command": "npx",<br>'
+'&nbsp;&nbsp;"args": ["-y", "mcp-remote",<br>'
+'&nbsp;&nbsp;&nbsp;&nbsp;"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>"""