877 lines
29 KiB
Python
877 lines
29 KiB
Python
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/<int:invite_id>/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/<int:invite_id>/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/<int:session_id>")
|
|
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/<int:session_id>/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/<int:session_id>/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/<int:session_id>/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/<int:session_id>/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/<int:session_id>/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/<int:dm_id>/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/<int:call_id>")
|
|
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/<int:call_id>/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/<int:call_id>/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/<int:call_id>/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)})
|