Files
foodlinkk-command-center/cockpit/app/routes/agents_api.py
T

886 lines
31 KiB
Python
Raw Normal View History

"""Agents API — souls & activity."""
from __future__ import annotations
import asyncio
import json
from typing import Any, Optional
from fastapi import File, UploadFile, APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.db import fetch_all, fetch_one
from app.services import agent_approvals, agent_integration, agent_souls
from app.services.agent_names import normalize_agent_key
from app.services.agent_terminal import execute_terminal_command
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
class SoulUpdate(BaseModel):
display_name: Optional[str] = None
role_title: Optional[str] = None
soul_md: Optional[str] = None
responsibilities: Optional[str] = None
is_active: Optional[bool] = None
2026-06-09 10:41:13 +00:00
class ActionRequestBody(BaseModel):
agent_key: str
title: str = Field(..., min_length=1, max_length=255)
action_type: str = "query"
query_payload: dict[str, Any] = Field(default_factory=dict)
class RejectBody(BaseModel):
reason: str = ""
class ExecuteBody(BaseModel):
result: dict[str, Any] = Field(default_factory=dict)
class HandoffBody(BaseModel):
from_agent: str
to_agent: str
handoff_type: str = "partner"
payload: dict[str, Any] = Field(default_factory=dict)
correlation_id: Optional[str] = None
class TerminalCommandBody(BaseModel):
command: str = Field(..., min_length=1, max_length=2000)
def _aggregate_agent_stats() -> dict[str, dict[str, Any]]:
from datetime import datetime, timedelta, timezone
rows = fetch_all(
"""
SELECT agent_name, title, status, created_at
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '7 days'
ORDER BY created_at DESC
"""
)
now = datetime.now(timezone.utc)
by_key: dict[str, dict[str, Any]] = {}
for r in rows:
key = normalize_agent_key(str(r.get("agent_name") or ""))
if not key:
continue
bucket = by_key.setdefault(
key,
{"events_6h": 0, "errors_24h": 0, "last_event_at": None, "last_event_title": None},
)
created = r.get("created_at")
if bucket["last_event_at"] is None and created is not None:
bucket["last_event_at"] = created
bucket["last_event_title"] = r.get("title")
if created is not None:
if hasattr(created, "tzinfo") and created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if created >= now - timedelta(hours=6):
bucket["events_6h"] += 1
if created >= now - timedelta(hours=24) and str(r.get("status") or "") in ("error", "rejected"):
bucket["errors_24h"] += 1
return by_key
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
if events_6h > 0 and errors_24h == 0:
return "healthy"
if events_6h > 0:
return "warn"
if event_count > 0:
return "idle"
return "offline"
@router.get("/collaboration")
def api_collaboration() -> dict[str, Any]:
items = agent_integration.list_collaboration()
return {"items": items, "count": len(items)}
@router.post("/handoff")
def api_create_handoff(body: HandoffBody) -> dict[str, Any]:
try:
handoff = agent_integration.create_handoff(
body.from_agent,
body.to_agent,
handoff_type=body.handoff_type,
payload=body.payload,
correlation_id=body.correlation_id,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "handoff": handoff}
def _terminal_payload(ev: dict[str, Any]) -> dict[str, Any]:
if ev.get("created_at") and hasattr(ev["created_at"], "isoformat"):
ev["created_at"] = ev["created_at"].isoformat()
meta = ev.get("metadata")
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
ev["metadata"] = meta or {}
line_type = "handoff" if "handoff" in str(ev.get("event_type") or "") else "action"
if str(ev.get("event_type") or "").startswith("terminal_"):
line_type = "command" if ev.get("event_type") == "terminal_in" else "output"
if ev["metadata"].get("target_agent"):
line_type = "handoff_out"
if ev["metadata"].get("source_agent"):
line_type = "handoff_in"
if str(ev.get("status") or "") in ("error", "rejected"):
line_type = "error"
return {
"type": line_type,
"id": ev.get("id"),
"agent": normalize_agent_key(ev.get("agent_name")),
"message": ev.get("title") or ev.get("event_type"),
"detail": (ev.get("body") or "")[:500],
"status": ev.get("status"),
"correlation_id": ev["metadata"].get("correlation_id"),
"at": ev.get("created_at"),
}
@router.get("/souls/{agent_key}/terminal/stream")
async def api_agent_terminal_stream(agent_key: str, correlation_id: Optional[str] = None):
key = normalize_agent_key(agent_key)
soul = agent_souls.get_soul(key)
if not soul:
raise HTTPException(404, "Agent not found")
async def event_gen():
last_id = 0
try:
hist = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE LOWER(agent_name) = %s
ORDER BY id DESC
LIMIT 30
""",
(key,),
)
for row in reversed(hist):
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception:
pass
while True:
try:
if correlation_id:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE metadata->>'correlation_id' = %s
AND id > %s
ORDER BY id ASC
LIMIT 50
""",
(correlation_id, last_id),
)
else:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE LOWER(agent_name) = %s AND id > %s
ORDER BY id ASC
LIMIT 50
""",
(key, last_id),
)
for row in rows:
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception as exc:
err = {"type": "error", "message": str(exc)}
yield f"data: {json.dumps(err)}\n\n"
await asyncio.sleep(0.4)
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/live/stream")
async def api_agents_live_stream():
"""Single realtime stream for all agent terminals."""
async def event_gen():
last_id = 0
try:
recent = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '30 minutes'
ORDER BY id ASC
LIMIT 80
"""
)
for row in recent:
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
payload["replay"] = True
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception:
pass
yield f"data: {json.dumps({'type': 'connected', 'last_id': last_id})}\n\n"
while True:
try:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
FROM agent_events
WHERE id > %s
ORDER BY id ASC
LIMIT 100
""",
(last_id,),
)
for row in rows:
last_id = max(last_id, int(row["id"]))
payload = _terminal_payload(dict(row))
yield f"data: {json.dumps(payload, default=str)}\n\n"
except Exception as exc:
err = {"type": "error", "message": str(exc)}
yield f"data: {json.dumps(err)}\n\n"
await asyncio.sleep(0.25)
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/souls")
def api_list_souls() -> dict[str, Any]:
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
@router.get("/souls/{agent_key}")
def api_get_soul(agent_key: str) -> dict[str, Any]:
soul = agent_souls.get_soul(agent_key)
if not soul:
raise HTTPException(404, "Agent not found")
return {"soul": soul}
2026-06-09 10:41:13 +00:00
@router.get("/souls/{agent_key}/events")
def api_list_agent_events(agent_key: str, limit: int = 50) -> dict[str, Any]:
soul = agent_souls.get_soul(agent_key)
if not soul:
raise HTTPException(404, "Agent not found")
items = agent_souls.list_agent_events(agent_key, limit=min(limit, 100))
return {"agent_key": agent_key.lower(), "items": items, "count": len(items)}
@router.post("/souls/{agent_key}/terminal/command")
async def api_terminal_command(agent_key: str, body: TerminalCommandBody) -> dict[str, Any]:
try:
return await execute_terminal_command(agent_key, body.command.strip())
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.put("/souls/{agent_key}")
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
try:
soul = agent_souls.update_soul(agent_key, **body.model_dump(exclude_none=True))
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
return {"soul": soul}
@router.get("/mesh")
def api_agents_mesh() -> dict[str, Any]:
souls = agent_souls.list_souls()
by_key = _aggregate_agent_stats()
nodes: list[dict[str, Any]] = []
for soul in souls:
key = str(soul.get("agent_key") or "").lower()
row = by_key.get(key, {})
events_6h = int(row.get("events_6h") or 0)
errors_24h = int(row.get("errors_24h") or 0)
health = _node_health(events_6h, errors_24h, int(soul.get("event_count") or 0))
is_active = health == "healthy" and events_6h > 0
node = dict(soul)
node["health"] = health
node["events_6h"] = events_6h
node["errors_24h"] = errors_24h
node["is_active"] = is_active
node["last_event_title"] = row.get("last_event_title")
lat = row.get("last_event_at")
if lat is not None and hasattr(lat, "isoformat"):
node["last_event_at"] = lat.isoformat()
nodes.append(node)
report_edges: list[dict[str, Any]] = []
for node in nodes:
key = str(node.get("agent_key") or "").lower()
if key == "herman" or not node.get("is_active"):
continue
report_edges.append(
{
"source": key,
"target": "herman",
"type": "report",
"active": True,
"weight": int(node.get("events_6h") or 1),
}
)
delegate_rows = fetch_all(
"""
SELECT metadata, created_at
FROM agent_events
WHERE agent_name = 'herman'
AND created_at >= NOW() - INTERVAL '6 hours'
AND (
event_type IN ('openswarm_delegation', 'delegate', 'packaging_delivered', 'telegram_delegation')
OR metadata ? 'delegated'
)
ORDER BY created_at DESC
LIMIT 100
"""
)
delegate_edges: list[dict[str, Any]] = []
seen_delegate: set[tuple[str, str]] = set()
for r in delegate_rows:
meta = r.get("metadata") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
delegated = meta.get("delegated") or meta.get("delegated_agents") or []
if isinstance(delegated, str):
delegated = [delegated]
channel = meta.get("channel") or "herman"
for agent in delegated:
tgt = normalize_agent_key(str(agent))
if not tgt or tgt == "herman":
continue
pair = ("herman", tgt)
if pair in seen_delegate:
continue
seen_delegate.add(pair)
delegate_edges.append(
{
"source": "herman",
"target": tgt,
"type": "delegate",
"active": True,
"channel": channel,
}
)
peer_static = [
{
"source": str(r["from_agent"]),
"target": str(r["to_agent"]),
"type": "static",
"handoff_type": r.get("handoff_type"),
}
for r in agent_integration.list_collaboration()
]
peer_live = agent_integration.peer_edges_live(hours=6)
peer_edges = peer_static + peer_live
herman_active = any(n.get("agent_key") == "herman" and n.get("is_active") for n in nodes)
herman_active = herman_active or bool(delegate_edges) or bool(report_edges)
executive_edges = []
if herman_active:
executive_edges = [
{"source": "herman", "target": "ceo", "type": "executive", "active": True},
{"source": "herman", "target": "cto", "type": "executive", "active": True},
]
return {
"nodes": nodes,
"report_edges": report_edges,
"delegate_edges": delegate_edges,
"peer_edges": peer_edges,
"executive_edges": executive_edges,
"edges": report_edges,
"executives": [
{"id": "ceo", "label": "CEO", "role": "Aissa"},
{"id": "cto", "label": "CTO", "role": "Platform"},
],
}
2026-06-09 10:41:13 +00:00
@router.get("/approvals")
def api_list_approvals(status: Optional[str] = None, limit: int = 50) -> dict[str, Any]:
try:
items = agent_approvals.list_requests(status=status, limit=limit)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"items": items, "count": len(items)}
@router.post("/requests")
def api_create_action_request(body: ActionRequestBody) -> dict[str, Any]:
try:
req = agent_approvals.create_request(
body.agent_key,
body.title,
action_type=body.action_type,
query_payload=body.query_payload,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
auto = None
rid = (req or {}).get("id")
if rid:
try:
auto = agent_approvals.try_auto_approve_and_execute(int(rid))
except Exception:
auto = None
msg = "Wacht op goedkeuring voordat query uitgevoerd mag worden"
if auto:
msg = "Auto-goedgekeurd en uitgevoerd (SysOps policy)"
return {"ok": True, "request": req, "auto_executed": bool(auto), "message": msg}
2026-06-09 10:41:13 +00:00
@router.post("/approvals/{request_id}/approve")
def api_approve_request(request_id: int) -> dict[str, Any]:
import os
import httpx
try:
req = agent_approvals.approve_request(request_id, approved_by="ceo")
action = req.get("action_type") or ""
auto_result = None
if action in ("config_backup", "maintenance_scan"):
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
path = "/ops/backup/run" if action == "config_backup" else "/ops/maintenance/scan"
payload = {"approval_request_id": request_id} if action == "config_backup" else None
with httpx.Client(timeout=180.0) as client:
resp = client.post(f"{tools_url}{path}", json=payload or {})
try:
auto_result = resp.json()
except Exception:
auto_result = {"ok": False, "detail": resp.text}
req = agent_approvals.mark_executed(request_id, auto_result or {})
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "request": req, "executed": auto_result is not None, "result": auto_result}
@router.post("/approvals/{request_id}/reject")
def api_reject_request(request_id: int, body: RejectBody) -> dict[str, Any]:
try:
req = agent_approvals.reject_request(request_id, reason=body.reason)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"ok": True, "request": req}
@router.post("/approvals/{request_id}/execute")
def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str, Any]:
import os
import httpx
try:
req = agent_approvals.require_approved(request_id)
result_payload = body.result or {}
action = req.get("action_type") or ""
if action == "config_backup":
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
with httpx.Client(timeout=180.0) as client:
resp = client.post(
f"{tools_url}/ops/backup/run",
json={"approval_request_id": request_id},
)
try:
result_payload = resp.json()
except Exception:
result_payload = {"ok": False, "detail": resp.text}
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=result_payload.get("detail") or resp.text)
elif action == "maintenance_scan":
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
with httpx.Client(timeout=120.0) as client:
resp = client.post(f"{tools_url}/ops/maintenance/scan")
result_payload = resp.json() if resp.status_code < 400 else {"ok": False, "detail": resp.text}
req = agent_approvals.mark_executed(request_id, result_payload)
except HTTPException:
raise
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "request": req}
# --- War Room chat ---
class WarroomMessageBody(BaseModel):
content: str = Field("", max_length=8000)
attachments: list[dict] = Field(default_factory=list)
reply_to_id: Optional[int] = None
@router.get("/warroom/rooms")
async def warroom_list_rooms() -> dict[str, Any]:
from app.services import agent_chat
agent_chat.ensure_warroom()
return {"items": agent_chat.list_rooms()}
@router.post("/warroom/rooms/dm/{agent_key}")
async def warroom_open_dm(agent_key: str) -> dict[str, Any]:
from app.services import agent_chat
try:
room = agent_chat.get_or_create_dm(agent_key)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
return {"room": room}
@router.get("/warroom/rooms/{room_id}/messages")
async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int | None = None) -> dict[str, Any]:
from app.services import agent_chat
agent_chat.ensure_warroom()
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
items = agent_chat.list_messages(room_id, limit=min(limit, 300), after_id=after_id)
deleted_ids = agent_chat.list_deleted_ids(room_id) if after_id else []
return {"ok": True, "room": room, "items": items, "deleted_ids": deleted_ids}
@router.post("/warroom/rooms/{room_id}/messages")
async def warroom_post_message(request: Request, room_id: int, body: WarroomMessageBody) -> dict[str, Any]:
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
try:
result = await agent_chat.post_user_message(
room_id,
body.content,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
reply_to_id=body.reply_to_id,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return result
@router.post("/warroom/rooms/{room_id}/messages/stream")
async def warroom_post_message_stream(request: Request, room_id: int, body: WarroomMessageBody):
"""SSE stream: user → typing tokens → agent message(s) → done."""
import json
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
async def gen():
try:
async for ev in agent_chat.post_user_message_stream(
room_id,
body.content,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
reply_to_id=body.reply_to_id,
):
yield f"data: {json.dumps(ev, default=str)}\n\n"
except ValueError as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/warroom/upload")
async def warroom_upload(file: UploadFile = File(...)) -> dict[str, Any]:
"""Upload a file to share in War Room (images/docs)."""
from pathlib import Path as P
import uuid
import re
raw_name = (file.filename or "upload.bin").strip()
safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", raw_name)[:120] or "upload.bin"
ext = P(safe).suffix.lower()
allowed = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".txt", ".csv",
".xlsx", ".xls", ".doc", ".docx", ".zip", ".json", ".md",
}
if ext not in allowed:
raise HTTPException(400, f"Bestandstype niet toegestaan: {ext or '(geen)'}")
data = await file.read()
if len(data) > 12 * 1024 * 1024:
raise HTTPException(400, "Max 12MB per bestand")
dest_dir = P(__file__).resolve().parent.parent.parent / "static" / "uploads" / "warroom"
dest_dir.mkdir(parents=True, exist_ok=True)
fname = f"{uuid.uuid4().hex[:12]}_{safe}"
(dest_dir / fname).write_bytes(data)
url = f"/static/uploads/warroom/{fname}"
return {
"ok": True,
"url": url,
"name": raw_name[:180],
"mime": file.content_type or "application/octet-stream",
"size": len(data),
}
@router.post("/warroom/digest")
async def warroom_daily_digest(hours: int = 24, deliver: bool = True) -> dict[str, Any]:
"""Generate Herman War Room day report and optionally push to Aïssa + Mo on Telegram."""
from app.services import warroom_digest
try:
return await warroom_digest.run_daily_digest(hours=max(1, min(hours, 72)), deliver=deliver)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
@router.post("/warroom/rooms/{room_id}/continue")
async def warroom_continue(request: Request, room_id: int, turns: int = 2):
"""Autonomous A2A continuation — agents keep debating with cockpit data."""
import json
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
async def gen():
try:
async for ev in agent_chat.continue_warroom_stream(
room_id,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
max_turns=max(1, min(int(turns or 2), 4)),
):
yield f"data: {json.dumps(ev, default=str)}\n\n"
except ValueError as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/warroom/ceo-inbox")
async def warroom_ceo_inbox(unread: bool = False, limit: int = 50) -> dict[str, Any]:
"""Direct messages from agents to CEO (@aissa)."""
from app.services import agent_chat
items = agent_chat.list_ceo_inbox(unread_only=unread, limit=min(limit, 100))
return {
"ok": True,
"items": items,
"unread": agent_chat.ceo_inbox_unread_count(),
}
class CeoInboxReadBody(BaseModel):
ids: Optional[list[int]] = None
all: bool = False
@router.post("/warroom/ceo-inbox/read")
async def warroom_ceo_inbox_read(body: CeoInboxReadBody) -> dict[str, Any]:
from app.services import agent_chat
n = agent_chat.mark_ceo_inbox_read(body.ids, all_unread=bool(body.all))
return {"ok": True, "marked": n, "unread": agent_chat.ceo_inbox_unread_count()}
@router.delete("/warroom/rooms/{room_id}/messages")
async def warroom_clear_messages(room_id: int) -> dict[str, Any]:
"""Soft-delete all messages in a War Room / DM."""
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
n = agent_chat.clear_messages(room_id)
return {"ok": True, "cleared": n}
@router.delete("/warroom/rooms/{room_id}/messages/{message_id}")
async def warroom_delete_message(room_id: int, message_id: int) -> dict[str, Any]:
"""Soft-delete a War Room message."""
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
row = agent_chat.delete_message(room_id, message_id)
if not row:
raise HTTPException(404, "Bericht niet gevonden")
return {"ok": True, "id": message_id}
@router.post("/warroom/rooms/{room_id}/voice-room")
async def warroom_voice_room(request: Request, room_id: int) -> dict[str, Any]:
"""Open/announce a Foodlinkk Voice Room (humans + agents via STT/TTS)."""
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
body = {}
try:
body = await request.json()
except Exception:
body = {}
action = str(body.get("action") or "join").lower()
agents = body.get("agents") or ["herman", "retail", "bizdev", "halal", "finance"]
if not isinstance(agents, list):
agents = ["herman"]
agents = [str(a).strip().lower() for a in agents if str(a).strip()][:12]
user = getattr(request.state, "user", None) or {}
who = user.get("username") if isinstance(user, dict) else "user"
if action == "leave":
agent_chat.post_system_line(
f"🎙️ {who} verliet de Voice Room",
agent_key="herman",
metadata={"kind": "voice_room", "action": "leave", "opened_by": who},
)
return {"ok": True, "action": "leave"}
agent_chat.post_system_line(
f"🎙️ Voice Room open — {who} + agents: " + ", ".join("@" + a for a in agents),
agent_key="herman",
metadata={
"kind": "voice_room",
"action": "join",
"opened_by": who,
"agents": agents,
},
)
return {"ok": True, "action": "join", "agents": agents, "host": who}
@router.post("/warroom/rooms/{room_id}/call")
async def warroom_start_call(request: Request, room_id: int) -> dict[str, Any]:
"""Open a simple no-account video room in an external HTTPS window.
Cockpit stays on HTTP. Mic/camera need HTTPS in a separate tab.
Default provider is Freifunk meet (no 8x8 login / no moderator gate).
"""
import os
from urllib.parse import quote
from app.db import execute, fetch_one
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
base = (os.getenv("WARROOM_MEET_BASE") or "https://meet.ffmuc.net").rstrip("/")
slug = f"FoodlinkkWR{room_id}"
if room.get("kind") == "dm" and room.get("agent_key"):
slug = f"FoodlinkkDM{room.get('agent_key')}{room_id}"
url = f"{base}/{quote(slug)}"
user = getattr(request.state, "user", None) or {}
who = user.get("username") if isinstance(user, dict) else "user"
recent = fetch_one(
"""SELECT id, metadata FROM warroom_messages
WHERE room_id = %s AND deleted_at IS NULL
AND metadata->>'kind' = 'call'
AND created_at > NOW() - INTERVAL '20 minutes'
ORDER BY id DESC LIMIT 1""",
(room_id,),
)
reused = bool(recent and (recent.get("metadata") or {}).get("call_url") == url)
if not reused:
execute(
"""UPDATE warroom_messages
SET deleted_at = NOW(), content = '',
metadata = COALESCE(metadata, '{}'::jsonb) || '{"deleted": true}'::jsonb
WHERE room_id = %s AND deleted_at IS NULL
AND (
metadata->>'kind' = 'call'
OR content ILIKE %s
OR content ILIKE %s
)""",
(room_id, "%War Room-call%", "%Call open%"),
)
agent_chat.post_system_line(
f"📹 Call open — geen account nodig: {url}",
agent_key="herman",
metadata={"kind": "call", "call_url": url, "room_slug": slug, "opened_by": who},
)
return {
"ok": True,
"url": url,
"slug": slug,
"provider": "ffmuc",
"embed": False,
"mode": "external",
"reused": reused,
"hint": "Cockpit blijft op HTTP. Gesprek opent in een apart venster — geen login.",
}