{{ room.name }}
+{{ room.description }}
+diff --git a/ambassadors/fastapi-ambassador-leaderboard/data/business_rooms.json b/ambassadors/fastapi-ambassador-leaderboard/data/business_rooms.json new file mode 100644 index 0000000000..95c3fdaca6 --- /dev/null +++ b/ambassadors/fastapi-ambassador-leaderboard/data/business_rooms.json @@ -0,0 +1,23 @@ +[ + { + "id": "strategy", + "name": "Strategy War Room", + "description": "Plan high-impact raids, governance pushes, and cross-community launches.", + "topic": "Campaign Coordination", + "ghost_key": null + }, + { + "id": "product-labs", + "name": "Product & Labs", + "description": "Deep dives into protocol upgrades, feature requests, and testing feedback.", + "topic": "Product Feedback", + "ghost_key": null + }, + { + "id": "intel-vault", + "name": "Intel Vault", + "description": "Sensitive intel drops and counter-fud briefings. Share carefully.", + "topic": "Confidential", + "ghost_key": "ghost-7777" + } +] diff --git a/ambassadors/fastapi-ambassador-leaderboard/main.py b/ambassadors/fastapi-ambassador-leaderboard/main.py index e635463060..6773a4d182 100644 --- a/ambassadors/fastapi-ambassador-leaderboard/main.py +++ b/ambassadors/fastapi-ambassador-leaderboard/main.py @@ -4,10 +4,9 @@ import ipaddress import mimetypes import base64 -import os, json, shutil, shlex, subprocess +import os, json, shutil, shlex, subprocess, time import secrets import hashlib -import subprocess import csv import io import string @@ -20,7 +19,17 @@ import pandas as pd import httpx from dotenv import load_dotenv -from fastapi import FastAPI, Form, Query, Request, UploadFile, File +from fastapi import ( + FastAPI, + HTTPException, + Form, + Query, + Request, + UploadFile, + File, + WebSocket, + WebSocketDisconnect, +) from fastapi.responses import JSONResponse, RedirectResponse, FileResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -163,6 +172,328 @@ def _load_registrations() -> set[str]: MIGRATIONS_DIR / "pending_all_v2.complete" ) +BUSINESS_ROOMS_PATH = Path( + os.getenv("BUSINESS_ROOMS_PATH", "data/business_rooms.json") +).resolve() +BUSINESS_ROOMS_REFRESH_SECONDS = int( + os.getenv("BUSINESS_ROOMS_REFRESH_SECONDS", "15") +) +BUSINESS_ROOM_ACCESS_TTL = int( + os.getenv("BUSINESS_ROOM_ACCESS_TTL", str(4 * 3600)) +) +BUSINESS_CHAT_HISTORY_LIMIT = int( + os.getenv("BUSINESS_CHAT_HISTORY_LIMIT", "200") +) +BUSINESS_SIGNAL_MAX_BYTES = int( + os.getenv("BUSINESS_SIGNAL_MAX_BYTES", str(256 * 1024)) +) + +BUSINESS_DEFAULT_ROOMS = [ + { + "id": "strategy", + "name": "Strategy War Room", + "description": "Coordinate high-impact launches and raid responses.", + "topic": "Campaign Coordination", + "ghost_key": "", + }, + { + "id": "product-labs", + "name": "Product & Labs", + "description": "Feedback loop for feature requests, QA, and roadmap insights.", + "topic": "Product Feedback", + "ghost_key": "", + }, +] + +_business_rooms_cache: list[dict[str, Any]] = [] +_business_rooms_cached_at: float = 0.0 +_business_rooms_mtime: float | None = None + + +def _slugify(value: str) -> str: + base = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-") + return base or secrets.token_hex(4) + + +def _initials(value: str) -> str: + parts = [segment[0] for segment in str(value or "").split() if segment] + if not parts: + return "IG" + if len(parts) == 1: + return parts[0].upper() + return (parts[0] + parts[1]).upper() + + +def _business_room_key(room_id: str) -> str: + return f"business:room:{room_id}" + + +def _business_room_chat_key(room_id: str) -> str: + return f"{_business_room_key(room_id)}:chat" + + +def _business_room_access_key(room_id: str, email: str) -> str: + email_norm = _normalize_email(email) + return f"business:access:{room_id}:{email_norm}" + + +def _coerce_business_room(entry: dict[str, Any]) -> dict[str, Any]: + name_raw = _safe_text(entry.get("name") or "") + topic_raw = _safe_text(entry.get("topic") or "") + description_raw = _safe_text(entry.get("description") or "") + ghost_key = str(entry.get("ghost_key") or "").strip() + slug = _slugify(entry.get("id") or entry.get("slug") or name_raw or ghost_key) + return { + "id": slug, + "name": name_raw or slug.replace("-", " ").title(), + "topic": topic_raw or "Open Collaboration", + "description": description_raw, + "ghost_key": ghost_key, + "is_private": bool(ghost_key), + } + + +async def _load_business_rooms(force_refresh: bool = False) -> list[dict[str, Any]]: + global _business_rooms_cache, _business_rooms_cached_at, _business_rooms_mtime + + now = time.monotonic() + if ( + not force_refresh + and _business_rooms_cache + and now - _business_rooms_cached_at < BUSINESS_ROOMS_REFRESH_SECONDS + ): + return [dict(room) for room in _business_rooms_cache] + + rooms_data: list[dict[str, Any]] = [] + path = BUSINESS_ROOMS_PATH + if path.exists(): + try: + text = path.read_text(encoding="utf-8") + payload = json.loads(text) + if isinstance(payload, list): + rooms_data = [ + _coerce_business_room(room) + for room in payload + if isinstance(room, dict) + ] + except Exception as exc: + print(f"[business] failed to load rooms: {exc}") + + if not rooms_data: + rooms_data = [_coerce_business_room(room) for room in BUSINESS_DEFAULT_ROOMS] + + # deduplicate by id preserving order + seen: set[str] = set() + deduped: list[dict[str, Any]] = [] + for room in rooms_data: + rid = room.get("id") or "" + if not rid or rid in seen: + continue + seen.add(rid) + deduped.append(room) + + _business_rooms_cache = [dict(room) for room in deduped] + _business_rooms_cached_at = now + _business_rooms_mtime = path.stat().st_mtime if path.exists() else None + return [dict(room) for room in deduped] + + +async def _business_rooms(refresh: bool = False) -> list[dict[str, Any]]: + rooms = await _load_business_rooms(force_refresh=refresh) + sanitized: list[dict[str, Any]] = [] + for room in rooms: + sanitized.append( + { + "id": room["id"], + "name": room["name"], + "topic": room["topic"], + "description": room.get("description", ""), + "is_private": bool(room.get("ghost_key")), + } + ) + return sanitized + + +async def _business_room(room_id: str) -> dict[str, Any] | None: + rooms = await _load_business_rooms() + for room in rooms: + if room.get("id") == room_id: + return dict(room) + return None + + +async def _business_room_history( + room_id: str, limit: int = BUSINESS_CHAT_HISTORY_LIMIT +) -> list[dict[str, Any]]: + if limit <= 0: + return [] + key = _business_room_chat_key(room_id) + entries = await redis_client.lrange(key, -limit, -1) + history: list[dict[str, Any]] = [] + for raw in entries: + try: + entry = json.loads(raw) + except Exception: + continue + history.append(entry) + history.sort(key=lambda m: m.get("timestamp", "")) + return history + + +async def _append_business_message(room_id: str, message: dict[str, Any]) -> None: + payload = json.dumps(message) + key = _business_room_chat_key(room_id) + pipe = redis_client.pipeline() + pipe.rpush(key, payload) + pipe.ltrim(key, -BUSINESS_CHAT_HISTORY_LIMIT, -1) + await pipe.execute() + + +async def _business_profile(email: str) -> dict[str, Any]: + email_norm = _normalize_email(email) + profile = await redis_client.hgetall(f"user:{email_norm}") + display_name = _safe_text(profile.get("name") or "") + if not display_name: + local_part = email_norm.split("@")[0] + display_name = local_part.replace(".", " ").title() + telegram = _safe_text(profile.get("telegram") or "") + initials = _initials(display_name) + color_seed = hashlib.sha1(email_norm.encode()).hexdigest()[:6] + return { + "email": email_norm, + "display_name": display_name, + "telegram": telegram, + "initials": initials, + "color": f"#{color_seed}", + } + + +async def _has_business_access(room: dict[str, Any], email: str) -> bool: + if not room.get("ghost_key"): + return True + key = _business_room_access_key(room["id"], email) + return bool(await redis_client.exists(key)) + + +class BusinessRoomManager: + def __init__(self) -> None: + self._connections: dict[str, set[WebSocket]] = {} + self._participants: dict[str, dict[str, dict[str, Any]]] = {} + + def active_counts(self) -> dict[str, int]: + return { + room_id: len(members) + for room_id, members in self._participants.items() + } + + async def connect( + self, room_id: str, websocket: WebSocket, participant: dict[str, Any] + ) -> None: + await websocket.accept() + self._connections.setdefault(room_id, set()).add(websocket) + self._participants.setdefault(room_id, {})[participant["id"]] = { + "info": participant, + "websocket": websocket, + } + await self._broadcast_presence(room_id) + + async def disconnect(self, room_id: str, participant_id: str) -> None: + members = self._participants.get(room_id, {}) + member = members.pop(participant_id, None) + if not member: + return + websocket = member["websocket"] + conns = self._connections.get(room_id) + if conns is not None: + if websocket in conns: + conns.remove(websocket) + if not conns: + self._connections.pop(room_id, None) + if not members: + self._participants.pop(room_id, None) + await self._broadcast_presence(room_id) + + async def _broadcast_presence(self, room_id: str) -> None: + members = self._participants.get(room_id, {}) + payload = { + "type": "presence", + "participants": [ + { + "id": data["info"]["id"], + "display_name": data["info"]["display_name"], + "telegram": data["info"].get("telegram", ""), + "initials": data["info"].get("initials", ""), + "color": data["info"].get("color", "#4aa3ff"), + } + for data in members.values() + ], + } + await self._broadcast(room_id, payload) + + async def _broadcast( + self, room_id: str, message: dict[str, Any], skip: str | None = None + ) -> None: + members = self._participants.get(room_id, {}) + if not members: + return + text = json.dumps(message) + for pid, data in list(members.items()): + if skip and pid == skip: + continue + websocket = data["websocket"] + try: + await websocket.send_text(text) + except Exception as exc: + print(f"[business] failed to send to {pid}: {exc}") + + async def send_welcome(self, room_id: str, participant_id: str) -> None: + member = self._participants.get(room_id, {}).get(participant_id) + if not member: + return + info = member["info"] + payload = { + "type": "welcome", + "self": { + "id": info["id"], + "display_name": info["display_name"], + "telegram": info.get("telegram", ""), + "initials": info.get("initials", ""), + "color": info.get("color", "#4aa3ff"), + "email": info.get("email", ""), + }, + } + try: + await member["websocket"].send_text(json.dumps(payload)) + except Exception as exc: + print(f"[business] failed to send welcome to {participant_id}: {exc}") + + async def send_history(self, room_id: str, participant_id: str) -> None: + member = self._participants.get(room_id, {}).get(participant_id) + if not member: + return + history = await _business_room_history(room_id) + payload = {"type": "history", "messages": history[-BUSINESS_CHAT_HISTORY_LIMIT:]} + try: + await member["websocket"].send_text(json.dumps(payload)) + except Exception as exc: + print(f"[business] failed to send history to {participant_id}: {exc}") + + async def broadcast_message( + self, room_id: str, message: dict[str, Any], sender_id: str + ) -> None: + await self._broadcast(room_id, message, skip=None) + + async def broadcast_signal( + self, room_id: str, signal: dict[str, Any], sender_id: str + ) -> None: + await self._broadcast(room_id, signal, skip=sender_id) + + async def broadcast_system(self, room_id: str, message: dict[str, Any]) -> None: + await self._broadcast(room_id, message) + + +business_manager = BusinessRoomManager() + # Legacy in-memory cache kept for reference. Redis is the primary cache, but # retain the structure for forward compatibility when new metrics are added. cache: Dict[str, Any] = { @@ -2079,6 +2410,13 @@ async def _current_email(request: Request) -> str | None: return await redis_client.get(f"session:{token}") +async def _current_email_ws(websocket: WebSocket) -> str | None: + token = websocket.cookies.get("session") + if not token: + return None + return await redis_client.get(f"session:{token}") + + async def _current_admin(request: Request) -> bool: token = request.cookies.get("admin") if not token: @@ -4442,6 +4780,234 @@ async def tasks_page(request: Request) -> Any: ) +@app.get("/business") +async def business_panel(request: Request) -> Any: + email = await _current_email(request) + if not email: + return RedirectResponse("/login") + + profile = await _business_profile(email) + rooms = await _business_rooms() + counts = business_manager.active_counts() + + for room in rooms: + room["active"] = counts.get(room["id"], 0) + + return templates.TemplateResponse( + "business.html", + { + "request": request, + "rooms": rooms, + "profile": profile, + }, + ) + + +@app.get("/api/business/rooms") +async def business_rooms_api(request: Request) -> JSONResponse: + email = await _current_email(request) + if not email: + return JSONResponse({"error": "unauthorized"}, status_code=401) + + rooms = await _business_rooms() + counts = business_manager.active_counts() + for room in rooms: + room["active"] = counts.get(room["id"], 0) + return JSONResponse({"rooms": rooms}) + + +@app.get("/business/room/{room_id}") +async def business_room_view(request: Request, room_id: str) -> Any: + email = await _current_email(request) + if not email: + return RedirectResponse("/login") + + room = await _business_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + profile = await _business_profile(email) + has_access = await _has_business_access(room, email) + error_msg = _safe_text(request.query_params.get("error", "")).strip() + ws_url = request.url_for("business_room_socket", room_id=room_id) + ws_url = ws_url.replace("http", "ws", 1) + + boot_payload = { + "roomId": room["id"], + "roomName": room["name"], + "roomTopic": room.get("topic", ""), + "isPrivate": bool(room.get("ghost_key")), + "wsUrl": ws_url, + "user": { + "displayName": profile["display_name"], + "email": profile["email"], + "telegram": profile.get("telegram", ""), + "initials": profile.get("initials", ""), + "color": profile.get("color", "#4aa3ff"), + }, + } + + return templates.TemplateResponse( + "business_room.html", + { + "request": request, + "room": { + "id": room["id"], + "name": room["name"], + "topic": room.get("topic", ""), + "description": room.get("description", ""), + "is_private": bool(room.get("ghost_key")), + }, + "profile": profile, + "has_access": has_access, + "error": error_msg, + "boot_payload": json.dumps(boot_payload), + "history_limit": BUSINESS_CHAT_HISTORY_LIMIT, + }, + ) + + +@app.post("/business/room/{room_id}/access") +async def business_room_access( + request: Request, room_id: str, ghost_key: str = Form("") +) -> RedirectResponse: + email = await _current_email(request) + if not email: + return RedirectResponse("/login") + + room = await _business_room(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + expected = room.get("ghost_key") or "" + provided = str(ghost_key or "").strip() + if expected and secrets.compare_digest(provided, expected): + key = _business_room_access_key(room["id"], email) + await redis_client.setex(key, BUSINESS_ROOM_ACCESS_TTL, "1") + return RedirectResponse(f"/business/room/{room_id}", status_code=303) + + if expected: + msg = quote_plus("Invalid ghost key") + return RedirectResponse( + f"/business/room/{room_id}?error={msg}", status_code=303 + ) + + return RedirectResponse(f"/business/room/{room_id}", status_code=303) + + +@app.websocket("/ws/business/{room_id}") +async def business_room_socket(websocket: WebSocket, room_id: str) -> None: + email = await _current_email_ws(websocket) + if not email: + await websocket.close(code=4401) + return + + room = await _business_room(room_id) + if not room: + await websocket.close(code=4404) + return + + if not await _has_business_access(room, email): + await websocket.close(code=4403) + return + + profile = await _business_profile(email) + participant_id = secrets.token_urlsafe(8) + participant = { + "id": participant_id, + "display_name": profile["display_name"], + "telegram": profile.get("telegram", ""), + "initials": profile.get("initials", ""), + "color": profile.get("color", "#4aa3ff"), + "email": profile["email"], + } + + await business_manager.connect(room_id, websocket, participant) + await business_manager.send_welcome(room_id, participant_id) + await business_manager.send_history(room_id, participant_id) + + join_message = { + "type": "system", + "message": f"{participant['display_name']} joined the room", + "timestamp": datetime.utcnow().isoformat(), + } + await business_manager.broadcast_system(room_id, join_message) + + try: + while True: + data = await websocket.receive_text() + if not data: + continue + if len(data) > BUSINESS_SIGNAL_MAX_BYTES: + continue + try: + payload = json.loads(data) + except json.JSONDecodeError: + continue + + msg_type = payload.get("type") + if msg_type == "chat": + body = _safe_text(payload.get("body", "")).strip() + if not body: + continue + if len(body) > 2000: + body = body[:2000] + timestamp = datetime.utcnow().isoformat() + message = { + "type": "chat", + "id": secrets.token_hex(8), + "sender": { + "id": participant_id, + "display_name": participant["display_name"], + "initials": participant.get("initials", ""), + "color": participant.get("color", "#4aa3ff"), + }, + "body": body, + "timestamp": timestamp, + } + await _append_business_message(room_id, message) + await business_manager.broadcast_message( + room_id, message, participant_id + ) + elif msg_type == "signal": + signal_name = str(payload.get("signal") or "").strip() + if not signal_name: + continue + signal_payload = { + "type": "signal", + "signal": signal_name, + "from": participant_id, + "timestamp": datetime.utcnow().isoformat(), + "payload": payload.get("payload"), + } + target = str(payload.get("target") or "").strip() + if target: + signal_payload["target"] = target + await business_manager.broadcast_signal( + room_id, signal_payload, participant_id + ) + elif msg_type == "ping": + await websocket.send_text(json.dumps({"type": "pong"})) + except WebSocketDisconnect: + leave_message = { + "type": "system", + "message": f"{participant['display_name']} left the room", + "timestamp": datetime.utcnow().isoformat(), + } + await business_manager.broadcast_system(room_id, leave_message) + await business_manager.disconnect(room_id, participant_id) + except Exception as exc: + print(f"[business] websocket error: {exc}") + leave_message = { + "type": "system", + "message": f"{participant['display_name']} left the room", + "timestamp": datetime.utcnow().isoformat(), + } + await business_manager.broadcast_system(room_id, leave_message) + await business_manager.disconnect(room_id, participant_id) + await websocket.close(code=1011) + + @app.post("/tasks/apply") async def tasks_apply(request: Request, task_id: str = Form(...)) -> RedirectResponse: email = await _current_email(request) diff --git a/ambassadors/fastapi-ambassador-leaderboard/static/business.js b/ambassadors/fastapi-ambassador-leaderboard/static/business.js new file mode 100644 index 0000000000..9a1cc1c520 --- /dev/null +++ b/ambassadors/fastapi-ambassador-leaderboard/static/business.js @@ -0,0 +1,697 @@ +(() => { + const ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }]; + + function formatTime(iso) { + if (!iso) return ''; + try { + const date = new Date(iso); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } catch (err) { + return ''; + } + } + + function createElement(tag, opts = {}) { + const el = document.createElement(tag); + Object.entries(opts).forEach(([key, value]) => { + if (key === 'class') { + el.className = value; + } else if (key === 'text') { + el.textContent = value; + } else if (key === 'html') { + el.innerHTML = value; + } else if (value !== undefined) { + el.setAttribute(key, value); + } + }); + return el; + } + + function BusinessRoomBoot(config) { + if (!config || !config.wsUrl) { + console.warn('Business room boot aborted: invalid payload'); + return; + } + + const state = { + config, + ws: null, + selfId: null, + awaitingReady: false, + inCall: false, + audioMuted: false, + localStream: null, + screenStream: null, + peerConnections: new Map(), + participants: new Map(), + remoteContainers: new Map(), + destroyed: false, + }; + + const els = { + messageStream: document.getElementById('messageStream'), + messageCounter: document.getElementById('messageCounter'), + chatForm: document.getElementById('chatForm'), + chatInput: document.getElementById('chatInput'), + participantList: document.getElementById('participantList'), + joinCallBtn: document.getElementById('joinCallBtn'), + muteCallBtn: document.getElementById('muteCallBtn'), + leaveCallBtn: document.getElementById('leaveCallBtn'), + shareScreenBtn: document.getElementById('shareScreenBtn'), + localPreview: document.getElementById('localPreview'), + screenPreview: document.getElementById('screenPreview'), + remoteMedia: document.getElementById('remoteMedia'), + }; + + const supportsWebRTC = typeof window.RTCPeerConnection === 'function' && navigator.mediaDevices; + if (!supportsWebRTC) { + disableCallControls('Voice and screen share require a modern browser with WebRTC support.'); + } + + bindEvents(); + connectWebSocket(); + + function bindEvents() { + if (els.chatForm) { + els.chatForm.addEventListener('submit', (event) => { + event.preventDefault(); + const body = (els.chatInput?.value || '').trim(); + if (!body) return; + if (state.ws?.readyState !== WebSocket.OPEN) return; + state.ws.send(JSON.stringify({ type: 'chat', body })); + els.chatInput.value = ''; + }); + } + + if (els.joinCallBtn) { + els.joinCallBtn.addEventListener('click', async () => { + if (!supportsWebRTC || state.inCall) return; + await joinCall(); + }); + } + + if (els.leaveCallBtn) { + els.leaveCallBtn.addEventListener('click', () => { + if (!state.inCall) return; + leaveCall(); + }); + } + + if (els.muteCallBtn) { + els.muteCallBtn.addEventListener('click', () => { + if (!state.localStream) return; + state.audioMuted = !state.audioMuted; + state.localStream.getAudioTracks().forEach((track) => { + track.enabled = !state.audioMuted; + }); + els.muteCallBtn.textContent = state.audioMuted ? 'Unmute' : 'Mute'; + els.muteCallBtn.classList.toggle('btn-warning', state.audioMuted); + }); + } + + if (els.shareScreenBtn) { + els.shareScreenBtn.addEventListener('click', () => { + if (!state.inCall) return; + if (state.screenStream) { + stopScreenShare(); + } else { + startScreenShare(); + } + }); + } + } + + function disableCallControls(reason) { + [els.joinCallBtn, els.muteCallBtn, els.leaveCallBtn, els.shareScreenBtn].forEach((btn) => { + if (btn) { + btn.disabled = true; + btn.classList.add('disabled'); + } + }); + if (reason && els.messageStream) { + pushSystemMessage(reason); + } + } + + function updateCallButtons() { + if (!supportsWebRTC) return; + if (els.joinCallBtn) { + els.joinCallBtn.disabled = state.inCall; + els.joinCallBtn.classList.toggle('disabled', state.inCall); + } + if (els.leaveCallBtn) { + els.leaveCallBtn.disabled = !state.inCall; + } + if (els.muteCallBtn) { + els.muteCallBtn.disabled = !state.inCall; + els.muteCallBtn.textContent = state.audioMuted ? 'Unmute' : 'Mute'; + els.muteCallBtn.classList.toggle('btn-warning', state.audioMuted); + } + if (els.shareScreenBtn) { + els.shareScreenBtn.disabled = !state.inCall; + els.shareScreenBtn.textContent = state.screenStream ? 'Stop share' : 'Share screen'; + } + } + + function connectWebSocket() { + try { + state.ws = new WebSocket(config.wsUrl); + } catch (err) { + pushSystemMessage('Unable to connect to business room. Please refresh.'); + return; + } + + state.ws.addEventListener('open', () => { + pushSystemMessage('Connected to the business room.'); + if (state.inCall && state.selfId) { + sendSignal('ready'); + } + }); + + state.ws.addEventListener('message', (event) => { + try { + const payload = JSON.parse(event.data); + handleSocketMessage(payload); + } catch (err) { + console.warn('Invalid websocket payload', err); + } + }); + + state.ws.addEventListener('close', () => { + pushSystemMessage('Connection closed. Refresh to reconnect.'); + leaveCall(); + }); + + state.ws.addEventListener('error', () => { + pushSystemMessage('A network error occurred.'); + }); + } + + function handleSocketMessage(data) { + if (!data || typeof data !== 'object') return; + switch (data.type) { + case 'welcome': + state.selfId = data.self?.id || null; + if (state.awaitingReady && state.selfId) { + sendSignal('ready'); + state.awaitingReady = false; + } + if (state.inCall && state.selfId && state.participants.size) { + state.participants.forEach((participant, pid) => { + if (!pid || pid === state.selfId) return; + const entry = ensureConnection(pid); + if (entry && entry.isInitiator && entry.pc.signalingState === 'stable' && entry.pc.connectionState !== 'connected') { + maybeInitiateOffer(pid); + } + }); + } + break; + case 'history': + renderHistory(data.messages || []); + break; + case 'chat': + appendMessage(data); + break; + case 'system': + pushSystemMessage(data.message, data.timestamp); + break; + case 'presence': + updateParticipants(data.participants || []); + break; + case 'signal': + handleSignal(data); + break; + case 'pong': + break; + default: + break; + } + } + + function renderHistory(messages) { + if (!els.messageStream) return; + els.messageStream.innerHTML = ''; + (messages || []).forEach((message) => appendMessage(message)); + } + + function appendMessage(message) { + if (!els.messageStream || !message) return; + const type = message.type || 'chat'; + const item = createElement('div', { class: 'message-item' }); + if (type === 'system') { + item.classList.add('system-message'); + const meta = formatTime(message.timestamp); + item.textContent = meta ? `[${meta}] ${message.message}` : message.message; + } else { + const bubble = createElement('div', { class: 'message-bubble' }); + const header = createElement('div', { class: 'd-flex justify-content-between align-items-center mb-1' }); + const sender = createElement('div', { class: 'fw-semibold' }); + const meta = createElement('div', { class: 'message-meta' }); + const body = createElement('div'); + const displayName = message.sender?.display_name || 'Ambassador'; + const initials = message.sender?.initials || '?'; + const color = message.sender?.color || '#4aa3ff'; + const badge = createElement('span', { + class: 'badge', + text: initials, + }); + badge.style.background = color; + badge.style.color = '#0d1117'; + badge.style.marginRight = '0.5rem'; + sender.appendChild(badge); + sender.append(document.createTextNode(displayName)); + meta.textContent = formatTime(message.timestamp); + body.textContent = message.body || ''; + header.appendChild(sender); + header.appendChild(meta); + bubble.appendChild(header); + bubble.appendChild(body); + item.appendChild(bubble); + } + els.messageStream.appendChild(item); + if (els.messageStream.childElementCount) { + if (els.messageCounter) { + const total = els.messageStream.childElementCount; + els.messageCounter.textContent = `${total} message${total === 1 ? '' : 's'}`; + } + els.messageStream.scrollTop = els.messageStream.scrollHeight; + } + } + + function pushSystemMessage(message, timestamp) { + appendMessage({ type: 'system', message, timestamp: timestamp || new Date().toISOString() }); + } + + function updateParticipants(participants) { + state.participants.clear(); + participants.forEach((participant) => { + if (participant?.id) { + state.participants.set(participant.id, participant); + } + }); + if (!els.participantList) return; + els.participantList.innerHTML = ''; + const list = participants.slice().sort((a, b) => a.display_name.localeCompare(b.display_name)); + list.forEach((participant) => { + const pill = createElement('div', { class: 'participant-pill' }); + const avatar = createElement('div', { class: 'participant-avatar', text: participant.initials || '?' }); + avatar.style.background = participant.color || '#4aa3ff'; + const meta = createElement('div'); + const name = createElement('div', { class: 'fw-semibold', text: participant.display_name || 'Ambassador' }); + const handle = participant.telegram ? createElement('div', { class: 'small text-info', text: participant.telegram }) : null; + meta.appendChild(name); + if (handle) meta.appendChild(handle); + pill.appendChild(avatar); + pill.appendChild(meta); + els.participantList.appendChild(pill); + updateRemoteLabel(participant.id, participant.display_name); + }); + if (state.inCall && state.selfId) { + list.forEach((participant) => { + if (!participant.id || participant.id === state.selfId) return; + const entry = ensureConnection(participant.id); + if (!entry) return; + entry.isInitiator = state.selfId > participant.id; + if (entry.isInitiator && entry.pc.signalingState === 'stable' && entry.pc.connectionState !== 'connected') { + maybeInitiateOffer(participant.id); + } + }); + } + } + + function updateRemoteLabel(remoteId, name) { + const container = state.remoteContainers.get(remoteId); + if (container) { + const label = container.querySelector('.remote-name'); + if (label) label.textContent = name || 'Ambassador'; + } + } + + function ensureRemoteContainer(remoteId) { + let container = state.remoteContainers.get(remoteId); + if (container) return container; + const wrapper = createElement('div', { class: 'remote-card p-3 bg-dark bg-opacity-50 rounded-3', 'data-remote': remoteId }); + const header = createElement('div', { class: 'd-flex justify-content-between align-items-center mb-2' }); + const name = createElement('div', { class: 'fw-semibold remote-name', text: 'Ambassador' }); + const status = createElement('span', { class: 'badge bg-info-subtle text-info-emphasis', text: 'Live' }); + header.appendChild(name); + header.appendChild(status); + const audio = document.createElement('audio'); + audio.autoplay = true; + audio.controls = false; + audio.className = 'remote-audio w-100'; + const video = document.createElement('video'); + video.autoplay = true; + video.playsInline = true; + video.muted = false; + video.className = 'remote-video w-100 mt-2'; + video.classList.add('d-none'); + wrapper.appendChild(header); + wrapper.appendChild(audio); + wrapper.appendChild(video); + if (els.remoteMedia) { + els.remoteMedia.appendChild(wrapper); + } + state.remoteContainers.set(remoteId, wrapper); + const participant = state.participants.get(remoteId); + if (participant?.display_name) { + name.textContent = participant.display_name; + } + return wrapper; + } + + function removeRemoteContainer(remoteId) { + const container = state.remoteContainers.get(remoteId); + if (container && container.parentElement) { + container.parentElement.removeChild(container); + } + state.remoteContainers.delete(remoteId); + } + + function handleSignal(message) { + const from = message.from; + if (!from || from === state.selfId) return; + const target = message.target; + if (target && state.selfId && target !== state.selfId) return; + const signal = message.signal; + const payload = message.payload || {}; + + switch (signal) { + case 'ready': + if (state.inCall) { + ensureConnection(from); + maybeInitiateOffer(from); + } + break; + case 'offer': + handleOffer(from, payload.sdp); + break; + case 'answer': + handleAnswer(from, payload.sdp); + break; + case 'ice': + handleIceCandidate(from, payload.candidate); + break; + case 'leave-call': + closeConnection(from, 'left the call'); + break; + case 'renegotiate': + triggerRenegotiation(from); + break; + case 'screen-share': + handleRemoteScreenSignal(from, payload); + break; + default: + break; + } + } + + function ensureConnection(remoteId) { + if (state.peerConnections.has(remoteId)) { + return state.peerConnections.get(remoteId); + } + if (!supportsWebRTC) return null; + const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); + const entry = { + pc, + isInitiator: state.selfId ? state.selfId > remoteId : false, + audioSender: null, + screenSender: null, + }; + state.peerConnections.set(remoteId, entry); + + pc.onicecandidate = (event) => { + if (event.candidate) { + sendSignal('ice', { candidate: event.candidate }, remoteId); + } + }; + + pc.ontrack = (event) => { + const [stream] = event.streams; + const kind = event.track.kind; + const container = ensureRemoteContainer(remoteId); + if (kind === 'audio') { + const audioEl = container.querySelector('.remote-audio'); + if (audioEl) { + audioEl.srcObject = stream; + } + } else if (kind === 'video') { + const videoEl = container.querySelector('.remote-video'); + if (videoEl) { + videoEl.srcObject = stream; + videoEl.classList.remove('d-none'); + } + } + event.track.addEventListener('ended', () => { + if (kind === 'video') { + const videoEl = container.querySelector('.remote-video'); + if (videoEl) { + videoEl.srcObject = null; + videoEl.classList.add('d-none'); + } + } + }); + }; + + pc.onconnectionstatechange = () => { + if (['failed', 'closed', 'disconnected'].includes(pc.connectionState)) { + closeConnection(remoteId, pc.connectionState); + } + }; + + attachLocalTracks(remoteId); + return entry; + } + + function attachLocalTracks(remoteId) { + const entry = state.peerConnections.get(remoteId); + if (!entry) return; + if (state.localStream) { + const audioTrack = state.localStream.getAudioTracks()[0]; + if (audioTrack && !entry.audioSender) { + entry.audioSender = entry.pc.addTrack(audioTrack, state.localStream); + } + } + if (state.screenStream) { + const videoTrack = state.screenStream.getVideoTracks()[0]; + if (videoTrack && !entry.screenSender) { + entry.screenSender = entry.pc.addTrack(videoTrack, state.screenStream); + } + } + } + + async function maybeInitiateOffer(remoteId) { + const entry = ensureConnection(remoteId); + if (!entry) return; + if (!state.localStream) return; + if (!entry.isInitiator) { + // let the peer initiate + return; + } + try { + const offer = await entry.pc.createOffer(); + await entry.pc.setLocalDescription(offer); + sendSignal('offer', { sdp: offer }, remoteId); + } catch (err) { + console.warn('offer failed', err); + } + } + + async function handleOffer(remoteId, sdp) { + if (!state.inCall) return; + const entry = ensureConnection(remoteId); + if (!entry) return; + entry.isInitiator = false; + try { + await entry.pc.setRemoteDescription(new RTCSessionDescription(sdp)); + attachLocalTracks(remoteId); + const answer = await entry.pc.createAnswer(); + await entry.pc.setLocalDescription(answer); + sendSignal('answer', { sdp: answer }, remoteId); + } catch (err) { + console.warn('answer failed', err); + } + } + + async function handleAnswer(remoteId, sdp) { + const entry = state.peerConnections.get(remoteId); + if (!entry) return; + try { + await entry.pc.setRemoteDescription(new RTCSessionDescription(sdp)); + } catch (err) { + console.warn('setRemoteDescription failed', err); + } + } + + async function handleIceCandidate(remoteId, candidate) { + const entry = state.peerConnections.get(remoteId); + if (!entry || !candidate) return; + try { + await entry.pc.addIceCandidate(new RTCIceCandidate(candidate)); + } catch (err) { + console.warn('ice candidate failed', err); + } + } + + function triggerRenegotiation(remoteId) { + const entry = state.peerConnections.get(remoteId); + if (!entry) return; + if (entry.isInitiator) { + maybeInitiateOffer(remoteId); + } + } + + function handleRemoteScreenSignal(remoteId, payload) { + const active = Boolean(payload?.active); + const container = state.remoteContainers.get(remoteId); + if (!container) return; + const videoEl = container.querySelector('.remote-video'); + if (videoEl && !active) { + videoEl.srcObject = null; + videoEl.classList.add('d-none'); + } + } + + async function joinCall() { + if (!supportsWebRTC) { + pushSystemMessage('Voice is not available in this browser.'); + return; + } + try { + state.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + state.localStream.getAudioTracks().forEach((track) => { + track.enabled = !state.audioMuted; + }); + if (els.localPreview) { + els.localPreview.srcObject = state.localStream; + els.localPreview.muted = true; + els.localPreview.play().catch(() => {}); + } + state.inCall = true; + updateCallButtons(); + if (state.selfId) { + sendSignal('ready'); + } else { + state.awaitingReady = true; + } + } catch (err) { + pushSystemMessage('Microphone access is required to join voice.'); + console.warn('join call failed', err); + } + } + + function leaveCall() { + if (!state.inCall) return; + state.inCall = false; + updateCallButtons(); + if (state.ws?.readyState === WebSocket.OPEN && state.selfId) { + sendSignal('leave-call'); + } + if (state.screenStream) { + stopScreenShare(); + } + if (state.localStream) { + state.localStream.getTracks().forEach((track) => track.stop()); + state.localStream = null; + } + if (els.localPreview) { + els.localPreview.srcObject = null; + } + if (els.screenPreview) { + els.screenPreview.srcObject = null; + } + Array.from(state.peerConnections.keys()).forEach((remoteId) => { + closeConnection(remoteId, 'call ended'); + }); + } + + async function startScreenShare() { + if (!supportsWebRTC || !state.inCall) return; + try { + const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); + state.screenStream = stream; + const track = stream.getVideoTracks()[0]; + if (els.screenPreview) { + els.screenPreview.srcObject = stream; + els.screenPreview.muted = true; + els.screenPreview.play().catch(() => {}); + } + track.addEventListener('ended', () => { + stopScreenShare(); + }); + state.peerConnections.forEach((entry, remoteId) => { + if (!entry.screenSender) { + entry.screenSender = entry.pc.addTrack(track, stream); + } else { + entry.screenSender.replaceTrack(track).catch(() => {}); + } + if (entry.isInitiator) { + maybeInitiateOffer(remoteId); + } else { + sendSignal('renegotiate', { reason: 'screen-share' }, remoteId); + } + }); + sendSignal('screen-share', { active: true }); + updateCallButtons(); + } catch (err) { + console.warn('screen share failed', err); + } + } + + function stopScreenShare() { + if (!state.screenStream) return; + state.screenStream.getTracks().forEach((track) => track.stop()); + state.screenStream = null; + if (els.screenPreview) { + els.screenPreview.srcObject = null; + } + state.peerConnections.forEach((entry, remoteId) => { + if (entry.screenSender) { + entry.screenSender.replaceTrack(null).catch(() => {}); + entry.screenSender = null; + } + if (entry.isInitiator) { + maybeInitiateOffer(remoteId); + } else { + sendSignal('renegotiate', { reason: 'screen-stop' }, remoteId); + } + }); + sendSignal('screen-share', { active: false }); + updateCallButtons(); + } + + function closeConnection(remoteId, reason) { + const entry = state.peerConnections.get(remoteId); + if (entry) { + try { + entry.pc.getSenders().forEach((sender) => { + try { + sender.track?.stop(); + } catch (err) { + // ignore + } + }); + entry.pc.close(); + } catch (err) { + console.warn('close connection error', err); + } + } + state.peerConnections.delete(remoteId); + removeRemoteContainer(remoteId); + if (reason) { + const participant = state.participants.get(remoteId); + const name = participant?.display_name || 'An ambassador'; + pushSystemMessage(`${name} ${reason}.`); + } + } + + function sendSignal(signal, payload = {}, target) { + if (state.ws?.readyState !== WebSocket.OPEN) return; + const message = { type: 'signal', signal, payload }; + if (target) message.target = target; + state.ws.send(JSON.stringify(message)); + } + } + + window.BusinessRoomBoot = BusinessRoomBoot; +})(); diff --git a/ambassadors/fastapi-ambassador-leaderboard/templates/business.html b/ambassadors/fastapi-ambassador-leaderboard/templates/business.html new file mode 100644 index 0000000000..4b3e95c1fe --- /dev/null +++ b/ambassadors/fastapi-ambassador-leaderboard/templates/business.html @@ -0,0 +1,166 @@ + + +
+ + +{{ room.description }}
+Leadership locked this room. Enter the ghost key to join the briefing.
+ {% if error %} +{{ room.description }}
+