mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ 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:
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>;
|
||||
|
||||
Reference in new issue
Block a user