diff --git a/apps/backend/hub_platform/conversations/ingest.py b/apps/backend/hub_platform/conversations/ingest.py index c9adaa3..96d0859 100644 --- a/apps/backend/hub_platform/conversations/ingest.py +++ b/apps/backend/hub_platform/conversations/ingest.py @@ -80,7 +80,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: # Явный шаринг контакта: сообщение без текста, но с телефоном. is_contact_share = bool(inbound.phone) - is_voice = bool(inbound.voice_file_id or inbound.voice_url) + is_voice = bool(inbound.voice_file_id or inbound.voice_url or inbound.voice_content) message_text = inbound.text or ( f"Поделился контактом: {inbound.phone}" if is_contact_share else "" ) or ("Голосовое сообщение" if is_voice else "") diff --git a/apps/backend/hub_platform/conversations/transports/__init__.py b/apps/backend/hub_platform/conversations/transports/__init__.py index 9b5629f..5dc52ee 100644 --- a/apps/backend/hub_platform/conversations/transports/__init__.py +++ b/apps/backend/hub_platform/conversations/transports/__init__.py @@ -73,15 +73,24 @@ def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url: # Голосовые (дизайн-базлайн v2, кадр H): скачивание входящих — TG (file_id) и -# MAX (прямой url); отправка операторских голосовых — Telegram и MAX. +# MAX (прямой url) — web-виджет шлёт байты сразу; отправка операторских +# голосовых — Telegram, MAX и Web (доставка поллингом виджета). + +def _web_voice_noop(integration, *, chat_id: str, user_id: str, content: bytes, content_type: str, duration: int) -> bool: + # Web Chat: голосовое уже сохранено в БД, браузер заберёт его поллингом. + return True + _VOICE_SEND = { IntegrationProvider.TELEGRAM: _telegram.send_voice, IntegrationProvider.MAX: _max.send_voice, + IntegrationProvider.WEB: _web_voice_noop, } def download_voice(integration, inbound) -> tuple[bytes, str]: + if inbound.voice_content: + return inbound.voice_content, inbound.voice_mime or "audio/webm" if integration.provider == IntegrationProvider.TELEGRAM and inbound.voice_file_id: return _telegram.download_voice(integration, inbound.voice_file_id) if integration.provider == IntegrationProvider.MAX and inbound.voice_url: diff --git a/apps/backend/hub_platform/conversations/transports/base.py b/apps/backend/hub_platform/conversations/transports/base.py index d6cd3a4..2220b01 100644 --- a/apps/backend/hub_platform/conversations/transports/base.py +++ b/apps/backend/hub_platform/conversations/transports/base.py @@ -37,6 +37,8 @@ class InboundMessage: # провайдера (TG file_id) ИЛИ прямой URL (MAX), длительность и mime. voice_file_id: str = "" voice_url: str = "" + # Голосовое, пришедшее телом запроса (web-виджет): скачивать нечего. + voice_content: bytes = b"" voice_duration: int = 0 voice_mime: str = "" diff --git a/apps/backend/hub_platform/webchat/services.py b/apps/backend/hub_platform/webchat/services.py index 55ab530..ee3eac1 100644 --- a/apps/backend/hub_platform/webchat/services.py +++ b/apps/backend/hub_platform/webchat/services.py @@ -172,6 +172,26 @@ def post_message(session: WebSession, text: str) -> None: display_name=session.identity.display_name, ) ingest_inbound(session.connection, inbound) + _remember_widget(session) + + +def post_voice(session: WebSession, *, content: bytes, content_type: str, duration: int) -> None: + """Голосовое из виджета: байты приходят телом запроса, скачивать нечего.""" + inbound = InboundMessage( + external_id=uuid.uuid4().hex, + user_id=session.identity.external_user_id, + chat_id="", + text="", + display_name=session.identity.display_name, + voice_content=content, + voice_mime=content_type, + voice_duration=duration, + ) + ingest_inbound(session.connection, inbound) + _remember_widget(session) + + +def _remember_widget(session: WebSession) -> None: conversation = ( Conversation.objects.filter( channel=session.connection.channel, @@ -243,7 +263,15 @@ def messages_payload(session: WebSession, since: int) -> dict: "state": _STATE.get(conversation.control_mode, "ai"), "lifecycle": conversation.lifecycle, "messages": [ - {"id": m.id, "author": _ROLE.get(m.author_type, "ai"), "kind": m.kind, "text": m.text, "createdAt": m.created_at.isoformat()} + { + "id": m.id, + "author": _ROLE.get(m.author_type, "ai"), + "kind": m.kind, + "text": m.text, + "createdAt": m.created_at.isoformat(), + "durationSeconds": m.duration_seconds, + "hasAudio": bool(m.audio), + } for m in items ], "call": _call_payload(session), diff --git a/apps/backend/hub_platform/webchat/tests.py b/apps/backend/hub_platform/webchat/tests.py index d977e7f..52b5dab 100644 --- a/apps/backend/hub_platform/webchat/tests.py +++ b/apps/backend/hub_platform/webchat/tests.py @@ -74,6 +74,54 @@ class PublicWebChatWidgetTests(TestCase): self.assertEqual(response.json(), {"available": False}) + def test_voice_message_round_trip(self) -> None: + # Голосовое из виджета: multipart → VOICE-сообщение, диалог уходит + # оператору; аудио отдаётся только владельцу токена сессии. + from django.core.files.uploadedfile import SimpleUploadedFile + + from hub_platform.conversations.models import ( + ControlMode, + Conversation, + MessageKind, + ) + + widget = create_web_widget(self.channel, name="Виджет") + token = self._session(widget.public_key).json()["token"] + + posted = self.client.post( + "/api/v1/webchat/messages/", + data={ + "audio": SimpleUploadedFile("voice.webm", b"WEBMDATA", content_type="audio/webm"), + "duration": "4", + }, + format="multipart", + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(posted.status_code, 201) + + conversation = Conversation.objects.get(channel=self.channel) + message = conversation.messages.get(kind=MessageKind.VOICE) + self.assertEqual(message.duration_seconds, 4) + self.assertTrue(message.audio) + self.assertEqual(conversation.control_mode, ControlMode.PAUSED) + + payload = self.client.get( + "/api/v1/webchat/messages/?since=0", + headers={"Authorization": f"Bearer {token}"}, + ).json() + voice = next(m for m in payload["messages"] if m["kind"] == MessageKind.VOICE) + self.assertTrue(voice["hasAudio"]) + self.assertEqual(voice["durationSeconds"], 4) + + audio = self.client.get(f"/api/v1/webchat/messages/{message.id}/audio/?token={token}") + self.assertEqual(audio.status_code, 200) + self.assertEqual(audio.headers["Content-Type"], "audio/webm") + + # Чужая сессия не видит аудио этого диалога. + foreign_token = self._session(widget.public_key).json()["token"] + denied = self.client.get(f"/api/v1/webchat/messages/{message.id}/audio/?token={foreign_token}") + self.assertEqual(denied.status_code, 404) + def test_widget_origin_policy_is_scoped_per_widget(self) -> None: allowed = create_web_widget( self.channel, diff --git a/apps/backend/hub_platform/webchat/urls.py b/apps/backend/hub_platform/webchat/urls.py index d91f71c..7ce41ff 100644 --- a/apps/backend/hub_platform/webchat/urls.py +++ b/apps/backend/hub_platform/webchat/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ path("config/", views.WebchatConfigView.as_view(), name="webchat-config"), path("session/", views.WebchatSessionView.as_view(), name="webchat-session"), path("messages/", views.WebchatMessagesView.as_view(), name="webchat-messages"), + path("messages//audio/", views.WebchatMessageAudioView.as_view(), name="webchat-message-audio"), path("contact/", views.WebchatContactView.as_view(), name="webchat-contact"), path("call/open/", views.WebchatCallOpenView.as_view(), name="webchat-call-open"), path("call/decline/", views.WebchatCallDeclineView.as_view(), name="webchat-call-decline"), diff --git a/apps/backend/hub_platform/webchat/views.py b/apps/backend/hub_platform/webchat/views.py index d7bd8b8..c01aaaf 100644 --- a/apps/backend/hub_platform/webchat/views.py +++ b/apps/backend/hub_platform/webchat/views.py @@ -1,12 +1,15 @@ from contextlib import contextmanager -from django.http import HttpResponse +from django.http import FileResponse, HttpResponse from django.views import View +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from hub_platform.conversations.models import Message, MessageKind +from hub_platform.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES from hub_platform.identity.models import Organization from hub_platform.integrations.models import IntegrationStatus from hub_platform.tenancy.context import TenantContext @@ -144,10 +147,32 @@ class WebchatSessionView(_Public): class WebchatMessagesView(_Public): + # JSON — текст, multipart — голосовое из записи в виджете. + parser_classes = [JSONParser, MultiPartParser, FormParser] + def post(self, request: Request) -> Response: with _resolved_web_session(request) as (_context, session): if session is None: return Response({"detail": "Сессия не найдена"}, status=401) + upload = request.FILES.get("audio") + if upload is not None: + # Голосовое из виджета (дизайн-базлайн v2, кадр H). + if upload.size > MAX_VOICE_BYTES: + return Response({"detail": "Аудио больше 10 МБ"}, status=400) + content_type = (upload.content_type or "audio/webm").split(";")[0] + if content_type not in ALLOWED_AUDIO_TYPES: + return Response({"detail": "Неподдерживаемый формат аудио"}, status=400) + try: + duration = max(0, int(request.data.get("duration", 0))) + except (TypeError, ValueError): + duration = 0 + services.post_voice( + session, + content=upload.read(), + content_type=content_type, + duration=duration, + ) + return Response({"ok": True}, status=201) text = str(request.data.get("text", "")).strip() if not text: return Response({"detail": "Пустое сообщение"}, status=400) @@ -165,6 +190,31 @@ class WebchatMessagesView(_Public): return Response(services.messages_payload(session, since)) +class WebchatMessageAudioView(_Public): + def get(self, request: Request, message_id: int) -> Response | FileResponse: + with _resolved_web_session(request) as (_context, session): + if session is None: + return Response({"detail": "Сессия не найдена"}, status=401) + message = ( + Message.objects.filter( + id=message_id, + kind=MessageKind.VOICE, + conversation__channel=session.connection.channel, + conversation__contact=session.identity.contact, + ) + .exclude(audio="") + .first() + ) + if message is None: + return Response({"detail": "Сообщение не найдено"}, status=404) + response = FileResponse( + message.audio.open("rb"), + content_type=message.audio_content_type or "audio/ogg", + ) + response["Cache-Control"] = "private, max-age=3600" + return response + + class WebchatContactView(_Public): def post(self, request: Request) -> Response: with _resolved_web_session(request) as (_context, session): diff --git a/apps/internal-ui/src/features/conversations/Composer.tsx b/apps/internal-ui/src/features/conversations/Composer.tsx index d9621a7..5dfe487 100644 --- a/apps/internal-ui/src/features/conversations/Composer.tsx +++ b/apps/internal-ui/src/features/conversations/Composer.tsx @@ -43,7 +43,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c }, }); // Каналы с транспортом отправки голосовых (transports.supports_voice_send). - const voiceAvailable = recorder.supported && (channel === "TG" || channel === "MAX"); + const voiceAvailable = recorder.supported && (channel === "TG" || channel === "MAX" || channel === "WEB"); if (conversationId == null) { return
Выберите диалог
; diff --git a/apps/web-chat/src/App.tsx b/apps/web-chat/src/App.tsx index a3f84f1..c8d0880 100644 --- a/apps/web-chat/src/App.tsx +++ b/apps/web-chat/src/App.tsx @@ -7,7 +7,9 @@ import { poll, sendContact, sendMessage, + sendVoice, startSession, + voiceAudioUrl, type CallInfo, type Poll, type WebConfig, @@ -15,6 +17,7 @@ import { } from "./api"; import { CallInviteBanner, ChatBody, ChatComposer, ChatHeader, StartChatFooter } from "./ChatView"; import { useScrollToLatest } from "./useScrollToLatest"; +import { useVoiceRecorder } from "./useVoiceRecorder"; import { useWidgetActivity } from "./widgetActivity"; const PARAMS = new URLSearchParams(location.search); @@ -47,6 +50,14 @@ export function App() { const scrollToLatest = useScrollToLatest(bodyRef); const incomingCall = Boolean(call && (call.status === "REQUESTED" || call.status === "RINGING")); const notifyNewMessage = useWidgetActivity(incomingCall); + const recorder = useVoiceRecorder({ + onSend: async (audio, durationSeconds) => { + if (!token) return; + const ok = await sendVoice(token, audio, durationSeconds); + if (!ok) throw new Error("Не удалось отправить голосовое"); + try { ingestPoll(await poll(token, lastId.current)); } catch { /* polling loop will retry */ } + }, + }); useEffect(() => { getConfig(ENTRY, HOST_ORIGIN).then(setConfig).catch(() => setConfig({ available: false })); @@ -149,10 +160,10 @@ export function App() { return (
- + voiceAudioUrl(token, id) : undefined} /> {config?.available && accepted && call && (call.status === "REQUESTED" || call.status === "RINGING") && void acceptCallInvite()} onDecline={() => void declineCallInvite()} />} {config?.available && !accepted && void accept()} />} - {config?.available && accepted && void send()} />} + {config?.available && accepted && void send()} voice={recorder} />}
); } diff --git a/apps/web-chat/src/ChatView.tsx b/apps/web-chat/src/ChatView.tsx index 374b4da..7bd95ee 100644 --- a/apps/web-chat/src/ChatView.tsx +++ b/apps/web-chat/src/ChatView.tsx @@ -1,6 +1,13 @@ import { useEffect, useRef, useState, type RefObject } from "react"; import { isVideoCall, type CallInfo, type WebConfig, type WebMessage } from "./api"; +import type { useVoiceRecorder } from "./useVoiceRecorder"; + +export type ComposerVoice = ReturnType; + +function formatSeconds(total: number): string { + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; +} export function ChatHeader({ accent, letter, title, statusLabel, statusDot, unavailable, onClose }: { accent: string; letter: string; title: string; statusLabel: string; statusDot: string; unavailable: boolean; onClose: () => void }) { return ( @@ -20,7 +27,7 @@ export function ChatHeader({ accent, letter, title, statusLabel, statusDot, unav ); } -export function ChatBody({ bodyRef, config, unavailable, accepted, accent, letter, title, messages, pending, awaiting, lastContactRequestId, showPhoneForm, onSubmitContact }: { +export function ChatBody({ bodyRef, config, unavailable, accepted, accent, letter, title, messages, pending, awaiting, lastContactRequestId, showPhoneForm, onSubmitContact, audioUrlFor }: { bodyRef: RefObject; config: WebConfig | null; unavailable: boolean; @@ -34,6 +41,7 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette lastContactRequestId: number; showPhoneForm: boolean; onSubmitContact: (phone: string) => Promise; + audioUrlFor?: (messageId: number) => string; }) { return (
@@ -46,7 +54,7 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette {config.greeting && } {messages.map((message) => message.author === "system" ? - :
{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && }
)} + :
{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && }
)} {pending.map((text, index) => )} {awaiting && } @@ -73,7 +81,7 @@ export function StartChatFooter({ accent, starting, onAccept }: { accent: string const COMPOSER_MAX_HEIGHT = 132; -export function ChatComposer({ accent, state, quickReplies, pendingCount, messageCount, input, placeholder, onInput, onSend }: { accent: string; state: "ai" | "operator" | "waiting"; quickReplies: string[]; pendingCount: number; messageCount: number; input: string; placeholder?: string; onInput: (value: string) => void; onSend: () => void }) { +export function ChatComposer({ accent, state, quickReplies, pendingCount, messageCount, input, placeholder, onInput, onSend, voice }: { accent: string; state: "ai" | "operator" | "waiting"; quickReplies: string[]; pendingCount: number; messageCount: number; input: string; placeholder?: string; onInput: (value: string) => void; onSend: () => void; voice?: ComposerVoice }) { const textareaRef = useRef(null); // Поле растёт под текст, как в мессенджере (до COMPOSER_MAX_HEIGHT, дальше скролл). @@ -86,11 +94,31 @@ export function ChatComposer({ accent, state, quickReplies, pendingCount, messag node.style.height = node.scrollHeight > 0 ? `${Math.min(node.scrollHeight, COMPOSER_MAX_HEIGHT)}px` : ""; }, [input]); + if (voice && voice.state !== "idle") { + // Режим записи (кадр H, упрощённый для виджета): корзина · таймер · отправить. + const sending = voice.state === "sending"; + return ( +
+
+ + + {formatSeconds(voice.seconds)} + {sending ? "Отправка…" : "Идёт запись"} + +
+
+ ); + } + return (
+ {voice?.errorText &&
{voice.errorText}
} {state === "ai" && quickReplies.length > 0 && pendingCount === 0 && messageCount === 0 &&
{quickReplies.map((reply) => )}
}