commit c1ef5871c2294bf023f9d7fba0dda8658333bae6 Author: Noloquideus Date: Sun May 10 08:41:16 2026 +0300 init commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9c74f8f --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +APP_BASE_URL=https://linkra.ru + +LIVEKIT_URL=ws://livekit:7880 +LIVEKIT_PUBLIC_URL=wss://rtc.linkra.ru + +LIVEKIT_API_KEY=linkra_key +LIVEKIT_API_SECRET=8edc959cc8517b864f73c99c86305a1d3b3fe509309e0a6e6e35172cc6695d87 + +CORS_ALLOW_ORIGINS=https://linkra.ru \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f942fbd --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..daa8624 Binary files /dev/null and b/backend/app/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..7a69778 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000..68e6761 Binary files /dev/null and b/backend/app/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..6e5043f --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,44 @@ +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_base_url: str = Field(default="http://localhost:8080", alias="APP_BASE_URL") + livekit_url: str = Field(default="ws://livekit:7880", alias="LIVEKIT_URL") + livekit_public_url: str = Field(default="ws://localhost:7880", alias="LIVEKIT_PUBLIC_URL") + livekit_api_key: str = Field(default="devkey", alias="LIVEKIT_API_KEY") + livekit_api_secret: str = Field(default="secret", alias="LIVEKIT_API_SECRET") + call_token_ttl_hours: int = Field(default=2, alias="CALL_TOKEN_TTL_HOURS") + room_empty_timeout_seconds: int = Field(default=600, alias="ROOM_EMPTY_TIMEOUT_SECONDS") + max_attachment_size_mb: int = Field(default=100, alias="MAX_ATTACHMENT_SIZE_MB") + uploads_dir: str = Field(default="uploads", alias="UPLOADS_DIR") + telegram_alerting_enabled: bool = Field(default=False, alias="TELEGRAM_ALERTING_ENABLED") + telegram_bot_token: str = Field(default="", alias="TELEGRAM_BOT_TOKEN") + telegram_chat_id: str = Field(default="", alias="TELEGRAM_CHAT_ID") + telegram_topic_id: int | None = Field(default=None, alias="TELEGRAM_TOPIC_ID") + block_probe_paths: bool = Field(default=True, alias="BLOCK_PROBE_PATHS") + cors_allow_origins: str = Field(default="", alias="CORS_ALLOW_ORIGINS") + + model_config = SettingsConfigDict(case_sensitive=False, extra="ignore") + + @property + def max_attachment_size_bytes(self) -> int: + return self.max_attachment_size_mb * 1024 * 1024 + + @property + def uploads_path(self) -> Path: + return Path(self.uploads_dir) + + @property + def telegram_alerting_available(self) -> bool: + return bool(self.telegram_alerting_enabled and self.telegram_bot_token.strip() and self.telegram_chat_id.strip()) + + @property + def cors_origins(self) -> list[str]: + raw = getattr(self, 'cors_allow_origins', '') + return [origin.strip() for origin in raw.split(',') if origin.strip()] + + +settings = Settings() diff --git a/backend/app/livekit_service.py b/backend/app/livekit_service.py new file mode 100644 index 0000000..7bec0b4 --- /dev/null +++ b/backend/app/livekit_service.py @@ -0,0 +1,45 @@ +from datetime import timedelta + +from livekit import api + +from .config import settings + + +async def create_room_if_needed(room_name: str) -> None: + lkapi = api.LiveKitAPI( + url=settings.livekit_url, + api_key=settings.livekit_api_key, + api_secret=settings.livekit_api_secret, + ) + try: + rooms = await lkapi.room.list_rooms(api.ListRoomsRequest(names=[room_name])) + if rooms.rooms: + return + await lkapi.room.create_room( + api.CreateRoomRequest( + name=room_name, + empty_timeout=settings.room_empty_timeout_seconds, + max_participants=20, + ) + ) + finally: + await lkapi.aclose() + + +async def build_participant_token(*, room_name: str, identity: str, display_name: str) -> str: + token = ( + api.AccessToken(settings.livekit_api_key, settings.livekit_api_secret) + .with_identity(identity) + .with_name(display_name) + .with_ttl(timedelta(hours=settings.call_token_ttl_hours)) + .with_grants( + api.VideoGrants( + room_join=True, + room=room_name, + can_publish=True, + can_subscribe=True, + can_update_own_metadata=True, + ) + ) + ) + return token.to_jwt() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..f8e8d0f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,495 @@ +import json +import mimetypes +import secrets +import urllib.parse +import urllib.request +from datetime import datetime +from pathlib import Path +from shutil import rmtree +from uuid import uuid4 + +from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse, RedirectResponse +from fastapi.middleware.cors import CORSMiddleware + +from .config import settings +from .livekit_service import build_participant_token, create_room_if_needed +from .models import AttachmentRecord, CallRecord, ChatEventRecord +from .schemas import ( + AttachmentListResponse, + AttachmentResponse, + AppConfigResponse, + CallInfoResponse, + ChatAuthorSyncRequest, + ChatMessageLogRequest, + CreateCallRequest, + CreateCallResponse, + FinishCallRequest, + JoinCallRequest, + JoinCallResponse, +) +from .probe_blocker import ProbeBlockMiddleware +from .store import store + +app = FastAPI(title="Video Call MVP") +app.add_middleware(ProbeBlockMiddleware) +if settings.cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +settings.uploads_path.mkdir(parents=True, exist_ok=True) + + +def public_app_url() -> str: + return settings.app_base_url.strip().rstrip('/') + + +def build_attachment_response(room_name: str, attachment: AttachmentRecord) -> AttachmentResponse: + return AttachmentResponse( + attachment_id=attachment.attachment_id, + file_name=attachment.original_name, + content_type=attachment.content_type, + size_bytes=attachment.size_bytes, + download_url=f"/api/calls/{room_name}/attachments/{attachment.attachment_id}", + ) + + +def ensure_call_access(room_name: str, invite_token: str) -> CallRecord: + call = store.get(room_name) + if call is None or not call.is_active: + raise HTTPException(status_code=404, detail="Call not found") + if invite_token != call.invite_token: + raise HTTPException(status_code=403, detail="Invalid invite token") + return call + + +def room_upload_dir(room_name: str) -> Path: + return settings.uploads_path / room_name + + +def human_dt(value: datetime) -> str: + return value.astimezone().strftime("%d.%m.%Y %H:%M:%S %Z") + + +def format_duration(seconds: int) -> str: + minutes, secs = divmod(max(0, seconds), 60) + hours, minutes = divmod(minutes, 60) + if hours: + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + return f"{minutes:02d}:{secs:02d}" + + +def split_text_chunks(text: str, limit: int = 3500) -> list[str]: + if len(text) <= limit: + return [text] + chunks: list[str] = [] + current = "" + for line in text.splitlines(): + candidate = f"{current}\n{line}".strip() if current else line + if len(candidate) > limit and current: + chunks.append(current) + current = line + elif len(line) > limit: + if current: + chunks.append(current) + current = "" + for start in range(0, len(line), limit): + chunks.append(line[start:start + limit]) + else: + current = candidate + if current: + chunks.append(current) + return chunks + + +def telegram_targets() -> list[dict[str, str | int]]: + chat_id = settings.telegram_chat_id.strip() + if settings.telegram_topic_id is not None: + return [ + { + "chat_id": chat_id, + "message_thread_id": settings.telegram_topic_id, + } + ] + return [{"chat_id": chat_id}] + + +def telegram_request( + method: str, + fields: dict[str, str | int], + file_path: Path | None = None, + file_name: str | None = None, + content_type: str | None = None, +) -> None: + url = f"https://api.telegram.org/bot{settings.telegram_bot_token.strip()}/{method}" + if file_path is None: + data = urllib.parse.urlencode({k: v for k, v in fields.items() if v is not None}).encode("utf-8") + request = urllib.request.Request(url, data=data, method="POST") + else: + boundary = f"----ChatGPTBoundary{uuid4().hex}" + body = bytearray() + for key, value in fields.items(): + if value is None: + continue + body.extend(f"--{boundary}\r\n".encode()) + body.extend(f'Content-Disposition: form-data; name="{key}"\r\n\r\n{value}\r\n'.encode("utf-8")) + guessed_type = content_type or mimetypes.guess_type(file_name or file_path.name)[0] or "application/octet-stream" + body.extend(f"--{boundary}\r\n".encode()) + body.extend( + f'Content-Disposition: form-data; name="document"; filename="{file_name or file_path.name}"\r\n'.encode( + "utf-8" + ) + ) + body.extend(f"Content-Type: {guessed_type}\r\n\r\n".encode("utf-8")) + body.extend(file_path.read_bytes()) + body.extend(b"\r\n") + body.extend(f"--{boundary}--\r\n".encode()) + request = urllib.request.Request( + url, + data=bytes(body), + method="POST", + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, + ) + with urllib.request.urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode("utf-8")) + if not payload.get("ok"): + raise RuntimeError(payload.get("description") or "Telegram API error") + + +def send_telegram_text(text: str) -> None: + for target in telegram_targets(): + for chunk in split_text_chunks(text): + telegram_request( + "sendMessage", + { + **target, + "text": chunk, + "disable_web_page_preview": "true", + }, + ) + + +def send_telegram_document(file_path: Path, attachment: AttachmentRecord, caption: str | None = None) -> None: + for target in telegram_targets(): + telegram_request( + "sendDocument", + { + **target, + "caption": caption or attachment.original_name, + }, + file_path=file_path, + file_name=attachment.original_name, + content_type=attachment.content_type, + ) + + +def send_finish_alert(call: CallRecord) -> None: + if not (settings.telegram_alerting_available and call.telegram_alert_enabled): + return + + started_at = call.started_at or call.created_at + finished_at = datetime.now(started_at.tzinfo) + duration_seconds = int((finished_at - started_at).total_seconds()) if started_at else 0 + + if duration_seconds < 60: + return + if len(call.participant_names) < 2: + return + if call.started_at is None: + return + + participants = ", ".join(call.participant_names) if call.participant_names else "—" + + summary = "\n".join( + [ + f"Итоги созвона: {call.room_title}", + f"Комната: {call.room_name}", + f"Создана: {human_dt(call.created_at)}", + f"Таймер созвона: {format_duration(duration_seconds)}", + f"Участники: {participants}", + ] + ) + send_telegram_text(summary) + + transcript_lines: list[str] = [] + for event in call.chat_events: + timestamp = event.created_at.astimezone().strftime("%H:%M:%S") + if event.event_type == "text" and event.text: + transcript_lines.append(f"[{timestamp}] {event.author}: {event.text}") + elif event.event_type == "file": + transcript_lines.append(f"[{timestamp}] {event.author}: файл {event.file_name or 'без названия'}") + + if transcript_lines: + send_telegram_text("Сообщения и файлы из чата:\n" + "\n".join(transcript_lines)) + else: + send_telegram_text("Сообщения и файлы из чата: нет пользовательских сообщений.") + + attachment_authors = { + event.attachment_id: event.author + for event in call.chat_events + if event.event_type == "file" and event.attachment_id + } + upload_dir = room_upload_dir(call.room_name) + for attachment in call.attachments: + file_path = upload_dir / attachment.stored_name + if not file_path.exists(): + continue + author = attachment_authors.get(attachment.attachment_id) + caption = f"Файл из чата: {attachment.original_name}" + if author: + caption = f"{caption} — {author}" + send_telegram_document(file_path, attachment, caption=caption[:1000]) + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/api/config", response_model=AppConfigResponse) +def get_config() -> AppConfigResponse: + return AppConfigResponse( + telegram_alerting_available=settings.telegram_alerting_available, + max_attachment_size_mb=settings.max_attachment_size_mb, + ) + + +@app.get("/i/{short_code}") +def invite_short_redirect(request: Request, short_code: str): + room_name = store.resolve_short_invite(short_code) + if room_name is None: + raise HTTPException(status_code=404, detail="Not found") + call = store.get(room_name) + if call is None or not call.is_active or call.invite_short_code != short_code: + raise HTTPException(status_code=404, detail="Not found") + query = urllib.parse.urlencode({"invite": call.invite_token}) + base = str(request.base_url).rstrip("/") + return RedirectResponse(url=f"{base}/call/{room_name}?{query}", status_code=302) + + +@app.post("/api/calls", response_model=CreateCallResponse) +async def create_call(payload: CreateCallRequest | None = None) -> CreateCallResponse: + room_name = f"call-{uuid4().hex[:10]}" + invite_token = uuid4().hex + room_title = (payload.room_title if payload and payload.room_title else room_name) + password = payload.password.strip() if payload and payload.password else None + telegram_alert_enabled = bool(payload.telegram_alert_enabled) if payload else False + quick_join_default = bool(payload.quick_join) if payload else False + + short_code = secrets.token_hex(4) + while not store.register_short_invite(short_code, room_name): + short_code = secrets.token_hex(4) + + call = CallRecord( + room_name=room_name, + room_title=room_title, + invite_token=invite_token, + invite_short_code=short_code, + quick_join_default=quick_join_default, + password=password or None, + telegram_alert_enabled=settings.telegram_alerting_available and telegram_alert_enabled, + ) + store.create(call) + await create_room_if_needed(room_name) + + base = public_app_url() + invite_link = f"{base}/call/{room_name}?invite={invite_token}" + short_invite_link = f"{base}/i/{short_code}" + return CreateCallResponse( + room_name=room_name, + room_title=room_title, + invite_token=invite_token, + invite_link=invite_link, + short_invite_link=short_invite_link, + has_password=bool(password), + ) + + +@app.get("/api/calls/{room_name}", response_model=CallInfoResponse) +def get_call(room_name: str) -> CallInfoResponse: + call = store.get(room_name) + if call is None: + raise HTTPException(status_code=404, detail="Call not found") + return CallInfoResponse( + room_name=call.room_name, + room_title=call.room_title, + is_active=call.is_active, + created_at=call.created_at.isoformat(), + has_password=bool(call.password), + ) + + +@app.post("/api/calls/{room_name}/join", response_model=JoinCallResponse) +async def join_call(room_name: str, payload: JoinCallRequest) -> JoinCallResponse: + call = store.get(room_name) + if call is None or not call.is_active: + raise HTTPException(status_code=404, detail="Call not found") + if payload.invite_token != call.invite_token: + raise HTTPException(status_code=403, detail="Invalid invite token") + if call.password and payload.password.strip() != call.password: + raise HTTPException(status_code=403, detail="Неверный пароль комнаты") + + identity = payload.identity or f"user-{uuid4().hex[:12]}" + participant_token = await build_participant_token( + room_name=room_name, + identity=identity, + display_name=payload.display_name, + ) + store.mark_started(room_name) + store.add_participant(room_name, payload.display_name) + + return JoinCallResponse( + server_url=settings.livekit_public_url, + participant_token=participant_token, + room_name=room_name, + room_title=call.room_title, + identity=identity, + display_name=payload.display_name, + ) + + +@app.post("/api/calls/{room_name}/chat-events") +def log_chat_message(room_name: str, payload: ChatMessageLogRequest) -> dict[str, str]: + ensure_call_access(room_name, payload.invite_token) + pid = (payload.participant_identity or "").strip() or None + store.add_chat_event( + room_name, + ChatEventRecord( + event_type="text", + author=payload.display_name.strip(), + author_identity=pid, + text=payload.text.strip(), + ), + ) + return {"status": "ok"} + + +@app.post("/api/calls/{room_name}/chat-author-sync") +def sync_chat_author_display(room_name: str, payload: ChatAuthorSyncRequest) -> dict[str, str]: + ensure_call_access(room_name, payload.invite_token) + name = payload.display_name.strip() + if not name: + raise HTTPException(status_code=400, detail="Пустое имя") + store.update_chat_events_author_by_identity( + room_name, + payload.participant_identity.strip(), + name, + ) + return {"status": "ok"} + + +@app.get("/api/calls/{room_name}/attachments", response_model=AttachmentListResponse) +def list_attachments(room_name: str, invite_token: str) -> AttachmentListResponse: + ensure_call_access(room_name, invite_token) + items = [build_attachment_response(room_name, item) for item in store.list_attachments(room_name)] + return AttachmentListResponse(items=items) + + +@app.post("/api/calls/{room_name}/attachments", response_model=AttachmentResponse) +async def upload_attachment( + room_name: str, + invite_token: str, + file: UploadFile = File(...), + display_name: str = Form(default=""), + participant_identity: str = Form(default=""), +) -> AttachmentResponse: + ensure_call_access(room_name, invite_token) + filename = (file.filename or "").strip() + if not filename: + raise HTTPException(status_code=400, detail="Файл не выбран") + + upload_dir = room_upload_dir(room_name) + upload_dir.mkdir(parents=True, exist_ok=True) + + attachment_id = uuid4().hex + ext = Path(filename).suffix + stored_name = f"{attachment_id}{ext}" + destination = upload_dir / stored_name + + size_bytes = 0 + try: + with destination.open("wb") as target: + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + size_bytes += len(chunk) + if size_bytes > settings.max_attachment_size_bytes: + raise HTTPException( + status_code=413, + detail=f"Максимальный размер файла — {settings.max_attachment_size_mb} МБ", + ) + target.write(chunk) + except Exception: + if destination.exists(): + destination.unlink(missing_ok=True) + raise + finally: + await file.close() + + attachment = AttachmentRecord( + attachment_id=attachment_id, + original_name=filename, + stored_name=stored_name, + content_type=file.content_type or "application/octet-stream", + size_bytes=size_bytes, + ) + store.add_attachment(room_name, attachment) + author = display_name.strip() or "Участник" + aid = participant_identity.strip() or None + store.add_chat_event( + room_name, + ChatEventRecord( + event_type="file", + author=author, + author_identity=aid, + attachment_id=attachment_id, + file_name=filename, + ), + ) + return build_attachment_response(room_name, attachment) + + +@app.get("/api/calls/{room_name}/attachments/{attachment_id}") +def download_attachment(room_name: str, attachment_id: str, invite_token: str): + ensure_call_access(room_name, invite_token) + attachment = store.get_attachment(room_name, attachment_id) + if attachment is None: + raise HTTPException(status_code=404, detail="Файл не найден") + file_path = room_upload_dir(room_name) / attachment.stored_name + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Файл не найден") + return FileResponse( + path=file_path, + filename=attachment.original_name, + media_type=attachment.content_type or "application/octet-stream", + ) + + +@app.post("/api/calls/{room_name}/finish") +def finish_call(room_name: str, payload: FinishCallRequest) -> dict[str, str]: + call = store.get(room_name) + if call is None or not call.is_active: + raise HTTPException(status_code=404, detail="Call not found") + if payload.invite_token != call.invite_token: + raise HTTPException(status_code=403, detail="Invalid invite token") + + alert_status = "disabled" + if settings.telegram_alerting_available and call.telegram_alert_enabled: + try: + send_finish_alert(call) + alert_status = "sent" + except Exception: + alert_status = "failed" + + store.clear_attachments(room_name) + upload_dir = room_upload_dir(room_name) + if upload_dir.exists(): + rmtree(upload_dir, ignore_errors=True) + store.deactivate(room_name) + return {"status": "finished", "telegram_alert_status": alert_status} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..d154b85 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,42 @@ +from datetime import datetime, timezone + +from pydantic import BaseModel, Field + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class AttachmentRecord(BaseModel): + attachment_id: str + original_name: str + stored_name: str + content_type: str + size_bytes: int + created_at: datetime = Field(default_factory=utcnow) + + +class ChatEventRecord(BaseModel): + event_type: str + author: str + author_identity: str | None = None + text: str | None = None + attachment_id: str | None = None + file_name: str | None = None + created_at: datetime = Field(default_factory=utcnow) + + +class CallRecord(BaseModel): + room_name: str + room_title: str + invite_token: str + invite_short_code: str | None = None + quick_join_default: bool = False + password: str | None = None + telegram_alert_enabled: bool = False + created_at: datetime = Field(default_factory=utcnow) + started_at: datetime | None = None + is_active: bool = True + participant_names: list[str] = Field(default_factory=list) + chat_events: list[ChatEventRecord] = Field(default_factory=list) + attachments: list[AttachmentRecord] = Field(default_factory=list) diff --git a/backend/app/probe_blocker.py b/backend/app/probe_blocker.py new file mode 100644 index 0000000..d60cba0 --- /dev/null +++ b/backend/app/probe_blocker.py @@ -0,0 +1,37 @@ +import re + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from .config import settings + +_PROBE_PATH_RE = re.compile( + r"""(?ix) + \.(?:php|phtml|phar|sh|sql|bak|old|orig|swp|zip|tar|gz)(?:\?|$|/)| + /\.(?:env|git|svn|hg|bzr|htpasswd|htaccess)(?:/|$)| + (?:^|/)\.env(?:\.|\?|$|/)| + [^/]+\.env(?:\?|$|/)| + (?:^|/)(?:wp-admin|wp-includes|wp-content|wp-login\.php|xmlrpc\.php)(?:/|$)| + (?:^|/)(?:phpmyadmin|pma|adminer|vendor/phpunit)(?:/|$)| + (?:^|/)cgi-bin(?:/|$)| + (?:^|/)(?:wlwmanifest\.xml|readme\.html|license\.txt)(?:/|$) + """ +) + + +def suspicious_request_path(path: str) -> bool: + return bool(_PROBE_PATH_RE.search(path)) + + + +class ProbeBlockMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + if not settings.block_probe_paths: + return await call_next(request) + p = request.url.path + if p.startswith('/static/'): + return await call_next(request) + if suspicious_request_path(p): + return Response(status_code=404, content=b'Not Found', media_type='text/plain') + return await call_next(request) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..5ec05c3 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,79 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class CreateCallRequest(BaseModel): + room_title: str = Field(default="", max_length=100) + password: str = Field(default="", max_length=100) + telegram_alert_enabled: bool = False + quick_join: bool = False + + +class CreateCallResponse(BaseModel): + room_name: str + room_title: str + invite_token: str + invite_link: str + short_invite_link: str + has_password: bool = False + + +class JoinCallRequest(BaseModel): + invite_token: str = Field(min_length=8) + display_name: str = Field(min_length=1, max_length=50) + password: str = Field(default="", max_length=100) + with_video: bool = True + with_audio: bool = True + identity: Optional[str] = Field(default=None, max_length=64) + + +class JoinCallResponse(BaseModel): + server_url: str + participant_token: str + room_name: str + room_title: str + identity: str + display_name: str + + +class CallInfoResponse(BaseModel): + room_name: str + room_title: str + is_active: bool + created_at: str + has_password: bool = False + + +class AppConfigResponse(BaseModel): + telegram_alerting_available: bool + max_attachment_size_mb: int + + +class FinishCallRequest(BaseModel): + invite_token: str = Field(min_length=8) + + +class AttachmentResponse(BaseModel): + attachment_id: str + file_name: str + content_type: str + size_bytes: int + download_url: str + + +class AttachmentListResponse(BaseModel): + items: list[AttachmentResponse] = Field(default_factory=list) + + +class ChatMessageLogRequest(BaseModel): + invite_token: str = Field(min_length=8) + display_name: str = Field(min_length=1, max_length=50) + text: str = Field(min_length=1, max_length=1000) + participant_identity: Optional[str] = Field(default=None, max_length=64) + + +class ChatAuthorSyncRequest(BaseModel): + invite_token: str = Field(min_length=8) + participant_identity: str = Field(min_length=1, max_length=64) + display_name: str = Field(min_length=1, max_length=50) diff --git a/backend/app/store.py b/backend/app/store.py new file mode 100644 index 0000000..f8821dc --- /dev/null +++ b/backend/app/store.py @@ -0,0 +1,127 @@ +from threading import Lock +from typing import Optional + +from .models import AttachmentRecord, CallRecord, ChatEventRecord, utcnow + + +class InMemoryCallStore: + def __init__(self) -> None: + self._lock = Lock() + self._calls: dict[str, CallRecord] = {} + self._short_to_room: dict[str, str] = {} + + def create(self, call: CallRecord) -> CallRecord: + with self._lock: + self._calls[call.room_name] = call + return call + + def register_short_invite(self, short_code: str, room_name: str) -> bool: + with self._lock: + if short_code in self._short_to_room: + return False + self._short_to_room[short_code] = room_name + return True + + def resolve_short_invite(self, short_code: str) -> Optional[str]: + with self._lock: + return self._short_to_room.get(short_code) + + def get(self, room_name: str) -> Optional[CallRecord]: + with self._lock: + return self._calls.get(room_name) + + def deactivate(self, room_name: str) -> Optional[CallRecord]: + with self._lock: + call = self._calls.get(room_name) + if call is None: + return None + if call.invite_short_code: + self._short_to_room.pop(call.invite_short_code, None) + updated = call.model_copy(update={"is_active": False}) + self._calls[room_name] = updated + return updated + + def mark_started(self, room_name: str): + with self._lock: + call = self._calls.get(room_name) + if call is None or call.started_at is not None: + return call + updated = call.model_copy(update={"started_at": utcnow()}) + self._calls[room_name] = updated + return updated + + def add_participant(self, room_name: str, display_name: str): + with self._lock: + call = self._calls.get(room_name) + if call is None: + return None + name = display_name.strip() + if not name or name in call.participant_names: + return call + updated = call.model_copy(update={"participant_names": [*call.participant_names, name]}) + self._calls[room_name] = updated + return updated + + def add_chat_event(self, room_name: str, event: ChatEventRecord) -> Optional[CallRecord]: + with self._lock: + call = self._calls.get(room_name) + if call is None: + return None + updated = call.model_copy(update={"chat_events": [*call.chat_events, event]}) + self._calls[room_name] = updated + return updated + + def update_chat_events_author_by_identity( + self, room_name: str, participant_identity: str, display_name: str + ) -> Optional[CallRecord]: + with self._lock: + call = self._calls.get(room_name) + if call is None: + return None + new_events: list[ChatEventRecord] = [] + for ev in call.chat_events: + if ev.author_identity and ev.author_identity == participant_identity: + new_events.append(ev.model_copy(update={"author": display_name})) + else: + new_events.append(ev) + updated = call.model_copy(update={"chat_events": new_events}) + self._calls[room_name] = updated + return updated + + def add_attachment(self, room_name: str, attachment: AttachmentRecord) -> Optional[CallRecord]: + with self._lock: + call = self._calls.get(room_name) + if call is None: + return None + updated = call.model_copy(update={"attachments": [*call.attachments, attachment]}) + self._calls[room_name] = updated + return updated + + def list_attachments(self, room_name: str) -> list[AttachmentRecord]: + with self._lock: + call = self._calls.get(room_name) + if call is None: + return [] + return list(call.attachments) + + def get_attachment(self, room_name: str, attachment_id: str) -> Optional[AttachmentRecord]: + with self._lock: + call = self._calls.get(room_name) + if call is None: + return None + for attachment in call.attachments: + if attachment.attachment_id == attachment_id: + return attachment + return None + + def clear_attachments(self, room_name: str) -> Optional[CallRecord]: + with self._lock: + call = self._calls.get(room_name) + if call is None: + return None + updated = call.model_copy(update={"attachments": []}) + self._calls[room_name] = updated + return updated + + +store = InMemoryCallStore() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..972e645 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn[standard] +livekit-api +pydantic-settings +python-multipart \ No newline at end of file diff --git a/backend/static/favicon.ico b/backend/static/favicon.ico new file mode 100644 index 0000000..8fe8274 Binary files /dev/null and b/backend/static/favicon.ico differ diff --git a/backend/static/icons/attachment.png b/backend/static/icons/attachment.png new file mode 100644 index 0000000..8708b2e Binary files /dev/null and b/backend/static/icons/attachment.png differ diff --git a/backend/static/icons/camera_off.png b/backend/static/icons/camera_off.png new file mode 100644 index 0000000..5d3dc74 Binary files /dev/null and b/backend/static/icons/camera_off.png differ diff --git a/backend/static/icons/camera_on.png b/backend/static/icons/camera_on.png new file mode 100644 index 0000000..6112506 Binary files /dev/null and b/backend/static/icons/camera_on.png differ diff --git a/backend/static/icons/favicon.ico b/backend/static/icons/favicon.ico new file mode 100644 index 0000000..8fe8274 Binary files /dev/null and b/backend/static/icons/favicon.ico differ diff --git a/backend/static/icons/favicon.png b/backend/static/icons/favicon.png new file mode 100644 index 0000000..285bd97 Binary files /dev/null and b/backend/static/icons/favicon.png differ diff --git a/backend/static/icons/micro_off.png b/backend/static/icons/micro_off.png new file mode 100644 index 0000000..8491f20 Binary files /dev/null and b/backend/static/icons/micro_off.png differ diff --git a/backend/static/icons/micro_on.png b/backend/static/icons/micro_on.png new file mode 100644 index 0000000..8355dcc Binary files /dev/null and b/backend/static/icons/micro_on.png differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..26f8179 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +services: + livekit: + image: livekit/livekit-server:latest + container_name: linkra-livekit + command: --config /etc/livekit.yaml + restart: unless-stopped + ports: + - "7881:7881/tcp" + - "7882:7882/udp" + - "127.0.0.1:7880:7880" + volumes: + - ./livekit.yaml:/etc/livekit.yaml:ro + networks: + - linkra_net + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7880"] + interval: 5s + timeout: 3s + retries: 20 + + backend: + build: + context: ./backend + container_name: linkra-backend + restart: unless-stopped + env_file: + - path: .env + required: false + ports: + - "127.0.0.1:8000:8000" + depends_on: + livekit: + condition: service_healthy + networks: + - linkra_net + + frontend: + build: + context: ./frontend + container_name: linkra-frontend + restart: unless-stopped + ports: + - "127.0.0.1:8080:80" + depends_on: + - backend + networks: + - linkra_net + +networks: + linkra_net: + driver: bridge \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..b6dddf6 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +npm-debug.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..854b291 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c5a0fde --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Linkra + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..07942ea --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 120m; + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /i/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a41d5eb --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1336 @@ +{ + "name": "frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@vitejs/plugin-react": "latest", + "livekit-client": "latest", + "qrcode": "latest", + "react": "latest", + "react-dom": "latest", + "typescript": "latest", + "vite": "latest" + }, + "devDependencies": {} + }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@livekit/mutex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", + "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "license": "Apache-2.0" + }, + "node_modules/@livekit/protocol": { + "version": "1.45.3", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.3.tgz", + "integrity": "sha512-WmMxBTsy4dRBqcrswFwUUlgq3Z0nnhOqKR6tX749Rb/PcB1yBMUtrHxZvcsS6qi3/5+86zHeVG+exmu1sZqfJg==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/dom-mediacapture-record": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", + "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==", + "license": "MIT", + "peer": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/livekit-client": { + "version": "2.18.9", + "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.18.9.tgz", + "integrity": "sha512-l0cADcxxBCWCBMtU9eWY6RpdbRfgA5c1/05yngQXo08mcy3VOttmSE2pNZ74k2B2zQym149g5/Y1B3vq2FWwlw==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/mutex": "1.1.1", + "@livekit/protocol": "1.45.3", + "events": "^3.3.0", + "jose": "^6.1.0", + "loglevel": "^1.9.2", + "sdp-transform": "^2.15.0", + "tslib": "2.8.1", + "typed-emitter": "^2.1.0", + "webrtc-adapter": "9.0.5" + }, + "peerDependencies": { + "@types/dom-mediacapture-record": "^1" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", + "license": "MIT" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webrtc-adapter": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz", + "integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f2d6dfb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,17 @@ +{ + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "@vitejs/plugin-react": "latest", + "vite": "latest", + "typescript": "latest", + "react": "latest", + "react-dom": "latest", + "livekit-client": "latest", + "qrcode": "latest" + }, + "devDependencies": {} +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..2a9ce2d --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,730 @@ +import React,{useCallback,useEffect,useMemo,useRef,useState}from'react'; +import{createRoot}from'react-dom/client'; +import{Room,RoomEvent,Track}from'livekit-client'; +import QRCode from'qrcode'; +import'./styles.css'; + + +const TELEGRAM_PREF_KEY = 'linkra-pref-telegram-alert'; + + +function apiUrl(path) { + return path; +} + + +async function readJson(response) { + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.detail || 'Ошибка запроса'); + return data; +} + + +function routeFromLocation() { + const path = window.location.pathname; + if (path === '/') return { name: 'home' }; + if (path === '/call/overloaded') return { name: 'error', code: '503' }; + const match = path.match(/^\/call\/([^/]+)$/); + if (match) return { name: 'call', roomName: decodeURIComponent(match[1]) }; + return { name: 'error', code: '404' }; +} + + +function getInviteToken() { + return new URL(window.location.href).searchParams.get('invite') || ''; +} + + +function randomGuestName() { + const a = Date.now().toString(36).slice(-4); + const b = Math.random().toString(36).slice(2, 6); + return `Гость-${a}${b}`.slice(0, 24); +} + + +function instaRoomTitle() { + return `Инста-${Math.random().toString(36).slice(2, 8)}`; +} + + +function Toasts({ items }) { + return ( +
+ {items.map((toast) => ( +
{toast.message}
+ ))} +
+ ); +} + + +function useToasts() { + const [items, setItems] = useState([]); + + const showToast = useCallback((message, type = 'info') => { + const id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()); + setItems((current) => current.concat({ id, message, type })); + window.setTimeout(() => { + setItems((current) => current.filter((item) => item.id !== id)); + }, 2600); + }, []); + + return [items, showToast]; +} + + +function ErrorPage({ code }) { + const copy = { + '403': ['Приглашение недействительно', 'Ссылка приглашения недействительна или была заменена. Запросите новую ссылку у организатора.'], + '410': ['Звонок завершён', 'Эта встреча уже закончена. Попросите организатора прислать новую ссылку.'], + '503': ['Сервис временно недоступен', 'Сервер перегружен. Подождите немного и обновите страницу или зайдите позже.'], + '404': ['Комната не найдена', 'Такой страницы нет или ссылка устарела. Проверьте адрес или создайте новый звонок на главной странице.'] + }; + const [title, message] = copy[code] || copy['404']; + + useEffect(() => { + document.title = `${title} · Linkra`; + }, [title]); + + return ( +
+
+

{code}

+

{title}

+

{message}

+
+ На главную + {code === '503' ? : null} +
+
+
+ ); +} + + +function HomePage({ showToast }) { + const [config, setConfig] = useState({ telegram_alerting_available: false, max_attachment_size_mb: 100 }); + const [settingsOpen, setSettingsOpen] = useState(false); + const [telegramAlert, setTelegramAlert] = useState(() => localStorage.getItem(TELEGRAM_PREF_KEY) === '1'); + const [roomTitle, setRoomTitle] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [instaLoading, setInstaLoading] = useState(false); + const [result, setResult] = useState(null); + const qrRef = useRef(null); + + useEffect(() => { + document.title = 'Созвон по ссылке · Linkra'; + fetch(apiUrl('/api/config')).then(readJson).then(setConfig).catch(() => {}); + }, []); + + useEffect(() => { + localStorage.setItem(TELEGRAM_PREF_KEY, telegramAlert ? '1' : '0'); + }, [telegramAlert]); + + useEffect(() => { + if (!result || !qrRef.current) return; + const target = result.short_invite_link || result.invite_link; + QRCode.toCanvas(qrRef.current, target, { width: 200, margin: 1 }).catch(() => {}); + }, [result]); + + const createCall = async ({ quickJoin = false } = {}) => { + const title = quickJoin ? instaRoomTitle() : roomTitle.trim(); + const roomPassword = quickJoin ? '' : password.trim(); + if (!quickJoin && title.length < 4) { + showToast('Название комнаты должно содержать минимум 4 символа', 'error'); + return null; + } + if (!quickJoin && roomPassword.length > 0 && roomPassword.length < 4) { + showToast('Пароль должен содержать минимум 4 символа', 'error'); + return null; + } + + const setBusy = quickJoin ? setInstaLoading : setLoading; + setBusy(true); + try { + const response = await fetch(apiUrl('/api/calls'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + room_title: title, + password: roomPassword, + telegram_alert_enabled: config.telegram_alerting_available && telegramAlert, + quick_join: quickJoin + }) + }); + return await readJson(response); + } catch (error) { + showToast(error.message || 'Не удалось создать звонок', 'error'); + return null; + } finally { + setBusy(false); + } + }; + + const handleCreate = async () => { + const data = await createCall(); + if (!data) return; + setResult(data); + showToast('Ссылка на звонок готова', 'success'); + }; + + const handleInsta = async () => { + const data = await createCall({ quickJoin: true }); + if (!data) return; + const target = data.short_invite_link || data.invite_link; + sessionStorage.setItem(`linkra-insta:${data.room_name}`, JSON.stringify({ + invite_token: data.invite_token, + display_name: randomGuestName(), + mic_enabled: false, + camera_enabled: false + })); + await navigator.clipboard.writeText(target).catch(() => {}); + window.location.href = target; + }; + + const copyText = async (value) => { + await navigator.clipboard.writeText(value); + showToast('Ссылка скопирована', 'success'); + }; + + return ( +
+
+
+

Созвон по ссылке

+

Создай комнату, отправь ссылку собеседнику: видео, чат и демонстрация экрана в браузере, без приложений.

+
    +
  • Камера и микрофон без установки
  • +
  • Чат и файлы в комнате
  • +
  • Короткая ссылка и QR для телефона
  • +
  • Кнопка «Инста» для быстрого входа
  • +
+
+ +
+
+

Новая комната

+
+ + {settingsOpen ? ( +
+ {config.telegram_alerting_available ? ( + + ) :

Итоги в Telegram недоступны на сервере

} +
+ ) : null} +
+
+ + {!result ? ( +
+ + +
+ + +
+
+ ) : ( +
+ + +
+ Показать полную ссылку и QR-код + + +
+
+

Название комнаты: {result.room_title}

+ Перейти в звонок +
+ )} +
+
+
+ ); +} + + +function MediaTrack({ track, kind, muted = false, className = '' }) { + const ref = useRef(null); + + useEffect(() => { + if (!track || !ref.current) return undefined; + track.attach(ref.current); + return () => { + track.detach(ref.current); + }; + }, [track]); + + if (kind === 'audio') return