import time from .common import ( CALLS_BASE, CALL_HELPER_SHARED_SECRET, CALL_TOKEN_TTL_SECONDS, now_iso, parse_iso, utc_now, csrf_valid, require_user, require_writable_user, audit, safe_json, jsonify, request, get_db, db_write, sign_call_helper_token, normalize_username, get_room_by_slug, can_access_room, get_channel_by_slug, can_access_channel, validate_dm_access, get_dm_other_user, build_room_helper_call_id, build_channel_helper_call_id, get_or_create_group_call_session, group_call_session_payload, get_group_call_session_by_id, upsert_group_call_participant, heartbeat_group_call_participant, mark_group_call_participant_left, list_group_call_participants, maybe_end_group_call_if_empty, create_call_session, call_payload, get_call_with_dm, append_call_signal, list_call_signals, end_call, can_access_group_call_session, can_manage_group_call_session, group_call_scope_payload, group_call_permissions_payload, ) def register_call_routes(app): @app.route("/api/group-calls/token", methods=["POST"]) def api_group_call_token(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_writable_user() if error: return error if not CALL_HELPER_SHARED_SECRET: return jsonify({"error": "Call helper is not configured."}), 503 try: data = safe_json() except ValueError as error: return jsonify({"error": str(error)}), 400 scope_kind = str(data.get("scope_kind", "")).strip().lower() helper_call_id = None scope_id = None scope_slug = None room_value = None if scope_kind == "room": room_slug = str(data.get("room_slug", "")).strip() room = get_room_by_slug(room_slug) if not room: return jsonify({"error": "Room not found."}), 404 if not can_access_room(room, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 helper_call_id = build_room_helper_call_id(room) scope_id = room["id"] scope_slug = room["slug"] room_value = room["slug"] elif scope_kind == "channel": team_slug = str(data.get("team_slug", "")).strip() channel_slug = str(data.get("channel_slug", "")).strip() channel = get_channel_by_slug(team_slug, channel_slug) if not channel: return jsonify({"error": "Channel not found."}), 404 if not can_access_channel(channel, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 helper_call_id = build_channel_helper_call_id(channel) scope_id = channel["id"] scope_slug = f"{channel['team_slug']}/{channel['slug']}" room_value = scope_slug else: return jsonify({"error": "scope_kind must be room or channel."}), 400 session_row = get_or_create_group_call_session( scope_kind=scope_kind, scope_id=scope_id, scope_slug=scope_slug, helper_call_id=helper_call_id, created_by_user_id=user["id"], ) upsert_group_call_participant(session_row["id"], user["id"]) payload = { "sub": f"user-{user['id']}", "username": user["username"], "room": room_value, "call_id": helper_call_id, "role": user["role"], "can_call": True, "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, } token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET) audit( "group_call_token_issued", user["id"], "group_call_session", str(session_row["id"]), helper_call_id, ) return jsonify( { "token": token, "calls_base": CALLS_BASE, "call_id": helper_call_id, "session": group_call_session_payload(session_row), "participants": list_group_call_participants(session_row["id"]), "permissions": group_call_permissions_payload(session_row, user), "scope": group_call_scope_payload(session_row, user), } ) @app.route("/api/group-calls/invite", methods=["POST"]) def api_group_call_invite(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_writable_user() if error: return error if not CALL_HELPER_SHARED_SECRET: return jsonify({"error": "Call helper is not configured."}), 503 try: data = safe_json() except ValueError as error: return jsonify({"error": str(error)}), 400 session_id_raw = data.get("group_call_session_id") invite_username = normalize_username(data.get("username")) try: session_id = int(session_id_raw) except (TypeError, ValueError): return jsonify({"error": "Invalid group_call_session_id."}), 400 if not invite_username: return jsonify({"error": "username is required."}), 400 session_row = get_group_call_session_by_id(session_id) if not session_row or session_row["status"] != "active": return jsonify({"error": "Active group call not found."}), 404 if not can_access_group_call_session(session_row, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 is_active_participant = False with get_db() as db: is_active_participant = bool( db.execute( """ SELECT 1 FROM group_call_participants WHERE group_call_session_id = ? AND user_id = ? AND left_at IS NULL """, (session_row["id"], user["id"]), ).fetchone() ) if not (is_active_participant or can_manage_group_call_session(session_row, user)): return jsonify( { "error": "Only active participants or scope managers can invite users.", "code": "forbidden", } ), 403 with get_db() as db: target = db.execute( """ SELECT id, username, role, status FROM users WHERE username = ? """, (invite_username,), ).fetchone() if not target: return jsonify({"error": "User not found."}), 404 if int(target["id"]) == int(user["id"]): return jsonify({"error": "You are already in the call."}), 400 if target["status"] in {"disabled", "blacklisted"}: return jsonify({"error": "User is not available."}), 400 payload = { "sub": f"user-{target['id']}", "username": target["username"], "room": session_row["scope_slug"], "call_id": session_row["helper_call_id"], "role": target["role"], "can_call": True, "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, } helper_token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET) expires_at = (utc_now() + app.timedelta(minutes=10)).isoformat() cursor = db_write( """ INSERT INTO group_call_invites ( group_call_session_id, inviter_user_id, invitee_user_id, helper_token, status, created_at, expires_at, responded_at ) VALUES (?, ?, ?, ?, 'pending', ?, ?, NULL) """, ( session_row["id"], user["id"], target["id"], helper_token, now_iso(), expires_at, ), ) audit( "group_call_invite_created", user["id"], "group_call_session", str(session_row["id"]), f"invitee={target['username']}", ) return jsonify( { "invite": { "id": cursor.lastrowid, "invitee_username": target["username"], "group_call_session_id": session_row["id"], "status": "pending", "expires_at": expires_at, } } ), 201 @app.route("/api/group-calls/invites") def api_list_group_call_invites(): user, error = require_user() if error: return error with get_db() as db: rows = db.execute( """ SELECT i.id, i.group_call_session_id, i.status, i.created_at, i.expires_at, i.responded_at, s.scope_kind, s.scope_slug, s.helper_call_id, s.status AS session_status, inviter.username AS inviter_username FROM group_call_invites AS i JOIN group_call_sessions AS s ON s.id = i.group_call_session_id JOIN users AS inviter ON inviter.id = i.inviter_user_id WHERE i.invitee_user_id = ? AND i.status = 'pending' AND i.expires_at > ? AND s.status = 'active' ORDER BY i.id DESC """, (user["id"], now_iso()), ).fetchall() return jsonify( { "invites": [ { "id": row["id"], "group_call_session_id": row["group_call_session_id"], "scope_kind": row["scope_kind"], "scope_slug": row["scope_slug"], "helper_call_id": row["helper_call_id"], "inviter_username": row["inviter_username"], "status": row["status"], "created_at": row["created_at"], "expires_at": row["expires_at"], } for row in rows ] } ) @app.route("/api/group-calls/invites//accept", methods=["POST"]) def api_accept_group_call_invite(invite_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_writable_user() if error: return error with get_db() as db: row = db.execute( """ SELECT i.id, i.group_call_session_id, i.invitee_user_id, i.helper_token, i.status, i.expires_at, s.helper_call_id FROM group_call_invites AS i JOIN group_call_sessions AS s ON s.id = i.group_call_session_id WHERE i.id = ? AND i.invitee_user_id = ? LIMIT 1 """, (invite_id, user["id"]), ).fetchone() if not row: return jsonify({"error": "Invite not found."}), 404 if row["status"] != "pending" or parse_iso(row["expires_at"]) <= utc_now(): return jsonify({"error": "Invite expired or unavailable."}), 400 db_write( """ UPDATE group_call_invites SET status = 'accepted', responded_at = ? WHERE id = ? """, (now_iso(), invite_id), ) upsert_group_call_participant(row["group_call_session_id"], user["id"]) audit( "group_call_invite_accepted", user["id"], "group_call_invite", str(invite_id), row["helper_call_id"], ) session_row = get_group_call_session_by_id(row["group_call_session_id"]) return jsonify( { "token": row["helper_token"], "calls_base": CALLS_BASE, "call_id": row["helper_call_id"], "session": group_call_session_payload(session_row), "participants": list_group_call_participants(row["group_call_session_id"]), "permissions": group_call_permissions_payload(session_row, user), "scope": group_call_scope_payload(session_row, user), } ) @app.route("/api/group-calls/invites//dismiss", methods=["POST"]) def api_dismiss_group_call_invite(invite_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_user() if error: return error with get_db() as db: row = db.execute( """ SELECT id, group_call_session_id FROM group_call_invites WHERE id = ? AND invitee_user_id = ? LIMIT 1 """, (invite_id, user["id"]), ).fetchone() if not row: return jsonify({"error": "Invite not found."}), 404 db_write( """ UPDATE group_call_invites SET status = 'dismissed', responded_at = ? WHERE id = ? AND invitee_user_id = ? AND status = 'pending' """, (now_iso(), invite_id, user["id"]), ) audit( "group_call_invite_dismissed", user["id"], "group_call_invite", str(invite_id), ) return jsonify( { "status": "dismissed", "group_call_session_id": row["group_call_session_id"], } ) @app.route("/api/group-calls/") def api_get_group_call_session(session_id: int): user, error = require_user() if error: return error session_row = get_group_call_session_by_id(session_id) if not session_row: return jsonify({"error": "Group call session not found."}), 404 if not can_access_group_call_session(session_row, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 return jsonify( { "session": group_call_session_payload(session_row), "scope": group_call_scope_payload(session_row, user), "participants": list_group_call_participants(session_id), "permissions": group_call_permissions_payload(session_row, user), } ) @app.route("/api/group-calls//participants") def api_get_group_call_participants(session_id: int): user, error = require_user() if error: return error session_row = get_group_call_session_by_id(session_id) if not session_row: return jsonify({"error": "Group call session not found."}), 404 if not can_access_group_call_session(session_row, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 return jsonify( { "session_id": session_id, "participants": list_group_call_participants(session_id), "permissions": group_call_permissions_payload(session_row, user), "scope": group_call_scope_payload(session_row, user), } ) @app.route("/api/group-calls//join", methods=["POST"]) def api_join_group_call_session(session_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_writable_user() if error: return error session_row = get_group_call_session_by_id(session_id) if not session_row: return jsonify({"error": "Group call session not found."}), 404 if session_row["status"] != "active": return jsonify({"error": "Group call session is not active."}), 400 if not can_access_group_call_session(session_row, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 upsert_group_call_participant(session_id, user["id"]) audit( "group_call_session_joined", user["id"], "group_call_session", str(session_id), session_row["helper_call_id"], ) return jsonify( { "status": "joined", "session": group_call_session_payload(session_row), "participants": list_group_call_participants(session_id), "permissions": group_call_permissions_payload(session_row, user), "scope": group_call_scope_payload(session_row, user), } ) @app.route("/api/group-calls//heartbeat", methods=["POST"]) def api_heartbeat_group_call_session(session_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_user() if error: return error session_row = get_group_call_session_by_id(session_id) if not session_row: return jsonify({"error": "Group call session not found."}), 404 if session_row["status"] != "active": return jsonify({"error": "Group call session is not active."}), 400 if not can_access_group_call_session(session_row, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 heartbeat_group_call_participant(session_id, user["id"]) return jsonify( { "status": "ok", "session": group_call_session_payload(session_row), "participants": list_group_call_participants(session_id), } ) @app.route("/api/group-calls//leave", methods=["POST"]) def api_leave_group_call_session(session_id: int): """ keep csrf behavior for normal requests, but allow same-origin unload-style cleanup where the frontend keepalive/beacon path may not be able to send the custom header. """ user, error = require_user() if error: return error if not csrf_valid(): origin = request.headers.get("Origin", "") sec_fetch_site = request.headers.get("Sec-Fetch-Site", "") same_origin_hint = (not origin) or origin.rstrip("/") == request.host_url.rstrip("/") or sec_fetch_site == "same-origin" if not same_origin_hint: return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 session_row = get_group_call_session_by_id(session_id) if not session_row: return jsonify({"error": "Group call session not found."}), 404 if not can_access_group_call_session(session_row, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 mark_group_call_participant_left(session_id, user["id"]) maybe_end_group_call_if_empty(session_id) refreshed = get_group_call_session_by_id(session_id) audit( "group_call_session_left", user["id"], "group_call_session", str(session_id), session_row["helper_call_id"], ) return jsonify( { "status": "left", "session": group_call_session_payload(refreshed) if refreshed else None, "participants": list_group_call_participants(session_id), } ) @app.route("/api/group-calls//end", methods=["POST"]) def api_end_group_call_session(session_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_writable_user() if error: return error session_row = get_group_call_session_by_id(session_id) if not session_row: return jsonify({"error": "Group call session not found."}), 404 if not can_manage_group_call_session(session_row, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 db_write( """ UPDATE group_call_sessions SET status = 'ended', ended_at = ? WHERE id = ? """, (now_iso(), session_id), ) db_write( """ UPDATE group_call_participants SET updated_at = ?, left_at = COALESCE(left_at, ?) WHERE group_call_session_id = ? AND left_at IS NULL """, (now_iso(), now_iso(), session_id), ) db_write( """ UPDATE group_call_invites SET status = 'dismissed', responded_at = COALESCE(responded_at, ?) WHERE group_call_session_id = ? AND status = 'pending' """, (now_iso(), session_id), ) refreshed = get_group_call_session_by_id(session_id) audit( "group_call_session_ended", user["id"], "group_call_session", str(session_id), session_row["helper_call_id"], ) return jsonify( { "status": "ended", "session": group_call_session_payload(refreshed) if refreshed else None, "participants": [], } ) @app.route("/api/call-token", methods=["POST"]) def api_call_token(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_writable_user() if error: return error if not CALL_HELPER_SHARED_SECRET: return jsonify({"error": "Call helper is not configured."}), 503 try: data = safe_json() except ValueError as error: return jsonify({"error": str(error)}), 400 room_slug = str(data.get("room", "")).strip() dm_id_raw = data.get("dm_id") call_id = None room_value = None if dm_id_raw is not None: try: dm_id = int(dm_id_raw) except (TypeError, ValueError): return jsonify({"error": "Invalid dm_id."}), 400 if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 peer = get_dm_other_user(dm_id, user["id"]) if not peer: return jsonify({"error": "Direct-message peer not found."}), 404 call_id = f"dm-{dm_id}" room_value = call_id elif room_slug: room = get_room_by_slug(room_slug) if not room: return jsonify({"error": "Room not found."}), 404 if not can_access_room(room, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 call_id = f"room-{room['slug']}" room_value = room["slug"] else: return jsonify({"error": "room or dm_id is required."}), 400 payload = { "sub": f"user-{user['id']}", "username": user["username"], "room": room_value, "call_id": call_id, "role": user["role"], "can_call": True, "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, } token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET) audit( "call_helper_token_issued", user["id"], "call", call_id, f"room={room_value}", ) return jsonify( { "token": token, "calls_base": CALLS_BASE, "call_id": call_id, } ) @app.route("/api/dms//calls", methods=["POST"]) def api_start_dm_call(dm_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_writable_user() if error: return error if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 call_id = create_call_session(dm_id, user["id"]) audit("call_started", user["id"], "call", str(call_id), str(dm_id)) return jsonify({"call": call_payload(call_id)}), 201 @app.route("/api/calls/") def api_get_call(call_id: int): user, error = require_user() if error: return error call = get_call_with_dm(call_id) if not call: return jsonify({"error": "Call not found."}), 404 if not validate_dm_access(call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404 return jsonify({"call": call_payload(call_id)}) @app.route("/api/calls//signal", methods=["POST"]) def api_post_call_signal(call_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_writable_user() if error: return error call = get_call_with_dm(call_id) if not call: return jsonify({"error": "Call not found."}), 404 if not validate_dm_access(call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404 if call["status"] != "active": return jsonify({"error": "Call is not active."}), 400 try: data = safe_json() except ValueError as error: return jsonify({"error": str(error)}), 400 signal_type = str(data.get("signal_type", "")).strip()[:40] payload = data.get("payload", {}) if not signal_type: return jsonify({"error": "signal_type is required."}), 400 if not isinstance(payload, dict): return jsonify({"error": "payload must be an object."}), 400 signal_id = append_call_signal(call_id, user["id"], signal_type, payload) return jsonify({"status": "ok", "signal_id": signal_id}), 201 @app.route("/api/calls//signals") def api_get_call_signals(call_id: int): user, error = require_user() if error: return error call = get_call_with_dm(call_id) if not call: return jsonify({"error": "Call not found."}), 404 if not validate_dm_access(call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404 after_raw = str(request.args.get("after_id", "")).strip() after_id = 0 if after_raw: try: after_id = max(0, int(after_raw)) except ValueError: return jsonify({"error": "after_id must be a non-negative integer."}), 400 signals = list_call_signals(call_id, after_id) latest_id = signals[-1]["id"] if signals else after_id return jsonify({"signals": signals, "latest_id": latest_id}) @app.route("/api/calls//end", methods=["POST"]) def api_end_call(call_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_writable_user() if error: return error call = get_call_with_dm(call_id) if not call: return jsonify({"error": "Call not found."}), 404 if not validate_dm_access(call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404 end_call(call_id) audit("call_ended", user["id"], "call", str(call_id)) return jsonify({"status": "ended", "call": call_payload(call_id)})