Implement LiveKit calls

This commit is contained in:
2026-04-22 23:19:38 +03:00
Unverified
parent b7d884df58
commit 66a1060685
16 changed files with 465 additions and 27 deletions
+2 -1
View File
@@ -18,4 +18,5 @@ rich>=13.9.4
slowapi>=0.1.9
firebase_admin>=7.1.0
PyNaCl>=1.5.0
numpy
numpy
livekit-api>=0.8.0
+21 -10
View File
@@ -10,7 +10,7 @@ import logging
from sqlalchemy.orm.exc import DetachedInstanceError
# Import from same directory
from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging
from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging, livekit
from .models import User
from .constants import OWNER_USERNAME
from .utils import get_client_ip
@@ -284,17 +284,27 @@ if add_security_middleware:
add_security_middleware(app)
# CORS
_lan_ip = os.getenv("LAN_IP", "").strip()
_cors_origins = [
"https://fromchat.ru",
"https://beta.fromchat.ru",
"https://www.fromchat.ru",
"http://127.0.0.1:8301",
"http://127.0.0.1:8300",
"http://localhost:8301",
"http://localhost:8300",
]
if _lan_ip:
_cors_origins.extend(
[
f"http://{_lan_ip}:8301",
f"http://{_lan_ip}:8300",
]
)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://fromchat.ru",
"https://beta.fromchat.ru",
"https://www.fromchat.ru",
"http://127.0.0.1:8301",
"http://127.0.0.1:8300",
"http://localhost:8301",
"http://localhost:8300",
],
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -307,6 +317,7 @@ app.include_router(messaging.router)
app.include_router(profile.router)
app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc")
app.include_router(livekit.router, prefix="/livekit")
app.include_router(devices.router, prefix="/devices")
app.include_router(moderation.router)
app.include_router(download.router)
+100
View File
@@ -0,0 +1,100 @@
"""
Mint LiveKit participant JWTs for DM calls. Requires LIVEKIT_API_KEY, LIVEKIT_API_SECRET,
and LIVEKIT_URL (WebSocket URL for clients, e.g. wss://livekit.example.com or ws://host:7880).
"""
from __future__ import annotations
import logging
import os
import uuid
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ..dependencies import get_current_user, get_db
from ..models import User
logger = logging.getLogger("uvicorn.error")
router = APIRouter()
class LiveKitTokenRequest(BaseModel):
peer_user_id: int = Field(..., description="The other participant (DM peer)")
room_name: str | None = Field(
None,
description="Existing room from an invite; omit to create a new room",
)
class LiveKitTokenResponse(BaseModel):
server_url: str
token: str
room_name: str
def _livekit_env() -> tuple[str, str, str]:
api_key = os.getenv("LIVEKIT_API_KEY", "").strip()
api_secret = os.getenv("LIVEKIT_API_SECRET", "").strip()
server_url = os.getenv("LIVEKIT_URL", "").strip()
if not api_key or not api_secret or not server_url:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="LiveKit is not configured (LIVEKIT_API_KEY / LIVEKIT_API_SECRET / LIVEKIT_URL)",
)
return api_key, api_secret, server_url
@router.post("/token", response_model=LiveKitTokenResponse)
async def create_livekit_token(
body: LiveKitTokenRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Issue a short-lived JWT for joining a 1:1 call room with peer_user_id.
"""
if body.peer_user_id == user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="peer_user_id must differ from caller")
peer = db.query(User).filter(User.id == body.peer_user_id).first()
if not peer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Peer user not found")
api_key, api_secret, server_url = _livekit_env()
if body.room_name and body.room_name.strip():
room_name = body.room_name.strip()
else:
room_name = f"call-{uuid.uuid4().hex}"
try:
from livekit.api import AccessToken, VideoGrants
except ImportError as e:
logger.exception("livekit-api not installed")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="LiveKit SDK unavailable on server",
) from e
grants = VideoGrants(
room_join=True,
room=room_name,
can_publish=True,
can_subscribe=True,
can_publish_data=True,
)
token = (
AccessToken(api_key, api_secret)
.with_identity(str(user.id))
.with_name(user.username or str(user.id))
.with_ttl(timedelta(hours=1))
.with_grants(grants)
)
jwt_token = token.to_jwt()
return LiveKitTokenResponse(server_url=server_url, token=jwt_token, room_name=room_name)
+3 -1
View File
@@ -46,10 +46,12 @@ os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
def _get_file_storage_url() -> str:
lan = os.getenv("LAN_IP", "").strip()
default_fs = f"http://{lan}:8302" if lan else "http://127.0.0.1:8302"
return (
os.getenv("FILE_STORAGE_SERVICE_URL")
or os.getenv("FILE_STORAGE_URL")
or "http://127.0.0.1:8302"
or default_fs
)
_SPAM_WINDOW_SECONDS = 45
+15 -8
View File
@@ -18,6 +18,13 @@ from fastapi import HTTPException, status
logger = logging.getLogger("uvicorn.error")
def _default_file_storage_base_url() -> str:
lan = os.getenv("LAN_IP", "").strip()
if lan:
return f"http://{lan}:8302"
return "http://127.0.0.1:8302"
def _get_messaging_module():
try:
from backend.services.messaging import main as messaging_module
@@ -151,7 +158,7 @@ async def upload_file_to_storage(file_obj: Any, timeout: float = 30.0) -> Dict[s
# Out-of-process HTTP
# Prefer explicit FILE_STORAGE_URL, fall back to FILE_STORAGE_SERVICE_URL, default to localhost for dev
storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302"
storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url()
url = f"{storage_url.rstrip('/')}/upload"
try:
try:
@@ -219,7 +226,7 @@ async def store_encrypted_file(
# Out-of-process HTTP
# Prefer explicit FILE_STORAGE_URL, fall back to FILE_STORAGE_SERVICE_URL, default to localhost for dev
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302"
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url()
url = f"{file_storage_url.rstrip('/')}/upload-base64"
try:
try:
@@ -463,7 +470,7 @@ async def init_resumable_upload_in_storage(
logger.error("In-process file_storage.init_resumable_upload failed: %s", e)
raise
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302"
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url()
url = f"{file_storage_url.rstrip('/')}/uploads/resumable/init"
payload = {
"filename": filename,
@@ -493,7 +500,7 @@ async def get_resumable_upload_status_in_storage(
logger.error("In-process file_storage.get_resumable_upload_status failed: %s", e)
raise
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302"
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url()
url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}"
import httpx
@@ -520,7 +527,7 @@ async def upload_resumable_chunk_in_storage(
logger.error("In-process file_storage.upload_resumable_chunk failed: %s", e)
raise
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302"
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url()
url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}"
payload = {
"offset": offset,
@@ -547,7 +554,7 @@ async def complete_resumable_upload_in_storage(
logger.error("In-process file_storage.complete_resumable_upload failed: %s", e)
raise
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302"
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url()
url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}/complete"
import httpx
@@ -570,7 +577,7 @@ async def get_resumable_upload_data_in_storage(
logger.error("In-process file_storage.get_resumable_upload_data failed: %s", e)
raise
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302"
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url()
url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}/data-b64"
import httpx
@@ -593,7 +600,7 @@ async def delete_resumable_upload_in_storage(
logger.error("In-process file_storage.delete_resumable_upload failed: %s", e)
raise
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302"
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or _default_file_storage_base_url()
url = f"{file_storage_url.rstrip('/')}/uploads/resumable/{upload_id}"
import httpx