diff --git a/.env.example b/.env.example index 9c74f8f..9a7a6d1 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,8 @@ 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 +CORS_ALLOW_ORIGINS=https://linkra.ru + +TURN_URLS=turn:72.56.247.116:3478?transport=udp,turn:72.56.247.116:3478?transport=tcp +TURN_USERNAME=linkra +TURN_CREDENTIAL=change-me \ No newline at end of file diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc deleted file mode 100644 index daa8624..0000000 Binary files a/backend/app/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc deleted file mode 100644 index 7a69778..0000000 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc deleted file mode 100644 index 68e6761..0000000 Binary files a/backend/app/__pycache__/schemas.cpython-312.pyc and /dev/null differ diff --git a/backend/app/config.py b/backend/app/config.py index 6e5043f..2fe7fa3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,12 +14,11 @@ class Settings(BaseSettings): 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") + turn_urls: str = Field(default="", alias="TURN_URLS") + turn_username: str = Field(default="", alias="TURN_USERNAME") + turn_credential: str = Field(default="", alias="TURN_CREDENTIAL") model_config = SettingsConfigDict(case_sensitive=False, extra="ignore") @@ -31,14 +30,23 @@ class Settings(BaseSettings): 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()] + @property + def turn_ice_servers(self) -> list[dict[str, str | list[str]]]: + urls = [url.strip() for url in self.turn_urls.split(',') if url.strip()] + if not urls or not self.turn_username.strip() or not self.turn_credential.strip(): + return [] + return [ + { + 'urls': urls, + 'username': self.turn_username.strip(), + 'credential': self.turn_credential.strip(), + } + ] + settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index f8e8d0f..d28aacc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,5 @@ -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 @@ -72,180 +68,6 @@ 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"} @@ -254,8 +76,8 @@ def health() -> dict[str, str]: @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, + turn_ice_servers=settings.turn_ice_servers, ) @@ -278,7 +100,6 @@ async def create_call(payload: CreateCallRequest | None = None) -> CreateCallRes 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) @@ -292,7 +113,6 @@ async def create_call(payload: CreateCallRequest | None = None) -> CreateCallRes 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) @@ -479,17 +299,9 @@ def finish_call(room_name: str, payload: FinishCallRequest) -> dict[str, str]: 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} + return {"status": "finished"} diff --git a/backend/app/models.py b/backend/app/models.py index d154b85..2894e40 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -33,7 +33,6 @@ class CallRecord(BaseModel): 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 diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 5ec05c3..03c912f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -6,7 +6,6 @@ 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 @@ -46,8 +45,8 @@ class CallInfoResponse(BaseModel): class AppConfigResponse(BaseModel): - telegram_alerting_available: bool max_attachment_size_mb: int + turn_ice_servers: list[dict] = Field(default_factory=list) class FinishCallRequest(BaseModel): diff --git a/docker-compose.yml b/docker-compose.yml index 26f8179..bc1aca2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: ports: - "7881:7881/tcp" - "7882:7882/udp" - - "127.0.0.1:7880:7880" + - "7880:7880" volumes: - ./livekit.yaml:/etc/livekit.yaml:ro networks: @@ -40,7 +40,7 @@ services: container_name: linkra-frontend restart: unless-stopped ports: - - "127.0.0.1:8080:80" + - "2080:80" depends_on: - backend networks: diff --git a/frontend/index.html b/frontend/index.html index c5a0fde..6c87c31 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,8 @@ + + Linkra diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a41d5eb..9b8c7a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,29 +21,6 @@ "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", diff --git a/frontend/public/icons/attachment.png b/frontend/public/icons/attachment.png new file mode 100644 index 0000000..8708b2e Binary files /dev/null and b/frontend/public/icons/attachment.png differ diff --git a/frontend/public/icons/camera_off.png b/frontend/public/icons/camera_off.png new file mode 100644 index 0000000..5d3dc74 Binary files /dev/null and b/frontend/public/icons/camera_off.png differ diff --git a/frontend/public/icons/camera_on.png b/frontend/public/icons/camera_on.png new file mode 100644 index 0000000..6112506 Binary files /dev/null and b/frontend/public/icons/camera_on.png differ diff --git a/frontend/public/icons/favicon.png b/frontend/public/icons/favicon.png new file mode 100644 index 0000000..285bd97 Binary files /dev/null and b/frontend/public/icons/favicon.png differ diff --git a/frontend/public/icons/micro_off.png b/frontend/public/icons/micro_off.png new file mode 100644 index 0000000..8491f20 Binary files /dev/null and b/frontend/public/icons/micro_off.png differ diff --git a/frontend/public/icons/micro_on.png b/frontend/public/icons/micro_on.png new file mode 100644 index 0000000..8355dcc Binary files /dev/null and b/frontend/public/icons/micro_on.png differ diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 2a9ce2d..28965ea 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -5,7 +5,13 @@ import QRCode from'qrcode'; import'./styles.css'; -const TELEGRAM_PREF_KEY = 'linkra-pref-telegram-alert'; +const ICONS = { + attachment: '/icons/attachment.png', + cameraOff: '/icons/camera_off.png', + cameraOn: '/icons/camera_on.png', + micOff: '/icons/micro_off.png', + micOn: '/icons/micro_on.png' +}; function apiUrl(path) { @@ -103,9 +109,6 @@ function ErrorPage({ code }) { 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); @@ -115,13 +118,8 @@ function HomePage({ showToast }) { 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; @@ -149,7 +147,6 @@ function HomePage({ showToast }) { body: JSON.stringify({ room_title: title, password: roomPassword, - telegram_alert_enabled: config.telegram_alerting_available && telegramAlert, quick_join: quickJoin }) }); @@ -205,19 +202,6 @@ function HomePage({ showToast }) {

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

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

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

} -
- ) : null} -
{!result ? ( @@ -262,7 +246,7 @@ function HomePage({ showToast }) { } -function MediaTrack({ track, kind, muted = false, className = '' }) { +function MediaTrack({ track, muted = false, className = '' }) { const ref = useRef(null); useEffect(() => { @@ -273,16 +257,49 @@ function MediaTrack({ track, kind, muted = false, className = '' }) { }; }, [track]); - if (kind === 'audio') return
@@ -690,7 +754,10 @@ function CallPage({ roomName, showToast }) {
setChatText(event.target.value)} onKeyDown={(event) => event.key === 'Enter' ? sendChat() : null} placeholder='Напиши сообщение...' maxLength='1000' /> - sendFile(event.target.files?.[0])} /> + sendFile(event.target.files?.[0])} /> +
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index eb5a207..3182353 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -138,6 +138,11 @@ input { font: inherit; } +input[type='range'] { + padding: 0; + accent-color: var(--primary); +} + .btn, .icon-btn, .toggle-btn { @@ -180,6 +185,9 @@ input { min-height: 50px; background: #1f2937; color: white; + display: inline-flex; + align-items: center; + justify-content: center; } .icon-btn.small { @@ -205,6 +213,25 @@ input { border: 1px solid #4b5563; } +.toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; +} + +.control-icon { + width: 24px; + height: 24px; + object-fit: contain; + display: block; +} + +.icon-btn .control-icon { + width: 26px; + height: 26px; +} + .home-actions { display: grid; grid-template-columns: 1fr auto; @@ -382,6 +409,16 @@ input { justify-content: space-between; } +.participant-volume { + display: grid; + gap: 8px; + padding: 10px 12px 12px; + border-bottom: 1px solid var(--line); + background: rgba(15, 23, 42, 0.72); + color: var(--muted); + font-size: 13px; +} + .screen-slot { height: 260px; background: #020617; @@ -473,10 +510,24 @@ input { .chat-form { margin-top: 14px; - display: grid; + display: flex; + flex-wrap: wrap; gap: 10px; } +.chat-form input[type='text'] { + min-width: 220px; + flex: 1 1 260px; +} + +.hidden-file-input { + display: none; +} + +.attachment-btn { + flex: 0 0 auto; +} + .file-link { color: #93c5fd; }