mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement LiveKit calls
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user