✨ feat(chat): голосовые в web-виджете (дизайн-базлайн v2, кадр H)

Виджет записывает голосовое (MediaRecorder, зеркало хука internal-ui) и
шлёт multipart в /webchat/messages/; входящее байтами — voice_content в
InboundMessage, скачивание не нужно. Аудио отдаётся по токену сессии
(/webchat/messages/<id>/audio/), в пузыре — нативный плеер. Оператор
может отправлять голосовые и в WEB-канал (доставка поллингом виджета).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5 committed 2026-09-05 01:48:57 +03:00
1 parent ba405bb32b
commit ce8c753357
12 files changed
+314 -15

No files matched your search

@@ -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 "")
@@ -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:
@@ -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 = ""
+29 -1
View File
@@ -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),
@@ -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,
@@ -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/<int:message_id>/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"),
+51 -1
View File
@@ -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):
@@ -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 <div className="sales-composer"><div className="sales-waiting-composer"><div><strong>Выберите диалог</strong></div></div></div>;
+13 -2
View File
@@ -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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%", width: "100%", background: "#fff", fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif", color: "#1f1f1f", overflow: "hidden" }}>
<ChatHeader accent={accent} letter={letter} title={title} statusLabel={status.label} statusDot={status.dot} unavailable={unavailable} onClose={closePanel} />
<ChatBody bodyRef={bodyRef} config={config} unavailable={unavailable} accepted={accepted} accent={accent} letter={letter} title={title} messages={messages} pending={pending} awaiting={awaiting} lastContactRequestId={lastContactRequestId} showPhoneForm={showPhoneForm} onSubmitContact={submitContact} />
<ChatBody bodyRef={bodyRef} config={config} unavailable={unavailable} accepted={accepted} accent={accent} letter={letter} title={title} messages={messages} pending={pending} awaiting={awaiting} lastContactRequestId={lastContactRequestId} showPhoneForm={showPhoneForm} onSubmitContact={submitContact} audioUrlFor={token ? (id) => voiceAudioUrl(token, id) : undefined} />
{config?.available && accepted && call && (call.status === "REQUESTED" || call.status === "RINGING") && <CallInviteBanner call={call} accent={accent} onAccept={() => void acceptCallInvite()} onDecline={() => void declineCallInvite()} />}
{config?.available && !accepted && <StartChatFooter accent={accent} starting={starting} onAccept={() => void accept()} />}
{config?.available && accepted && <ChatComposer accent={accent} state={state} quickReplies={config.quickReplies ?? []} pendingCount={pending.length} messageCount={messages.length} input={input} onInput={setInput} onSend={() => void send()} />}
{config?.available && accepted && <ChatComposer accent={accent} state={state} quickReplies={config.quickReplies ?? []} pendingCount={pending.length} messageCount={messages.length} input={input} onInput={setInput} onSend={() => void send()} voice={recorder} />}
</div>
);
}
+37 -6
View File
@@ -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<typeof useVoiceRecorder>;
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<HTMLDivElement | null>;
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<boolean>;
audioUrlFor?: (messageId: number) => string;
}) {
return (
<div ref={bodyRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", background: "#f7f8fa", padding: "18px 16px" }}>
@@ -46,7 +54,7 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette
{config.greeting && <Bubble author="ai" text={config.greeting} accent={accent} />}
{messages.map((message) => message.author === "system"
? <SystemMessage key={message.id} text={message.text} />
: <div key={message.id}><Bubble author={message.author} text={message.text} accent={accent} time={message.createdAt} />{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && <PhoneForm accent={accent} onSubmit={onSubmitContact} />}</div>)}
: <div key={message.id}><Bubble author={message.author} text={message.hasAudio ? "" : message.text || "Голосовое сообщение"} accent={accent} time={message.createdAt} audioUrl={message.hasAudio && audioUrlFor ? audioUrlFor(message.id) : undefined} />{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && <PhoneForm accent={accent} onSubmit={onSubmitContact} />}</div>)}
{pending.map((text, index) => <Bubble key={`p${index}`} author="client" text={text} accent={accent} pendingState />)}
{awaiting && <Typing />}
</>
@@ -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<HTMLTextAreaElement>(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 (
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, border: "1px solid #e8e8e8", borderRadius: 14, padding: "6px 6px 6px 10px", minHeight: 48, boxSizing: "border-box" }}>
<button onClick={voice.cancel} disabled={sending} aria-label="Отменить запись" style={{ width: 34, height: 34, borderRadius: 9, border: "none", background: "transparent", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#8c8c8c" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#f5222d", flex: "none", animation: "wcTyping 1.2s infinite ease-in-out" }} />
<span style={{ fontSize: 13.5, fontWeight: 600, color: "#f5222d", fontVariantNumeric: "tabular-nums" }}>{formatSeconds(voice.seconds)}</span>
<span style={{ flex: 1, fontSize: 12.5, color: "#8c8c8c" }}>{sending ? "Отправка…" : "Идёт запись"}</span>
<button onClick={voice.stopAndSend} disabled={sending} aria-label="Отправить голосовое" style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: accent, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", opacity: sending ? 0.6 : 1 }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg></button>
</div>
</div>
);
}
return (
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}>
{voice?.errorText && <div style={{ marginBottom: 8, fontSize: 12, color: "#cf1322" }}>{voice.errorText}</div>}
{state === "ai" && quickReplies.length > 0 && pendingCount === 0 && messageCount === 0 && <div style={{ display: "flex", gap: 7, flexWrap: "wrap", marginBottom: 10 }}>{quickReplies.map((reply) => <button key={reply} onClick={() => onInput(reply)} style={{ padding: "7px 13px", borderRadius: 16, border: "1px solid #d6e4ff", background: "#f0f7ff", color: "#0958d9", fontSize: 12.5, fontWeight: 500, cursor: "pointer" }}>{reply}</button>)}</div>}
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, border: "1px solid #e8e8e8", borderRadius: 14, padding: "6px 6px 6px 14px", background: "#fff" }}>
<textarea ref={textareaRef} rows={1} value={input} onChange={(event) => onInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); onSend(); } }} placeholder={placeholder ?? "Напишите сообщение…"} style={{ flex: 1, border: "none", outline: "none", resize: "none", fontSize: 14, lineHeight: 1.5, color: "#262626", fontFamily: "inherit", padding: "7px 0", maxHeight: COMPOSER_MAX_HEIGHT }} />
{voice?.supported && !input.trim() && (
<button onClick={() => void voice.start()} aria-label="Записать голосовое" style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: "transparent", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#8c8c8c" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2" /><line x1="12" y1="19" x2="12" y2="22" /></svg></button>
)}
<button onClick={onSend} aria-label="Отправить" style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: accent, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg></button>
</div>
</div>
@@ -115,11 +143,14 @@ function formatTime(iso: string | undefined): string {
const META: React.CSSProperties = { fontSize: 11, color: "#bfbfbf", marginTop: 4 };
export function Bubble({ author, text, accent, time, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; time?: string; pendingState?: boolean }) {
export function Bubble({ author, text, accent, time, pendingState, audioUrl }: { author: "client" | "ai" | "operator"; text: string; accent: string; time?: string; pendingState?: boolean; audioUrl?: string }) {
const at = formatTime(time);
if (author === "client") return <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 12 }}><div style={{ maxWidth: "82%" }}><div style={{ background: accent, color: "#fff", borderRadius: "16px 16px 4px 16px", padding: "10px 14px", fontSize: 14.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{text}</div><div style={{ ...META, marginRight: 4, textAlign: "right" }}>{pendingState ? "отправка…" : [at, "доставлено"].filter(Boolean).join(" · ")}</div></div></div>;
const content = audioUrl
? <audio controls preload="none" src={audioUrl} style={{ width: 216, height: 36, display: "block" }} />
: text;
if (author === "client") return <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 12 }}><div style={{ maxWidth: "82%" }}><div style={{ background: accent, color: "#fff", borderRadius: "16px 16px 4px 16px", padding: audioUrl ? "8px" : "10px 14px", fontSize: 14.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div><div style={{ ...META, marginRight: 4, textAlign: "right" }}>{pendingState ? "отправка…" : [at, "доставлено"].filter(Boolean).join(" · ")}</div></div></div>;
const isOperator = author === "operator";
return <div style={{ display: "flex", gap: 9, marginBottom: 12 }}><div style={{ width: 30, height: 30, borderRadius: "50%", background: isOperator ? accent : "#eef0f2", border: isOperator ? "none" : "1px solid #e3e6ea", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{isOperator ? <span style={{ fontSize: 11.5, fontWeight: 600 }}>О</span> : <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#8c8c8c" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="3.2" /><path d="M5.5 20a6.5 6.5 0 0 1 13 0" /></svg>}</div><div style={{ maxWidth: "82%" }}><div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "16px 16px 16px 4px", padding: "10px 14px", fontSize: 14.5, lineHeight: 1.5, color: "#262626", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{text}</div><div style={{ ...META, marginLeft: 4 }}>{[isOperator ? "Специалист" : "Виртуальный помощник", at].filter(Boolean).join(" · ")}</div></div></div>;
return <div style={{ display: "flex", gap: 9, marginBottom: 12 }}><div style={{ width: 30, height: 30, borderRadius: "50%", background: isOperator ? accent : "#eef0f2", border: isOperator ? "none" : "1px solid #e3e6ea", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>{isOperator ? <span style={{ fontSize: 11.5, fontWeight: 600 }}>О</span> : <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#8c8c8c" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="3.2" /><path d="M5.5 20a6.5 6.5 0 0 1 13 0" /></svg>}</div><div style={{ maxWidth: "82%" }}><div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "16px 16px 16px 4px", padding: audioUrl ? "8px" : "10px 14px", fontSize: 14.5, lineHeight: 1.5, color: "#262626", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div><div style={{ ...META, marginLeft: 4 }}>{[isOperator ? "Специалист" : "Виртуальный помощник", at].filter(Boolean).join(" · ")}</div></div></div>;
}
function formatPhone(raw: string): string {
+17 -2
View File
@@ -13,8 +13,9 @@ export type WebConfig = {
fallback?: { label: string; url: string }[];
};
// kind: "" — текст, "contact_request" — виджет рисует форму телефона, "contact" — клиент поделился номером.
export type WebMessage = { id: number; author: "client" | "ai" | "operator" | "system"; kind?: string; text: string; createdAt: string };
// kind: "" — текст, "contact_request" — виджет рисует форму телефона,
// "contact" — клиент поделился номером, "VOICE" — голосовое (см. hasAudio).
export type WebMessage = { id: number; author: "client" | "ai" | "operator" | "system"; kind?: string; text: string; createdAt: string; durationSeconds?: number; hasAudio?: boolean };
// Приглашение/состояние онлайн-звонка (SPEC-HUB-0013).
export type CallKind = "AUDIO" | "VIDEO";
@@ -70,6 +71,20 @@ export async function sendMessage(token: string, text: string): Promise<boolean>
return r.ok;
}
export async function sendVoice(token: string, audio: Blob, durationSeconds: number): Promise<boolean> {
const body = new FormData();
const type = (audio.type || "audio/webm").split(";")[0];
body.append("audio", audio, `voice.${type.split("/")[1] || "webm"}`);
body.append("duration", String(Math.round(durationSeconds)));
const r = await fetch(`${API}/messages/`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body });
return r.ok;
}
// URL аудио голосового: <audio src> не умеет заголовки — токен идёт параметром.
export function voiceAudioUrl(token: string, messageId: number): string {
return `${API}/messages/${messageId}/audio/?token=${encodeURIComponent(token)}`;
}
export async function sendContact(token: string, phone: string): Promise<boolean> {
const r = await fetch(`${API}/contact/`, {
method: "POST",
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef, useState } from "react";
// Запись голосового в композере виджета (дизайн-базлайн v2, кадр H):
// MediaRecorder, таймер, отмена/отправка. Формат — opus (ogg в Firefox,
// webm в Chromium). Зеркало internal-ui/features/conversations/useVoiceRecorder.
export type VoiceRecorderState = "idle" | "recording" | "sending";
function pickMimeType(): string {
if (typeof MediaRecorder === "undefined") return "";
for (const candidate of ["audio/ogg;codecs=opus", "audio/webm;codecs=opus", "audio/webm"]) {
if (MediaRecorder.isTypeSupported(candidate)) return candidate;
}
return "";
}
export function useVoiceRecorder({ onSend }: { onSend: (audio: Blob, durationSeconds: number) => Promise<void> }) {
const [state, setState] = useState<VoiceRecorderState>("idle");
const [seconds, setSeconds] = useState(0);
const [errorText, setErrorText] = useState("");
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const timerRef = useRef<number | null>(null);
const startedAtRef = useRef(0);
const discardRef = useRef(false);
const supported = typeof MediaRecorder !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia);
const cleanupStream = useCallback(() => {
recorderRef.current?.stream.getTracks().forEach((track) => track.stop());
recorderRef.current = null;
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
useEffect(() => () => {
discardRef.current = true;
recorderRef.current?.state !== "inactive" && recorderRef.current?.stop();
cleanupStream();
}, [cleanupStream]);
const start = useCallback(async () => {
if (!supported || state !== "idle") return;
setErrorText("");
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, pickMimeType() ? { mimeType: pickMimeType() } : undefined);
chunksRef.current = [];
discardRef.current = false;
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) chunksRef.current.push(event.data);
});
recorder.addEventListener("stop", () => {
const duration = (Date.now() - startedAtRef.current) / 1000;
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || "audio/webm" });
cleanupStream();
if (discardRef.current || blob.size === 0 || duration < 0.5) {
setState("idle");
setSeconds(0);
return;
}
setState("sending");
onSend(blob, duration)
.then(() => {
setState("idle");
setSeconds(0);
})
.catch((error) => {
setErrorText(error instanceof Error ? error.message : "Не удалось отправить голосовое");
setState("idle");
setSeconds(0);
});
});
recorderRef.current = recorder;
startedAtRef.current = Date.now();
recorder.start();
setState("recording");
setSeconds(0);
timerRef.current = window.setInterval(() => {
setSeconds(Math.floor((Date.now() - startedAtRef.current) / 1000));
}, 250);
} catch {
setErrorText("Нет доступа к микрофону");
}
}, [cleanupStream, onSend, state, supported]);
const stopAndSend = useCallback(() => {
if (recorderRef.current?.state === "recording") {
discardRef.current = false;
recorderRef.current.stop();
}
}, []);
const cancel = useCallback(() => {
if (recorderRef.current?.state === "recording") {
discardRef.current = true;
recorderRef.current.stop();
}
}, []);
return { supported, state, seconds, errorText, start, stopAndSend, cancel };
}