init commit
This commit is contained in:
BIN
backend/app/__pycache__/config.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/schemas.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/schemas.cpython-312.pyc
Normal file
Binary file not shown.
44
backend/app/config.py
Normal file
44
backend/app/config.py
Normal file
@@ -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()
|
||||
45
backend/app/livekit_service.py
Normal file
45
backend/app/livekit_service.py
Normal file
@@ -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()
|
||||
495
backend/app/main.py
Normal file
495
backend/app/main.py
Normal file
@@ -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}
|
||||
42
backend/app/models.py
Normal file
42
backend/app/models.py
Normal file
@@ -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)
|
||||
37
backend/app/probe_blocker.py
Normal file
37
backend/app/probe_blocker.py
Normal file
@@ -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)
|
||||
79
backend/app/schemas.py
Normal file
79
backend/app/schemas.py
Normal file
@@ -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)
|
||||
127
backend/app/store.py
Normal file
127
backend/app/store.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user