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

Транспорт MAX: /uploads?type=audio -> multipart-загрузка -> отправка
attachment c токеном, с повторами на attachment.not.ready. Диспетчер
send_voice/supports_voice_send переведён на карту провайдеров; запись
в композере доступна для каналов TG и MAX.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5 committed 2026-09-05 01:23:55 +03:00
1 parent 445e3c83c2
commit 60bb7e8a54
5 files changed
+104 -8

No files matched your search

@@ -34,7 +34,7 @@ from hub_platform.integrations.models import (
class VoiceTestCase(TestCase):
"""Голосовые сообщения (дизайн-базлайн v2, кадр H): приём, отдача,
расшифровка через BYOK, отправка оператором в Telegram."""
расшифровка через BYOK, отправка оператором в Telegram и MAX."""
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
@@ -194,6 +194,51 @@ class VoiceApiTests(VoiceTestCase):
self.assertEqual(sent.duration_seconds, 3)
self.assertTrue(sent.audio)
def test_max_send_voice_uploads_then_sends_attachment(self) -> None:
from hub_platform.conversations import transports
from hub_platform.conversations.transports import max as max_transport
integration = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.MAX,
name="MAX Bot",
secret="max-token",
channel=Channel.objects.create(
organization=self.organization, code="max-line", name="MAX"
),
)
self.assertTrue(transports.supports_voice_send(integration))
# Последовательность запросов: /uploads -> multipart -> /messages.
with mock.patch.object(
max_transport,
"request_json",
side_effect=[{"url": "https://upload.example/audio"}, {"message": {}}],
) as request_json, mock.patch.object(
max_transport,
"request_json_multipart",
return_value={"token": "att-1"},
) as upload:
sent = transports.send_voice(
integration,
chat_id="c-1",
user_id="",
content=b"WEBMDATA",
content_type="audio/webm",
duration=3,
)
self.assertTrue(sent)
upload.assert_called_once()
self.assertEqual(upload.call_args.args[0], "https://upload.example/audio")
send_call = request_json.call_args_list[-1]
self.assertIn("/messages?", send_call.args[0])
self.assertEqual(
send_call.kwargs["body"],
{"attachments": [{"type": "audio", "payload": {"token": "att-1"}}]},
)
def test_voice_send_requires_assignment_and_supported_channel(self) -> None:
message = self._voice_message()
conversation = message.conversation
@@ -73,7 +73,12 @@ def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url:
# Голосовые (дизайн-базлайн v2, кадр H): скачивание входящих — TG (file_id) и
# MAX (прямой url); отправка операторских голосовых поддержана в Telegram.
# MAX (прямой url); отправка операторских голосовых — Telegram и MAX.
_VOICE_SEND = {
IntegrationProvider.TELEGRAM: _telegram.send_voice,
IntegrationProvider.MAX: _max.send_voice,
}
def download_voice(integration, inbound) -> tuple[bytes, str]:
@@ -85,13 +90,14 @@ def download_voice(integration, inbound) -> tuple[bytes, str]:
def supports_voice_send(integration) -> bool:
return integration.provider == IntegrationProvider.TELEGRAM
return integration.provider in _VOICE_SEND
def send_voice(integration, *, chat_id: str, user_id: str, content: bytes, content_type: str, duration: int) -> bool:
if integration.provider != IntegrationProvider.TELEGRAM:
sender = _VOICE_SEND.get(integration.provider)
if sender is None:
return False
return _telegram.send_voice(
return sender(
integration,
chat_id=chat_id,
user_id=user_id,
@@ -11,12 +11,13 @@ import http.client
import json
import logging
import re
import time
import urllib.error
import urllib.parse
from django.conf import settings
from hub_platform.conversations.transports.base import InboundMessage, download_bytes, first, request_json
from hub_platform.conversations.transports.base import InboundMessage, download_bytes, first, request_json, request_json_multipart
from hub_platform.integrations.checks import DEFAULT_MAX_BASE_URL
logger = logging.getLogger(__name__)
@@ -178,3 +179,46 @@ def download_voice(integration, url: str) -> tuple[bytes, str]:
"""Скачивание голосового MAX по прямому URL вложения."""
content = download_bytes(url, proxy_url=_proxy(integration))
return content, "audio/ogg"
def send_voice(integration, *, chat_id: str, user_id: str, content: bytes, content_type: str, duration: int) -> bool:
"""Отправка голосового оператора: /uploads?type=audio -> multipart -> attachment token."""
token = integration.secret
if not token or not (chat_id or user_id):
return False
suffix = (content_type.rsplit("/", 1)[-1] or "ogg").split(";")[0]
try:
upload = request_json(
f"{_base(integration)}/uploads?type=audio",
headers={"Authorization": token, "Content-Type": "application/json"},
method="POST",
body={},
proxy_url=_proxy(integration),
)
upload_url = str(upload.get("url") or "")
if not upload_url:
return False
uploaded = request_json_multipart(
upload_url,
fields={},
file_field="data",
filename=f"voice.{suffix}",
content=content,
content_type=content_type,
proxy_url=_proxy(integration),
)
attach_token = str(uploaded.get("token") or "")
if not attach_token:
return False
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("MAX voice upload failed for integration %s: %s", integration.id, error)
return False
body = {"attachments": [{"type": "audio", "payload": {"token": attach_token}}]}
# Сразу после загрузки MAX может ответить attachment.not.ready — файл ещё
# обрабатывается на его стороне; даём пару повторов с паузой.
for attempt in range(3):
if attempt:
time.sleep(1)
if _send(integration, chat_id=chat_id, user_id=user_id, body=body):
return True
return False
@@ -1,7 +1,7 @@
"""Голосовые сообщения (дизайн-базлайн v2, кадр H).
Приём входящих — в ingest; здесь HTTP-слой оператора: отдача аудио, отправка
голосового в диалог (Telegram) и расшифровка через BYOK-провайдера организации
голосового в диалог (Telegram, MAX) и расшифровка через BYOK-провайдера организации
(решение владельца 2026-09-04).
"""
@@ -42,7 +42,8 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
onSent();
},
});
const voiceAvailable = recorder.supported && channel === "TG";
// Каналы с транспортом отправки голосовых (transports.supports_voice_send).
const voiceAvailable = recorder.supported && (channel === "TG" || channel === "MAX");
if (conversationId == null) {
return <div className="sales-composer"><div className="sales-waiting-composer"><div><strong>Выберите диалог</strong></div></div></div>;