mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ffad1e787 | ||
|
|
c69cd1b436 | ||
|
|
1f46ed62bd | ||
|
|
83b1119d6b | ||
|
|
c8b8246b35 | ||
|
|
b95a4edf6e | ||
|
|
19448eb05a | ||
|
|
a321109a8f | ||
|
|
e1cda4dd8b | ||
|
|
fed079e02d | ||
|
|
89c44db2bc | ||
|
|
ca23cd285f | ||
|
|
d34aea8a8a | ||
|
|
eb98b9b781 | ||
|
|
31985fc35c | ||
|
|
1ea0faf1c6 | ||
|
|
a41b23a5e2 | ||
|
|
089dd6ddc2 | ||
|
|
ae46508d22 | ||
|
|
bd3b9309bf | ||
|
|
e35133b159 | ||
|
|
e9b64b6afc | ||
|
|
65f7de6bc3 |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -32,3 +32,6 @@ Chatballs.zip
|
||||
|
||||
# Служебный каталог агента: локальные настройки запуска, а не часть продукта.
|
||||
.claude/
|
||||
|
||||
# Дизайн-хендоффы: рабочие материалы, а не часть продукта.
|
||||
design/
|
||||
@@ -16,18 +16,29 @@
|
||||
on_demand_tls {
|
||||
ask http://backend-platform:8000/api/v1/gateway/help-domain/
|
||||
}
|
||||
# Установку часто ставят за прокси панели (aaPanel, nginx хоста), который
|
||||
# снимает TLS и ходит сюда по http. Его X-Forwarded-Proto/For принимаются
|
||||
# только из частных сетей — оттуда, где такой прокси и стоит; клиент из
|
||||
# интернета подделать их не может.
|
||||
servers {
|
||||
trusted_proxies static private_ranges
|
||||
}
|
||||
}
|
||||
|
||||
(surfaces) {
|
||||
# Host уходит с портом: браузер шлёт Origin с портом (http://ip:8081), и
|
||||
# без него CSRF-проверка Django отвергала любой POST на нестандартном
|
||||
# порту. X-Forwarded-Proto Caddy ставит сам: {scheme}, а за доверенным
|
||||
# прокси — то, что прислал прокси (https, если TLS снят перед нами).
|
||||
|
||||
# Платформенная поверхность живёт на своём домене; пока он не задан,
|
||||
# матчер намеренно не совпадает ни с чем.
|
||||
@platform host {$CHATBALLS_PLATFORM_DOMAIN:platform.invalid}
|
||||
handle @platform {
|
||||
reverse_proxy backend-platform:8000 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up Host {hostport}
|
||||
header_up X-Real-IP {client_ip}
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +46,9 @@
|
||||
# frontend-контейнера.
|
||||
handle {
|
||||
reverse_proxy frontend:80 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up Host {hostport}
|
||||
header_up X-Real-IP {client_ip}
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<h1 align="center">Chatballs</h1>
|
||||
|
||||
<p align="center"><strong>AI customer support platform</strong></p>
|
||||
<p align="center"><strong>Self-hosted AI customer support platform</strong></p>
|
||||
|
||||
<p align="center">
|
||||
An AI platform that talks to your customers for you: it answers in messengers, email and web chat, and hands your team only the hard questions.
|
||||
@@ -23,6 +23,20 @@
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/cover.jpg" alt="Chatballs workspace: dialogs, conversation and contact card" width="1024">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/chat_balls">Telegram channel</a>
|
||||
·
|
||||
<a href="https://chatballs.ru">Website</a>
|
||||
·
|
||||
<a href="https://chatballs.com.edevs.tech/">Help center</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
@@ -43,7 +57,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Chatballs takes over the first line of customer conversations. An AI agent answers from your knowledge base in Telegram, MAX, email and the chat on your website. When the agent is not confident or the customer asks for a person, the conversation goes to your team together with a notification.
|
||||
Chatballs takes over the first line of customer conversations. An AI agent answers from your knowledge base in Telegram, MAX, VK, email and the chat on your website. When the agent is not confident or the customer asks for a person, the conversation goes to your team together with a notification.
|
||||
|
||||
The platform installs on your own server with a single command. Customer data stays with you. You connect the AI model with your own key.
|
||||
|
||||
@@ -100,7 +114,7 @@ Everything else is done in **Settings**.
|
||||
| Section | What to do |
|
||||
|---|---|
|
||||
| **Platform** | Set the installation domain. The gateway issues a Let's Encrypt certificate on its own and switches to HTTPS. Outgoing SMTP mail is configured here as well: it is needed for employee invitations and password recovery. |
|
||||
| **Integrations** | Connect an AI model provider: OpenRouter, any OpenAI-compatible service or a local model. A demo provider that needs no key is available for a first look. Then connect entry points: a Telegram bot, a MAX bot, a mailbox over IMAP/SMTP or a web widget for your site. |
|
||||
| **Integrations** | Connect an AI model provider: OpenRouter, any OpenAI-compatible service or a local model. A demo provider that needs no key is available for a first look. Then connect entry points: a Telegram bot, a MAX bot, a VK community, a mailbox over IMAP/SMTP or a web widget for your site. |
|
||||
| **Agents** | Create an AI agent: who it is, how it speaks, what rules it follows. Choose the model. Attach articles from the knowledge base. |
|
||||
| **Employees** | Invite your team by email, assign roles and groups. |
|
||||
|
||||
@@ -157,7 +171,7 @@ When the agent cannot find an answer or the customer asks for a real person, the
|
||||
|
||||
### All channels in one window
|
||||
|
||||
Telegram, MAX, email and website chat land in a single conversation list. The employee sees where the customer came from and replies in the same channel.
|
||||
Telegram, MAX, VK, email and website chat land in a single conversation list. The employee sees where the customer came from and replies in the same channel.
|
||||
|
||||
### Knowledge base with semantic search
|
||||
|
||||
@@ -173,7 +187,7 @@ The widget is installed with a single line of code and runs in an isolated windo
|
||||
|
||||
### Audio and video calls from the chat
|
||||
|
||||
The customer and the employee call each other straight from the conversation without third-party services. Works in the web widget, Telegram and MAX. A relay is available for difficult networks.
|
||||
The customer and the employee call each other straight from the conversation without third-party services. Works in the web widget, Telegram, MAX and VK. A relay is available for difficult networks.
|
||||
|
||||
### Operator workspace
|
||||
|
||||
@@ -255,7 +269,7 @@ Open the integration and run the check. For bots the usual cause is a wrong toke
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Messages from Telegram or MAX do not arrive</strong></summary>
|
||||
<summary><strong>Messages from Telegram, MAX or VK do not arrive</strong></summary>
|
||||
|
||||
The background worker polls the bots. Make sure it is running:
|
||||
|
||||
|
||||
+19
-5
@@ -23,6 +23,20 @@
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/cover.jpg" alt="Рабочее место Chatballs: список диалогов, переписка и карточка контакта" width="1024">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/chat_balls">Телеграм-канал</a>
|
||||
·
|
||||
<a href="https://chatballs.ru">Сайт</a>
|
||||
·
|
||||
<a href="https://chatballs.com.edevs.tech/">Центр помощи</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Что это](#что-это)
|
||||
@@ -43,7 +57,7 @@
|
||||
|
||||
## Что это
|
||||
|
||||
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
|
||||
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, ВКонтакте, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
|
||||
|
||||
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу.
|
||||
|
||||
@@ -100,7 +114,7 @@ docker compose up -d --wait
|
||||
| Раздел | Что сделать |
|
||||
|---|---|
|
||||
| **Платформа** | Укажите домен установки. Шлюз сам выпустит сертификат Let's Encrypt и переведёт работу на HTTPS. Здесь же задаётся исходящая почта по SMTP: она нужна для приглашений сотрудников и восстановления паролей. |
|
||||
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
|
||||
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, сообщество ВКонтакте, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
|
||||
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель. Прикрепите статьи из базы знаний. |
|
||||
| **Сотрудники** | Пригласите команду по почте, распределите роли и группы. |
|
||||
|
||||
@@ -157,7 +171,7 @@ docker compose pull && docker compose up -d --wait
|
||||
|
||||
### Все каналы в одном окне
|
||||
|
||||
Telegram, MAX, электронная почта и чат на сайте попадают в единый список диалогов. Сотрудник видит, откуда пришёл клиент, и отвечает в том же канале.
|
||||
Telegram, MAX, ВКонтакте, электронная почта и чат на сайте попадают в единый список диалогов. Сотрудник видит, откуда пришёл клиент, и отвечает в том же канале.
|
||||
|
||||
### База знаний с семантическим поиском
|
||||
|
||||
@@ -173,7 +187,7 @@ Telegram, MAX, электронная почта и чат на сайте по
|
||||
|
||||
### Аудио- и видеозвонки из чата
|
||||
|
||||
Клиент и сотрудник созваниваются прямо из диалога без сторонних сервисов. Работает в веб-виджете, Telegram и MAX. Для сложных сетей есть relay.
|
||||
Клиент и сотрудник созваниваются прямо из диалога без сторонних сервисов. Работает в веб-виджете, Telegram, MAX и ВКонтакте. Для сложных сетей есть relay.
|
||||
|
||||
### Рабочее место оператора
|
||||
|
||||
@@ -255,7 +269,7 @@ docker compose logs gateway
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Сообщения из Telegram или MAX не приходят</strong></summary>
|
||||
<summary><strong>Сообщения из Telegram, MAX или ВКонтакте не приходят</strong></summary>
|
||||
|
||||
Фоновый воркер опрашивает ботов. Проверьте, что он запущен:
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.db import transaction
|
||||
from django.db.models import Case, Count, IntegerField, Q, QuerySet, Value, When
|
||||
from django.utils.text import slugify
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, AnswerLanguage
|
||||
from chatballs.ai.models import HISTORY_LIMIT_MAX, AIAgent, AIAgentStatus, AnswerLanguage
|
||||
from chatballs.ai.serializers import agent_portal_article_payload
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.channels.services import (
|
||||
@@ -108,6 +108,13 @@ def knowledge_total_for_organization(organization_id: int) -> int:
|
||||
)
|
||||
|
||||
|
||||
def _integration_model(integration, key: str) -> str:
|
||||
"""Модель, заданная в интеграции: подсказка в поле модели на карточке."""
|
||||
if integration is None:
|
||||
return ""
|
||||
return str((integration.config or {}).get(key) or "")
|
||||
|
||||
|
||||
def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) -> dict[str, object]:
|
||||
agent: AIAgent = channel.ai_agent
|
||||
connections = _connections_payload(channel)
|
||||
@@ -125,10 +132,21 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
|
||||
# Цвет группы задаётся в настройках — точка у названия (кадры G1/G3).
|
||||
"groupColor": channel.group.color if channel.group_id else "",
|
||||
"aiStatus": agent.status,
|
||||
# Модели агента: пустая строка означает «как в интеграции», и тогда
|
||||
# карточка показывает модель интеграции подсказкой в поле.
|
||||
"model": agent.model,
|
||||
"transcriptionModel": agent.transcription_model,
|
||||
"providerModel": _integration_model(agent.provider_integration, "default_model"),
|
||||
"transcriptionProviderModel": _integration_model(
|
||||
agent.transcription_integration or agent.provider_integration,
|
||||
"transcription_model",
|
||||
),
|
||||
"providerIntegrationId": agent.provider_integration_id,
|
||||
# Чем расшифровывать голосовые; пусто — тем же провайдером, что отвечает.
|
||||
"transcriptionIntegrationId": agent.transcription_integration_id,
|
||||
"modelParams": agent.model_params,
|
||||
"answerLanguage": agent.answer_language,
|
||||
"historyLimit": agent.history_limit,
|
||||
"persona": agent.persona,
|
||||
"tone": agent.tone,
|
||||
"instructions": agent.instructions,
|
||||
@@ -230,11 +248,15 @@ def update_agent_card(
|
||||
|
||||
ai_fields = {
|
||||
"providerIntegrationId",
|
||||
"transcriptionIntegrationId",
|
||||
"model",
|
||||
"transcriptionModel",
|
||||
"modelParams",
|
||||
"persona",
|
||||
"tone",
|
||||
"instructions",
|
||||
"answerLanguage",
|
||||
"historyLimit",
|
||||
"knowledgeIds",
|
||||
}
|
||||
if ai_fields & set(body):
|
||||
@@ -256,6 +278,15 @@ def update_agent_card(
|
||||
provider_integration_id, int
|
||||
):
|
||||
raise ValidationError({"providerIntegrationId": t("api.integer_id_required")})
|
||||
transcription_integration_id = body.get(
|
||||
"transcriptionIntegrationId", agent.transcription_integration_id
|
||||
)
|
||||
if transcription_integration_id is not None and not isinstance(
|
||||
transcription_integration_id, int
|
||||
):
|
||||
raise ValidationError(
|
||||
{"transcriptionIntegrationId": t("api.integer_id_required")}
|
||||
)
|
||||
update_agent(
|
||||
context=context,
|
||||
agent=agent,
|
||||
@@ -263,6 +294,11 @@ def update_agent_card(
|
||||
# Имя агента следует за именем карточки: сущность одна.
|
||||
name=channel.name,
|
||||
provider_integration_id=provider_integration_id,
|
||||
transcription_integration_id=transcription_integration_id,
|
||||
model=str(body.get("model", agent.model)),
|
||||
transcription_model=str(
|
||||
body.get("transcriptionModel", agent.transcription_model)
|
||||
),
|
||||
model_params=model_params,
|
||||
allowed_tools=agent.allowed_tools,
|
||||
persona=str(body.get("persona", agent.persona)),
|
||||
@@ -271,6 +307,9 @@ def update_agent_card(
|
||||
answer_language=_clean_answer_language(
|
||||
body.get("answerLanguage", agent.answer_language)
|
||||
),
|
||||
history_limit=_clean_history_limit(
|
||||
body.get("historyLimit", agent.history_limit)
|
||||
),
|
||||
knowledge_ids=knowledge_ids,
|
||||
),
|
||||
)
|
||||
@@ -292,6 +331,16 @@ def _clean_answer_language(value: object) -> str:
|
||||
raise ValidationError({"answerLanguage": t("ai.unknown_answer_language")})
|
||||
|
||||
|
||||
def _clean_history_limit(value: object) -> int:
|
||||
"""Окно истории агента: целое число сообщений от 1 до HISTORY_LIMIT_MAX."""
|
||||
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= HISTORY_LIMIT_MAX:
|
||||
raise ValidationError(
|
||||
{"historyLimit": t("ai.history_limit_out_of_range", max=HISTORY_LIMIT_MAX)}
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def agent_deletion_blockers(channel: Channel) -> list[dict[str, object]]:
|
||||
"""Агент удаляется вместе с каналом; блокируют только внешние связи."""
|
||||
counts = (
|
||||
|
||||
@@ -246,6 +246,10 @@ class AgentCardTestChatView(APIView):
|
||||
history = request.data.get("history") or []
|
||||
if not isinstance(history, list):
|
||||
return Response({"detail": t("ai.history_must_be_list")}, status=400)
|
||||
# Проверочный чат видит то же окно истории, что и живой диалог.
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
if agent is not None:
|
||||
history = history[-agent.history_limit:]
|
||||
try:
|
||||
result = run_channel_turn(channel=channel, message=message, history=history)
|
||||
except ProviderError as error:
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
"""Обращения к LLM-провайдеру: подготовка, сам вызов и запись в журнал.
|
||||
|
||||
Вызов провайдера ждёт ответа десятки секунд, а подготовка и журнал — это
|
||||
обращения к базе. В одной функции они означают открытую транзакцию на всё время
|
||||
ожидания, а вместе с ней занятое соединение из пула и RLS-контекст
|
||||
(chatballs.tenancy.middleware). Поэтому шаги разделены: `prepare_*` и `record_*`
|
||||
вызывают внутри транзакции, `run_*` — вне её.
|
||||
|
||||
`invoke_chat` и `embed_texts` остаются для мест, где ждать под транзакцией не
|
||||
жалко: индексация знаний, предпросмотр карточки агента, тесты. Ход диалога с
|
||||
клиентом ходит по шагам (chatballs.ai.turn).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
@@ -15,19 +31,129 @@ from chatballs.ai.provider.base import (
|
||||
from chatballs.ai.provider.factory import get_provider
|
||||
from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilience
|
||||
|
||||
_breaker = CircuitBreaker()
|
||||
# Предохранитель считает сбои по ключу «организация + интеграция»: провайдер у
|
||||
# каждой организации свой, и отозванный ключ одной не имеет отношения к AI
|
||||
# остальных. Общий на процесс предохранитель гасил AI у всех сразу.
|
||||
_breakers: dict[tuple[int, int], CircuitBreaker] = {}
|
||||
|
||||
|
||||
def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]:
|
||||
def _breaker(key: tuple[int, int]) -> CircuitBreaker:
|
||||
breaker = _breakers.get(key)
|
||||
if breaker is None:
|
||||
breaker = CircuitBreaker()
|
||||
_breakers[key] = breaker
|
||||
return breaker
|
||||
|
||||
|
||||
def reset_breakers() -> None:
|
||||
"""Для тестов: забыть накопленные сбои провайдеров."""
|
||||
|
||||
_breakers.clear()
|
||||
|
||||
|
||||
def _breaker_key(channel) -> tuple[int, int]:
|
||||
"""Ключ предохранителя. Без канала провайдер может быть только тестовым —
|
||||
считать сбои там не по чему, и общий ключ (0, 0) никому не мешает."""
|
||||
|
||||
if channel is None:
|
||||
return (0, 0)
|
||||
return (channel.organization_id, routing.integration_id(channel))
|
||||
|
||||
|
||||
def _elapsed_ms(started: float) -> int:
|
||||
return int((time.monotonic() - started) * 1000)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ChatJob:
|
||||
"""Всё для похода к модели, уже прочитанное из базы."""
|
||||
|
||||
provider: LLMProvider
|
||||
model: str
|
||||
messages: list[ChatMessage]
|
||||
breaker_key: tuple[int, int]
|
||||
params: dict | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmbeddingJob:
|
||||
"""То же для эмбеддингов: вектор считается тем же провайдером организации."""
|
||||
|
||||
provider: LLMProvider
|
||||
model: str
|
||||
texts: list[str]
|
||||
breaker_key: tuple[int, int]
|
||||
|
||||
|
||||
def _effective_model(channel, requested_model: str | None) -> str:
|
||||
# BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции
|
||||
# организации с fallback на модель агента. Без интеграции модель остаётся
|
||||
# агентской: тестовый провайдер работает, прод упадёт в get_provider штатно.
|
||||
agent = channel.ai_agent
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
fallback = str(getattr(agent, "model", "") or "")
|
||||
if requested_model:
|
||||
return requested_model
|
||||
try:
|
||||
effective_model = routing.resolve_model(channel, fallback_model=agent.model)
|
||||
return routing.resolve_model(channel, fallback_model=fallback)
|
||||
except routing.IntegrationNotConfigured:
|
||||
effective_model = agent.model
|
||||
return get_provider(channel=channel), requested_model or effective_model
|
||||
return fallback
|
||||
|
||||
|
||||
def prepare_chat(
|
||||
*,
|
||||
channel,
|
||||
messages: list[ChatMessage],
|
||||
model: str | None = None,
|
||||
params: dict | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> ChatJob:
|
||||
"""Шаг в транзакции: провайдер, модель и очищенный от ПДн текст запроса."""
|
||||
|
||||
return ChatJob(
|
||||
provider=get_provider(channel=channel, timeout=timeout),
|
||||
model=_effective_model(channel, model),
|
||||
messages=[ChatMessage(role=item.role, content=redact(item.content)) for item in messages],
|
||||
breaker_key=_breaker_key(channel),
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
def run_chat(job: ChatJob) -> ChatResult:
|
||||
"""Шаг без транзакции: обращение к провайдеру."""
|
||||
|
||||
return call_with_resilience(
|
||||
lambda: job.provider.chat(messages=job.messages, model=job.model, params=job.params),
|
||||
retries=settings.CHATBALLS_AI_MAX_RETRIES,
|
||||
breaker=_breaker(job.breaker_key),
|
||||
)
|
||||
|
||||
|
||||
def record_chat(
|
||||
*,
|
||||
channel,
|
||||
job: ChatJob,
|
||||
purpose: str,
|
||||
result: ChatResult | None = None,
|
||||
error: Exception | None = None,
|
||||
latency_ms: int = 0,
|
||||
used_fragment_ids: list | None = None,
|
||||
) -> None:
|
||||
"""Шаг в транзакции: строка журнала вызовов — и об успехе, и об отказе."""
|
||||
|
||||
LlmInvocation.objects.create(
|
||||
organization=channel.organization,
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
operation="chat",
|
||||
model=result.model if result is not None else job.model,
|
||||
prompt_tokens=result.prompt_tokens if result else 0,
|
||||
completion_tokens=result.completion_tokens if result else 0,
|
||||
total_tokens=result.total_tokens if result else 0,
|
||||
latency_ms=latency_ms,
|
||||
status=LlmInvocationStatus.SUCCESS if result is not None else LlmInvocationStatus.ERROR,
|
||||
error="" if error is None else str(error)[:1000],
|
||||
used_fragment_ids=used_fragment_ids or [],
|
||||
)
|
||||
|
||||
|
||||
def invoke_chat(
|
||||
@@ -39,44 +165,82 @@ def invoke_chat(
|
||||
params: dict | None = None,
|
||||
used_fragment_ids: list | None = None,
|
||||
) -> ChatResult:
|
||||
provider, model = _prepare_invocation(channel=channel, requested_model=model)
|
||||
"""Три шага подряд, в транзакции вызывающего: там, где ждать не жалко."""
|
||||
|
||||
safe_messages = [ChatMessage(role=item.role, content=redact(item.content)) for item in messages]
|
||||
job = prepare_chat(channel=channel, messages=messages, model=model, params=params)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
result: ChatResult = call_with_resilience(
|
||||
lambda: provider.chat(messages=safe_messages, model=model, params=params),
|
||||
retries=settings.CHATBALLS_AI_MAX_RETRIES,
|
||||
breaker=_breaker,
|
||||
)
|
||||
result = run_chat(job)
|
||||
except ProviderError as error:
|
||||
LlmInvocation.objects.create(
|
||||
organization=channel.organization,
|
||||
record_chat(
|
||||
channel=channel,
|
||||
job=job,
|
||||
purpose=purpose,
|
||||
operation="chat",
|
||||
model=model,
|
||||
status=LlmInvocationStatus.ERROR,
|
||||
error=str(error)[:1000],
|
||||
latency_ms=int((time.monotonic() - started) * 1000),
|
||||
error=error,
|
||||
latency_ms=_elapsed_ms(started),
|
||||
)
|
||||
raise
|
||||
record_chat(
|
||||
channel=channel,
|
||||
job=job,
|
||||
purpose=purpose,
|
||||
result=result,
|
||||
latency_ms=_elapsed_ms(started),
|
||||
used_fragment_ids=used_fragment_ids,
|
||||
)
|
||||
return result
|
||||
|
||||
return_result = result
|
||||
|
||||
def prepare_embedding(
|
||||
*,
|
||||
channel,
|
||||
texts: list[str],
|
||||
model: str,
|
||||
timeout: float | None = None,
|
||||
) -> EmbeddingJob:
|
||||
"""Шаг в транзакции: провайдер эмбеддингов организации."""
|
||||
|
||||
return EmbeddingJob(
|
||||
provider=get_provider(channel=channel, timeout=timeout),
|
||||
model=model,
|
||||
texts=texts,
|
||||
breaker_key=_breaker_key(channel),
|
||||
)
|
||||
|
||||
|
||||
def run_embedding(job: EmbeddingJob) -> list[EmbeddingResult]:
|
||||
"""Шаг без транзакции: обращение к провайдеру."""
|
||||
|
||||
return call_with_resilience(
|
||||
lambda: job.provider.embed(texts=job.texts, model=job.model),
|
||||
retries=settings.CHATBALLS_AI_MAX_RETRIES,
|
||||
breaker=_breaker(job.breaker_key),
|
||||
)
|
||||
|
||||
|
||||
def record_embedding(
|
||||
*,
|
||||
channel=None,
|
||||
organization=None,
|
||||
model: str,
|
||||
purpose: str,
|
||||
results: list[EmbeddingResult],
|
||||
latency_ms: int = 0,
|
||||
) -> None:
|
||||
"""Шаг в транзакции: строка журнала."""
|
||||
|
||||
tokens = sum(result.tokens for result in results)
|
||||
LlmInvocation.objects.create(
|
||||
organization=channel.organization,
|
||||
organization=channel.organization if channel else organization,
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
operation="chat",
|
||||
model=result.model,
|
||||
prompt_tokens=result.prompt_tokens,
|
||||
completion_tokens=result.completion_tokens,
|
||||
total_tokens=result.total_tokens,
|
||||
latency_ms=int((time.monotonic() - started) * 1000),
|
||||
operation="embedding",
|
||||
model=model,
|
||||
prompt_tokens=tokens,
|
||||
total_tokens=tokens,
|
||||
latency_ms=latency_ms,
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
used_fragment_ids=used_fragment_ids or [],
|
||||
)
|
||||
return return_result
|
||||
|
||||
|
||||
def embed_texts(
|
||||
@@ -87,21 +251,17 @@ def embed_texts(
|
||||
model: str,
|
||||
purpose: str = "retrieval",
|
||||
) -> list[EmbeddingResult]:
|
||||
provider = get_provider(channel=channel)
|
||||
results: list[EmbeddingResult] = call_with_resilience(
|
||||
lambda: provider.embed(texts=texts, model=model),
|
||||
retries=settings.CHATBALLS_AI_MAX_RETRIES,
|
||||
breaker=_breaker,
|
||||
)
|
||||
tokens = sum(result.tokens for result in results)
|
||||
LlmInvocation.objects.create(
|
||||
organization=channel.organization if channel else organization,
|
||||
"""Три шага подряд: индексация знаний и прочие неинтерактивные места."""
|
||||
|
||||
job = prepare_embedding(channel=channel, texts=texts, model=model)
|
||||
started = time.monotonic()
|
||||
results = run_embedding(job)
|
||||
record_embedding(
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
operation="embedding",
|
||||
organization=organization,
|
||||
model=model,
|
||||
prompt_tokens=tokens,
|
||||
total_tokens=tokens,
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
purpose=purpose,
|
||||
results=results,
|
||||
latency_ms=_elapsed_ms(started),
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-15 04:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai', '0019_drop_llm_cost_accounting'),
|
||||
('integrations', '0008_encrypted_column_width'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aiagent',
|
||||
name='transcription_integration',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transcribing_agents', to='integrations.integration'),
|
||||
),
|
||||
]
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-15 05:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai', '0020_aiagent_transcription_integration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aiagent',
|
||||
name='transcription_model',
|
||||
field=models.CharField(blank=True, default='', max_length=128),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aiagent',
|
||||
name='model',
|
||||
field=models.CharField(blank=True, default='', max_length=128),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Агент перестаёт дублировать модель интеграции.
|
||||
|
||||
Раньше модель копировалась на агента при каждом сохранении, и поле означало
|
||||
«то же, что у интеграции». Теперь заполненное поле означает выбор человека:
|
||||
агент отвечает именно этой моделью, даже если у интеграции другая по
|
||||
умолчанию. Чтобы смена настройки провайдера не перестала доезжать до агентов,
|
||||
которым модель никто не выбирал, совпадающее значение очищается — такие агенты
|
||||
продолжают следовать за интеграцией.
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def release_copied_models(apps, schema_editor):
|
||||
AIAgent = apps.get_model("ai", "AIAgent")
|
||||
updated = []
|
||||
for agent in AIAgent.objects.select_related("provider_integration").exclude(model=""):
|
||||
integration = agent.provider_integration
|
||||
default_model = str((integration.config or {}).get("default_model") or "") if integration else ""
|
||||
if agent.model == default_model:
|
||||
agent.model = ""
|
||||
updated.append(agent)
|
||||
AIAgent.objects.bulk_update(updated, ["model"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0021_aiagent_transcription_model_alter_aiagent_model"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(release_copied_models, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0022_release_agent_model_from_integration"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="aiagent",
|
||||
name="history_limit",
|
||||
field=models.PositiveSmallIntegerField(default=20),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,11 @@ DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
|
||||
|
||||
|
||||
# Границы окна истории агента (AIAgent.history_limit).
|
||||
HISTORY_LIMIT_DEFAULT = 20
|
||||
HISTORY_LIMIT_MAX = 200
|
||||
|
||||
|
||||
class AnswerLanguage(models.TextChoices):
|
||||
"""Режимы поля ``AIAgent.answer_language``, кроме кодов самих языков."""
|
||||
|
||||
@@ -334,7 +339,7 @@ class KnowledgeFragment(TenantRelationModel):
|
||||
|
||||
class AIAgent(TenantRelationModel):
|
||||
|
||||
tenant_relation_fields = ("channel", "provider_integration")
|
||||
tenant_relation_fields = ("channel", "provider_integration", "transcription_integration")
|
||||
|
||||
channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
|
||||
|
||||
@@ -358,6 +363,18 @@ class AIAgent(TenantRelationModel):
|
||||
|
||||
)
|
||||
|
||||
# Чем расшифровывать голосовые. Обычно это тот же провайдер, что и отвечает,
|
||||
# но не всегда: модель, которая пишет ответы, может не уметь речь в текст
|
||||
# (у Anthropic и Yandex Foundation Models аудио-эндпоинта нет вовсе).
|
||||
# Пусто — расшифровка идёт к провайдеру ответов, как было.
|
||||
transcription_integration = models.ForeignKey(
|
||||
"integrations.Integration",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="transcribing_agents",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
status = models.CharField(
|
||||
@@ -372,9 +389,19 @@ class AIAgent(TenantRelationModel):
|
||||
|
||||
lifecycle_version = models.PositiveIntegerField(default=0)
|
||||
|
||||
model = models.CharField(max_length=128, default=DEFAULT_AI_MODEL)
|
||||
# Модель ответов. Пусто — берётся модель по умолчанию из интеграции; так
|
||||
# агент следует за настройкой провайдера. Заполнено — решает агент: на одном
|
||||
# ключе живут разные агенты, и дорогая модель нужна не каждому.
|
||||
model = models.CharField(max_length=128, blank=True, default="")
|
||||
# Модель расшифровки голосовых. Пусто — модель из интеграции, которая
|
||||
# расшифровывает, а если и там пусто — whisper-1.
|
||||
transcription_model = models.CharField(max_length=128, blank=True, default="")
|
||||
|
||||
model_params = models.JSONField(default=dict, blank=True)
|
||||
# Сколько последних сообщений диалога уходит модели вместе с новым. Больше —
|
||||
# агент помнит длинный разговор, но каждый ответ дороже, а у локальной
|
||||
# модели с малым окном контекста хвост просто обрежется на её стороне.
|
||||
history_limit = models.PositiveSmallIntegerField(default=HISTORY_LIMIT_DEFAULT)
|
||||
|
||||
# Язык ответов клиенту. По умолчанию агент отвечает на языке, на котором
|
||||
# к нему обратились: сигнал точный, лежит прямо в сообщении и не требует
|
||||
|
||||
@@ -35,6 +35,16 @@ class ProviderError(Exception):
|
||||
"""Transient/technical provider failure (eligible for retry / circuit breaker)."""
|
||||
|
||||
|
||||
class ProviderRejected(ProviderError):
|
||||
"""Отказ, который повтором не лечится: провайдер не принял сам запрос.
|
||||
|
||||
Неверный ключ, несуществующая модель, слишком длинный контекст. Повтор
|
||||
потратит ещё один таймаут и получит тот же ответ, а клиент всё это время
|
||||
ждёт ответа. «Слишком часто» (429) сюда не относится — это как раз тот
|
||||
случай, когда повторить стоит.
|
||||
"""
|
||||
|
||||
|
||||
class LLMProvider(abc.ABC):
|
||||
name: str = "base"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ def _test_provider() -> LLMProvider:
|
||||
return LocalProvider()
|
||||
|
||||
|
||||
def get_provider(*, channel=None) -> LLMProvider:
|
||||
def get_provider(*, channel=None, timeout: float | None = None) -> LLMProvider:
|
||||
"""Resolve the organization's own provider (BYOK, ADR-CHATBALLS-0042 §3).
|
||||
|
||||
The test adapter is an explicit test-surface override. Managed platform
|
||||
@@ -29,4 +29,17 @@ def get_provider(*, channel=None) -> LLMProvider:
|
||||
raise ProviderError(
|
||||
t("ai.provider_not_configured")
|
||||
)
|
||||
return routing.resolve_provider(channel)
|
||||
return routing.resolve_provider(channel, timeout=timeout)
|
||||
|
||||
|
||||
def get_transcription_provider(*, channel=None, timeout: float | None = None) -> LLMProvider:
|
||||
"""Провайдер расшифровки голосовых.
|
||||
|
||||
Отличается от `get_provider` одним: агент может расшифровывать другим
|
||||
провайдером, чем отвечает (chatballs.ai.provider.routing).
|
||||
"""
|
||||
if settings.CHATBALLS_AI_PROVIDER == "test":
|
||||
return _test_provider()
|
||||
if channel is None:
|
||||
raise ProviderError(t("ai.provider_not_configured"))
|
||||
return routing.resolve_transcription_provider(channel, timeout=timeout)
|
||||
@@ -37,7 +37,19 @@ import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
|
||||
from chatballs.ai.provider.base import (
|
||||
|
||||
ChatMessage,
|
||||
|
||||
ChatResult,
|
||||
|
||||
EmbeddingResult,
|
||||
|
||||
ProviderError,
|
||||
|
||||
ProviderRejected,
|
||||
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
@@ -72,6 +84,20 @@ def post_json(*, base_url: str, path: str, api_key: str, payload: dict, timeout:
|
||||
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
# Отказ самого провайдера разбирается отдельно: 4xx (кроме 429) — это ключ,
|
||||
|
||||
# модель или размер запроса, и повтор даст тот же ответ через ещё один таймаут.
|
||||
|
||||
except urllib.error.HTTPError as error:
|
||||
|
||||
detail = error.read().decode("utf-8", "replace")[:300]
|
||||
|
||||
if error.code != 429 and 400 <= error.code < 500:
|
||||
|
||||
raise ProviderRejected(f"HTTP {error.code}: {detail}") from error
|
||||
|
||||
raise ProviderError(f"HTTP {error.code}: {detail}") from error
|
||||
|
||||
# http.client.HTTPException covers IncompleteRead/BadStatusLine (dropped reply)
|
||||
|
||||
# — those are not OSError, so they would slip past ProviderError otherwise.
|
||||
|
||||
@@ -36,7 +36,12 @@ class OpenRouterProvider(LLMProvider):
|
||||
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
|
||||
# OpenAI-совместимый POST /audio/transcriptions (whisper). Формат ответа
|
||||
# {"text": "..."}; ошибки транслируются в ProviderError.
|
||||
#
|
||||
# Наружу уходит фраза для человека, а не ответ провайдера: оператору
|
||||
# в ленте сообщений нечего делать с JSON чужого API. Сам ответ пишется
|
||||
# в журнал — по нему разбирают настройку.
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
@@ -44,6 +49,8 @@ class OpenRouterProvider(LLMProvider):
|
||||
from chatballs.conversations.transports.base import multipart_body
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
body, body_type = multipart_body(
|
||||
{"model": model},
|
||||
file_field="file",
|
||||
@@ -65,9 +72,24 @@ class OpenRouterProvider(LLMProvider):
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = error.read().decode("utf-8", "replace")[:300]
|
||||
raise ProviderError(t("ai.transcription_failed_http", code=error.code, detail=detail)) from error
|
||||
logger.warning(
|
||||
"Transcription rejected by %s: HTTP %s %s (model=%s)",
|
||||
self.base_url,
|
||||
error.code,
|
||||
detail,
|
||||
model,
|
||||
)
|
||||
# 401/403 — ключ или доступ; 404 — у провайдера нет эндпоинта
|
||||
# расшифровки (так отвечают Anthropic и Yandex Foundation Models);
|
||||
# остальное — временный отказ, который лечится повтором.
|
||||
if error.code in (401, 403):
|
||||
raise ProviderError(t("ai.transcription_denied")) from error
|
||||
if error.code == 404:
|
||||
raise ProviderError(t("ai.transcription_unsupported")) from error
|
||||
raise ProviderError(t("ai.transcription_failed")) from error
|
||||
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
|
||||
raise ProviderError(t("ai.transcription_failed", error=error)) from error
|
||||
logger.warning("Transcription request to %s failed: %s", self.base_url, error)
|
||||
raise ProviderError(t("ai.transcription_unreachable")) from error
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not text:
|
||||
raise ProviderError(t("ai.empty_transcript"))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.provider.base import ProviderError, ProviderRejected
|
||||
|
||||
|
||||
class CircuitBreakerOpen(ProviderError):
|
||||
@@ -44,6 +44,10 @@ def call_with_resilience(
|
||||
breaker.before()
|
||||
try:
|
||||
result = func()
|
||||
except ProviderRejected:
|
||||
# Провайдер отказал по существу запроса: повторять нечего, и
|
||||
# предохранитель тут ни при чём — сам провайдер жив и отвечает.
|
||||
raise
|
||||
except ProviderError:
|
||||
if breaker is not None:
|
||||
breaker.on_failure()
|
||||
|
||||
@@ -40,10 +40,14 @@ class IntegrationNotConfigured(ProviderError):
|
||||
"""
|
||||
|
||||
|
||||
def resolve_provider(channel) -> LLMProvider:
|
||||
"""Build the BYOK LLMProvider from the channel's explicit integration."""
|
||||
def resolve_provider(channel, *, timeout: float | None = None) -> LLMProvider:
|
||||
"""Build the BYOK LLMProvider from the channel's explicit integration.
|
||||
|
||||
`timeout` переопределяет срок ожидания ответа: интерактивному ходу диалога
|
||||
отведено меньше, чем индексации знаний (chatballs.ai.turn).
|
||||
"""
|
||||
integration = _channel_integration(channel)
|
||||
return _provider_from_integration(integration)
|
||||
return _provider_from_integration(integration, timeout=timeout)
|
||||
|
||||
|
||||
def resolve_provider_and_model(channel, *, fallback_model: str) -> tuple[LLMProvider, str]:
|
||||
@@ -57,6 +61,17 @@ def resolve_provider_and_model(channel, *, fallback_model: str) -> tuple[LLMProv
|
||||
|
||||
|
||||
def resolve_model(channel, *, fallback_model: str) -> str:
|
||||
"""Модель ответов: выбранная на карточке агента, иначе модель интеграции.
|
||||
|
||||
Порядок именно такой: ключ провайдера один на организацию, а агентов на нём
|
||||
несколько, и модель — свойство агента, а не ключа. Пустое поле на карточке
|
||||
означает «как у интеграции», поэтому агент, которому модель не назначали,
|
||||
продолжает следовать за настройкой провайдера.
|
||||
"""
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
chosen = str(getattr(agent, "model", "") or "").strip()
|
||||
if chosen:
|
||||
return chosen
|
||||
integration = _channel_integration(channel)
|
||||
return str(integration.config.get("default_model") or "").strip() or fallback_model
|
||||
|
||||
@@ -64,10 +79,34 @@ def resolve_model(channel, *, fallback_model: str) -> str:
|
||||
DEFAULT_TRANSCRIPTION_MODEL = "whisper-1"
|
||||
|
||||
|
||||
def _transcription_integration(channel) -> Integration:
|
||||
"""Чем расшифровывать голосовые.
|
||||
|
||||
Обычно тем же провайдером, что и отвечает, но выбор отдельный: модель
|
||||
ответов может не уметь речь в текст. У Anthropic и Yandex Foundation Models
|
||||
эндпоинта `/audio/transcriptions` нет вовсе, и без отдельного выбора
|
||||
голосовые у такого агента расшифровать было нечем.
|
||||
"""
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
integration = getattr(agent, "transcription_integration", None) if agent else None
|
||||
if integration is None or not integration.secret:
|
||||
return _channel_integration(channel)
|
||||
return integration
|
||||
|
||||
|
||||
def resolve_transcription_provider(channel, *, timeout: float | None = None) -> LLMProvider:
|
||||
"""Провайдер расшифровки: отдельная интеграция агента либо провайдер ответов."""
|
||||
return _provider_from_integration(_transcription_integration(channel), timeout=timeout)
|
||||
|
||||
|
||||
def resolve_transcription_model(channel) -> str:
|
||||
"""Модель расшифровки голосовых из настроек AI-провайдера («Настройки →
|
||||
AI-провайдер», поле «Модель расшифровки»); по умолчанию whisper-1."""
|
||||
integration = _channel_integration(channel)
|
||||
"""Модель расшифровки: выбранная на карточке агента, иначе модель той
|
||||
интеграции, которая расшифровывает, иначе whisper-1."""
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
chosen = str(getattr(agent, "transcription_model", "") or "").strip()
|
||||
if chosen:
|
||||
return chosen
|
||||
integration = _transcription_integration(channel)
|
||||
return str(integration.config.get("transcription_model") or "").strip() or DEFAULT_TRANSCRIPTION_MODEL
|
||||
|
||||
|
||||
@@ -85,14 +124,29 @@ def _channel_integration(channel) -> Integration:
|
||||
return integration
|
||||
|
||||
|
||||
def _provider_from_integration(integration: Integration) -> LLMProvider:
|
||||
def integration_id(channel) -> int:
|
||||
"""Идентификатор интеграции канала; 0 — интеграции нет.
|
||||
|
||||
Нужен там, где интеграция — ключ, а не источник настроек: предохранитель
|
||||
считает сбои по конкретному ключу организации (chatballs.ai.invocation).
|
||||
"""
|
||||
try:
|
||||
return _channel_integration(channel).id
|
||||
except IntegrationNotConfigured:
|
||||
return 0
|
||||
|
||||
|
||||
def _provider_from_integration(
|
||||
integration: Integration, *, timeout: float | None = None
|
||||
) -> LLMProvider:
|
||||
from django.conf import settings
|
||||
|
||||
wait = timeout or settings.CHATBALLS_AI_REQUEST_TIMEOUT
|
||||
if integration.provider == IntegrationProvider.OPENROUTER:
|
||||
return OpenRouterProvider(
|
||||
api_key=integration.secret,
|
||||
base_url=integration.config.get("base_url") or settings.CHATBALLS_OPENROUTER_BASE_URL,
|
||||
timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT,
|
||||
timeout=wait,
|
||||
proxy_url=integration.config.get("proxy_url", ""),
|
||||
)
|
||||
if integration.provider == IntegrationProvider.DEMO:
|
||||
@@ -101,7 +155,7 @@ def _provider_from_integration(integration: Integration) -> LLMProvider:
|
||||
return CustomProvider(
|
||||
api_key=integration.secret,
|
||||
base_url=integration.config["base_url"],
|
||||
timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT,
|
||||
timeout=wait,
|
||||
proxy_url=integration.config.get("proxy_url", ""),
|
||||
)
|
||||
raise IntegrationNotConfigured(
|
||||
|
||||
@@ -56,3 +56,31 @@ def configure_agent_provider(
|
||||
{"providerIntegrationId": t("ai.integration_model_required")}
|
||||
)
|
||||
return ProviderSelection(model, integration)
|
||||
|
||||
|
||||
def configure_agent_transcription(
|
||||
*, context: TenantContext, integration_id: int | None
|
||||
) -> Integration | None:
|
||||
"""Интеграция, которой агент расшифровывает голосовые.
|
||||
|
||||
Пусто — расшифровка идёт к провайдеру ответов. Модель для неё живёт в самой
|
||||
интеграции («Модель расшифровки голосовых»), поэтому здесь проверяется
|
||||
только, что интеграция принадлежит организации и умеет быть провайдером.
|
||||
"""
|
||||
if integration_id is None:
|
||||
return None
|
||||
try:
|
||||
return Integration.objects.get(
|
||||
id=integration_id,
|
||||
organization_id=context.organization_id,
|
||||
kind=IntegrationKind.LLM_PROVIDER,
|
||||
provider__in=[
|
||||
IntegrationProvider.OPENROUTER,
|
||||
IntegrationProvider.CUSTOM,
|
||||
IntegrationProvider.DEMO,
|
||||
],
|
||||
)
|
||||
except (Integration.DoesNotExist, TypeError, ValueError) as error:
|
||||
raise ValidationError(
|
||||
{"transcriptionIntegrationId": t("ai.unknown_provider_integration")}
|
||||
) from error
|
||||
@@ -43,6 +43,26 @@ def semantic_search(
|
||||
)
|
||||
|
||||
|
||||
def merge_hits(
|
||||
agent: AIAgent,
|
||||
query: str,
|
||||
query_vector: list[float] | None,
|
||||
*,
|
||||
limit: int = 5,
|
||||
) -> list[KnowledgeFragment]:
|
||||
"""Оба поиска и их склейка — шаг в транзакции, без обращений наружу.
|
||||
|
||||
Вектор считается отдельно (chatballs.ai.turn): поход за эмбеддингом — это
|
||||
сеть, и держать ради него транзакцию незачем. Без вектора остаётся
|
||||
лексический поиск: знания находятся хуже, но находятся.
|
||||
"""
|
||||
semantic = semantic_search(agent, query_vector, limit=limit) if query_vector else []
|
||||
lexical = lexical_search(agent, query, limit=limit)
|
||||
seen = {fragment.id for fragment in semantic}
|
||||
merged = semantic + [fragment for fragment in lexical if fragment.id not in seen]
|
||||
return merged[:limit]
|
||||
|
||||
|
||||
class KnowledgeRetriever:
|
||||
"""Hybrid retriever: semantic (pgvector) primary, lexical (Postgres FTS) complementary."""
|
||||
|
||||
@@ -56,10 +76,6 @@ class KnowledgeRetriever:
|
||||
model=settings.CHATBALLS_AI_EMBEDDING_MODEL,
|
||||
purpose="retrieval_query",
|
||||
)[0].vector
|
||||
semantic = semantic_search(agent, query_vector, limit=limit)
|
||||
except ProviderError:
|
||||
semantic = []
|
||||
lexical = lexical_search(agent, query, limit=limit)
|
||||
seen = {fragment.id for fragment in semantic}
|
||||
merged = semantic + [fragment for fragment in lexical if fragment.id not in seen]
|
||||
return merged[:limit]
|
||||
query_vector = None
|
||||
return merge_hits(agent, query, query_vector, limit=limit)
|
||||
@@ -119,15 +119,19 @@ def knowledge_catalog(agent: AIAgent) -> str:
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def run_agent_turn(
|
||||
def build_turn_messages(
|
||||
*,
|
||||
agent: AIAgent,
|
||||
message: str,
|
||||
history: list[dict] | None = None,
|
||||
fragments: list[KnowledgeFragment],
|
||||
style_guard: bool = True,
|
||||
) -> AgentTurnResult:
|
||||
fragments = KnowledgeRetriever().retrieve(agent=agent, query=message, limit=5)
|
||||
) -> list[ChatMessage]:
|
||||
"""Промпт хода целиком: инструкции агента, каталог знаний, найденное, история.
|
||||
|
||||
Только чтение базы и склейка строк — обращений наружу здесь нет, поэтому
|
||||
сборку можно держать внутри транзакции (chatballs.ai.turn).
|
||||
"""
|
||||
messages: list[ChatMessage] = []
|
||||
system_prompt = agent_system_prompt(agent)
|
||||
if system_prompt:
|
||||
@@ -156,7 +160,30 @@ def run_agent_turn(
|
||||
ChatMessage(role=str(item.get("role", "user")), content=str(item.get("content", "")))
|
||||
)
|
||||
messages.append(ChatMessage(role="user", content=message))
|
||||
return messages
|
||||
|
||||
|
||||
def run_agent_turn(
|
||||
*,
|
||||
agent: AIAgent,
|
||||
message: str,
|
||||
history: list[dict] | None = None,
|
||||
style_guard: bool = True,
|
||||
) -> AgentTurnResult:
|
||||
"""Ход агента целиком, в транзакции вызывающего.
|
||||
|
||||
Остаётся для мест, где ждать провайдера под транзакцией не жалко:
|
||||
предпросмотр на карточке агента и тесты. Ход диалога с клиентом идёт
|
||||
шагами, вне транзакции (chatballs.ai.turn).
|
||||
"""
|
||||
fragments = KnowledgeRetriever().retrieve(agent=agent, query=message, limit=5)
|
||||
messages = build_turn_messages(
|
||||
agent=agent,
|
||||
message=message,
|
||||
history=history,
|
||||
fragments=fragments,
|
||||
style_guard=style_guard,
|
||||
)
|
||||
result = invoke_chat(
|
||||
channel=agent.channel,
|
||||
messages=messages,
|
||||
|
||||
@@ -9,7 +9,10 @@ from chatballs.ai.models import (
|
||||
AIAgentStatus,
|
||||
Knowledge,
|
||||
)
|
||||
from chatballs.ai.provider_selection import configure_agent_provider
|
||||
from chatballs.ai.provider_selection import (
|
||||
configure_agent_provider,
|
||||
configure_agent_transcription,
|
||||
)
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
@@ -19,12 +22,18 @@ from chatballs.tenancy.context import TenantContext
|
||||
class AgentInput:
|
||||
name: str
|
||||
provider_integration_id: int | None
|
||||
# Чем расшифровывать голосовые; None — тем же провайдером, что и отвечает.
|
||||
transcription_integration_id: int | None
|
||||
# Модели агента; пустая строка — «как в интеграции».
|
||||
model: str
|
||||
transcription_model: str
|
||||
model_params: dict
|
||||
allowed_tools: list
|
||||
persona: str
|
||||
tone: str
|
||||
instructions: str
|
||||
answer_language: str
|
||||
history_limit: int
|
||||
knowledge_ids: list[int] | None # None -> выбор знаний не меняется
|
||||
|
||||
|
||||
@@ -90,7 +99,9 @@ def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
|
||||
channel=channel,
|
||||
name=f"{channel.name} Agent",
|
||||
status=AIAgentStatus.DRAFT,
|
||||
model=selection.model,
|
||||
# Модель новой карточки не фиксируется: агент следует за интеграцией,
|
||||
# пока человек не выберет свою.
|
||||
model="",
|
||||
provider_integration=selection.integration,
|
||||
persona=data.persona,
|
||||
tone=data.tone,
|
||||
@@ -124,28 +135,38 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
|
||||
context=context,
|
||||
integration_id=data.provider_integration_id,
|
||||
)
|
||||
# Модель принадлежит интеграции; без провайдера прежняя модель сохраняется,
|
||||
# чтобы PATCH инструкций не стирал её у черновика.
|
||||
locked.model = selection.model if selection.integration else locked.model
|
||||
# Модель выбирают на карточке агента: на одном ключе провайдера живут разные
|
||||
# агенты, и модель им нужна разная. Пустое поле означает «как в интеграции»
|
||||
# и разрешается в момент вызова (ai.provider.routing).
|
||||
locked.model = data.model.strip()[:128]
|
||||
locked.transcription_model = data.transcription_model.strip()[:128]
|
||||
# Провайдер живёт на агенте: канал больше не изменяется при сохранении агента.
|
||||
locked.provider_integration = selection.integration
|
||||
locked.transcription_integration = configure_agent_transcription(
|
||||
context=context,
|
||||
integration_id=data.transcription_integration_id,
|
||||
)
|
||||
locked.model_params = data.model_params
|
||||
locked.allowed_tools = data.allowed_tools
|
||||
locked.persona = data.persona
|
||||
locked.tone = data.tone
|
||||
locked.instructions = data.instructions
|
||||
locked.answer_language = data.answer_language
|
||||
locked.history_limit = data.history_limit
|
||||
locked.save(
|
||||
update_fields=[
|
||||
"name",
|
||||
"model",
|
||||
"transcription_model",
|
||||
"provider_integration",
|
||||
"transcription_integration",
|
||||
"model_params",
|
||||
"allowed_tools",
|
||||
"persona",
|
||||
"tone",
|
||||
"instructions",
|
||||
"answer_language",
|
||||
"history_limit",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -253,6 +253,16 @@ class AgentCardUpdateTests(AgentCardTestCase):
|
||||
self.patch(knowledgeIds=[])
|
||||
self.assertEqual(agent.knowledge_items.count(), 0)
|
||||
|
||||
def test_history_limit_is_saved_and_validated(self) -> None:
|
||||
self.assertEqual(self.card["historyLimit"], 20)
|
||||
saved = self.patch(historyLimit=100)
|
||||
self.assertEqual(saved.status_code, 200)
|
||||
self.assertEqual(saved.json()["agent"]["historyLimit"], 100)
|
||||
for wrong in (0, 201, "50", 12.5, True, None):
|
||||
with self.subTest(value=wrong):
|
||||
self.assertEqual(self.patch(historyLimit=wrong).status_code, 400)
|
||||
self.assertEqual(AIAgent.objects.get(id=self.card["aiAgentId"]).history_limit, 100)
|
||||
|
||||
|
||||
class AgentCardActivationTests(AgentCardTestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -274,6 +284,55 @@ class AgentCardActivationTests(AgentCardTestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_transcription_integration_is_chosen_separately(self) -> None:
|
||||
# Модель ответов не обязана уметь речь в текст: у части провайдеров
|
||||
# аудио-эндпоинта нет вовсе, поэтому расшифровку можно увести к другому.
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.integrations.services import IntegrationInput, create_integration
|
||||
from chatballs.testing import system_tenant_context
|
||||
|
||||
answering = self._byok_integration()
|
||||
whisper = create_integration(
|
||||
context=system_tenant_context(self.organization),
|
||||
data=IntegrationInput(
|
||||
provider=IntegrationProvider.CUSTOM,
|
||||
name="Whisper",
|
||||
secret="sk-whisper",
|
||||
config={
|
||||
"baseUrl": "https://api.groq.com/openai/v1",
|
||||
"defaultModel": "any",
|
||||
"transcriptionModel": "whisper-large-v3",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
patched = self.client.patch(
|
||||
f"/api/v1/agents/{self.card['id']}/",
|
||||
data=json.dumps(
|
||||
{
|
||||
"providerIntegrationId": answering.id,
|
||||
"transcriptionIntegrationId": whisper.id,
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(patched.status_code, 200)
|
||||
self.assertEqual(
|
||||
patched.json()["agent"]["transcriptionIntegrationId"], whisper.id
|
||||
)
|
||||
agent = AIAgent.objects.get(id=self.card["aiAgentId"])
|
||||
self.assertEqual(agent.transcription_integration_id, whisper.id)
|
||||
self.assertEqual(agent.provider_integration_id, answering.id)
|
||||
|
||||
cleared = self.client.patch(
|
||||
f"/api/v1/agents/{self.card['id']}/",
|
||||
data=json.dumps({"transcriptionIntegrationId": None}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(cleared.status_code, 200)
|
||||
self.assertIsNone(cleared.json()["agent"]["transcriptionIntegrationId"])
|
||||
|
||||
def test_activation_without_provider_integration_is_rejected(self) -> None:
|
||||
# Активация требует выбранного провайдера организации (ADR-CHATBALLS-0042 §2);
|
||||
# деактивация свободна.
|
||||
@@ -292,8 +351,10 @@ class AgentCardActivationTests(AgentCardTestCase):
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(patched.status_code, 200)
|
||||
# Модель принадлежит интеграции: агент получает её default_model.
|
||||
self.assertEqual(patched.json()["agent"]["model"], "byok-model")
|
||||
# Своей модели у агента нет — он следует за интеграцией, и карточка
|
||||
# показывает её модель подсказкой.
|
||||
self.assertEqual(patched.json()["agent"]["model"], "")
|
||||
self.assertEqual(patched.json()["agent"]["providerModel"], "byok-model")
|
||||
|
||||
activated = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/")
|
||||
self.assertEqual(activated.status_code, 200)
|
||||
|
||||
@@ -58,7 +58,10 @@ class ProviderModeTests(TestCase):
|
||||
)
|
||||
agent = self.channel.ai_agent
|
||||
agent.provider_integration = integration
|
||||
agent.save(update_fields=["provider_integration"])
|
||||
# Пустая модель на агенте означает «как в интеграции» — именно так живёт
|
||||
# агент, которому модель не выбирали на карточке.
|
||||
agent.model = ""
|
||||
agent.save(update_fields=["provider_integration", "model"])
|
||||
self.channel.refresh_from_db()
|
||||
return integration
|
||||
|
||||
@@ -199,7 +202,9 @@ class AgentProviderOwnershipTests(TestCase):
|
||||
self.channel.provider_integration = integration
|
||||
self.channel.save(update_fields=["provider_integration"])
|
||||
self.agent.provider_integration = None
|
||||
self.agent.save(update_fields=["provider_integration"])
|
||||
# Агент следует за интеграцией: своей модели у него нет.
|
||||
self.agent.model = ""
|
||||
self.agent.save(update_fields=["provider_integration", "model"])
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(
|
||||
@@ -220,9 +225,23 @@ class AgentProviderOwnershipTests(TestCase):
|
||||
),
|
||||
)
|
||||
self.agent.provider_integration = current
|
||||
self.agent.save(update_fields=["provider_integration"])
|
||||
self.agent.model = ""
|
||||
self.agent.save(update_fields=["provider_integration", "model"])
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(
|
||||
resolve_model(self.channel, fallback_model="agent-model"), "current-model"
|
||||
)
|
||||
|
||||
def test_model_chosen_on_the_card_wins_over_the_integration(self) -> None:
|
||||
# Ключ провайдера один на организацию, агентов на нём несколько: модель
|
||||
# выбирают агенту, и она не должна теряться при смене настройки ключа.
|
||||
integration = self._integration(default_model="integration-model")
|
||||
self.agent.provider_integration = integration
|
||||
self.agent.model = "own-model"
|
||||
self.agent.save(update_fields=["provider_integration", "model"])
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(
|
||||
resolve_model(self.channel, fallback_model="agent-model"), "own-model"
|
||||
)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Расшифровка голосовых может идти не к тому провайдеру, который отвечает.
|
||||
|
||||
Модель ответов часто не умеет речь в текст: у Anthropic и Yandex Foundation
|
||||
Models эндпоинта `/audio/transcriptions` нет вовсе. Поэтому интеграция для
|
||||
расшифровки выбирается на агенте отдельно.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.provider.custom import CustomProvider
|
||||
from chatballs.ai.provider.routing import (
|
||||
resolve_model,
|
||||
resolve_transcription_model,
|
||||
resolve_transcription_provider,
|
||||
)
|
||||
from chatballs.ai.tests import make_channel_with_agent
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.integrations.services import IntegrationInput, create_integration
|
||||
from chatballs.testing import system_tenant_context
|
||||
|
||||
|
||||
class TranscriptionRoutingTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.context = system_tenant_context(self.organization)
|
||||
self.channel, self.agent = make_channel_with_agent(
|
||||
self.organization, code="voice-agent", name="Голосовой агент"
|
||||
)
|
||||
|
||||
def _integration(self, *, name: str, base_url: str, transcription_model: str = ""):
|
||||
config = {"baseUrl": base_url, "defaultModel": "answer-model"}
|
||||
if transcription_model:
|
||||
config["transcriptionModel"] = transcription_model
|
||||
return create_integration(
|
||||
context=self.context,
|
||||
data=IntegrationInput(
|
||||
provider=IntegrationProvider.CUSTOM,
|
||||
name=name,
|
||||
secret="sk-key",
|
||||
config=config,
|
||||
),
|
||||
)
|
||||
|
||||
def test_without_a_choice_transcription_goes_to_the_answering_provider(self) -> None:
|
||||
answering = self._integration(
|
||||
name="Ответы", base_url="https://answers.example.test/v1"
|
||||
)
|
||||
self.agent.provider_integration = answering
|
||||
self.agent.save(update_fields=["provider_integration"])
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
provider = resolve_transcription_provider(self.channel)
|
||||
self.assertIsInstance(provider, CustomProvider)
|
||||
self.assertEqual(provider.base_url, "https://answers.example.test/v1")
|
||||
|
||||
def test_chosen_integration_takes_the_voice(self) -> None:
|
||||
answering = self._integration(
|
||||
name="Ответы", base_url="https://answers.example.test/v1"
|
||||
)
|
||||
whisper = self._integration(
|
||||
name="Whisper",
|
||||
base_url="https://whisper.example.test/v1",
|
||||
transcription_model="whisper-large-v3",
|
||||
)
|
||||
self.agent.provider_integration = answering
|
||||
self.agent.transcription_integration = whisper
|
||||
self.agent.save(
|
||||
update_fields=["provider_integration", "transcription_integration"]
|
||||
)
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
provider = resolve_transcription_provider(self.channel)
|
||||
self.assertEqual(provider.base_url, "https://whisper.example.test/v1")
|
||||
self.assertEqual(resolve_transcription_model(self.channel), "whisper-large-v3")
|
||||
|
||||
def test_model_defaults_to_whisper_of_the_chosen_integration(self) -> None:
|
||||
answering = self._integration(
|
||||
name="Ответы",
|
||||
base_url="https://answers.example.test/v1",
|
||||
transcription_model="answer-side-model",
|
||||
)
|
||||
whisper = self._integration(
|
||||
name="Whisper", base_url="https://whisper.example.test/v1"
|
||||
)
|
||||
self.agent.provider_integration = answering
|
||||
self.agent.transcription_integration = whisper
|
||||
self.agent.save(
|
||||
update_fields=["provider_integration", "transcription_integration"]
|
||||
)
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(resolve_transcription_model(self.channel), "whisper-1")
|
||||
|
||||
|
||||
class TranscriptionModelTests(TranscriptionRoutingTests):
|
||||
"""Модель расшифровки тоже выбирается на агенте, а не только в интеграции."""
|
||||
|
||||
def test_model_from_the_card_wins(self) -> None:
|
||||
whisper = self._integration(
|
||||
name="Whisper",
|
||||
base_url="https://whisper.example.test/v1",
|
||||
transcription_model="whisper-large-v3",
|
||||
)
|
||||
self.agent.provider_integration = whisper
|
||||
self.agent.transcription_integration = whisper
|
||||
self.agent.transcription_model = "gpt-4o-mini-transcribe"
|
||||
self.agent.save(
|
||||
update_fields=[
|
||||
"provider_integration",
|
||||
"transcription_integration",
|
||||
"transcription_model",
|
||||
]
|
||||
)
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(
|
||||
resolve_transcription_model(self.channel), "gpt-4o-mini-transcribe"
|
||||
)
|
||||
|
||||
def test_text_and_voice_models_are_independent(self) -> None:
|
||||
answering = self._integration(
|
||||
name="Ответы", base_url="https://answers.example.test/v1"
|
||||
)
|
||||
whisper = self._integration(
|
||||
name="Whisper", base_url="https://whisper.example.test/v1"
|
||||
)
|
||||
self.agent.provider_integration = answering
|
||||
self.agent.transcription_integration = whisper
|
||||
self.agent.model = "yandexgpt/rc"
|
||||
self.agent.transcription_model = "whisper-large-v3-turbo"
|
||||
self.agent.save(
|
||||
update_fields=[
|
||||
"provider_integration",
|
||||
"transcription_integration",
|
||||
"model",
|
||||
"transcription_model",
|
||||
]
|
||||
)
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(resolve_model(self.channel, fallback_model=""), "yandexgpt/rc")
|
||||
self.assertEqual(
|
||||
resolve_transcription_model(self.channel), "whisper-large-v3-turbo"
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_transcription_provider(self.channel).base_url,
|
||||
"https://whisper.example.test/v1",
|
||||
)
|
||||
|
||||
|
||||
class TranscriptionErrorTextTests(TestCase):
|
||||
"""Оператору — фраза, провайдеру — журнал: сырого ответа API в ленте нет."""
|
||||
|
||||
def _provider(self) -> CustomProvider:
|
||||
return CustomProvider(
|
||||
api_key="sk-key", base_url="https://api.example.test/v1", timeout=5
|
||||
)
|
||||
|
||||
def _fail_with(self, code: int, body: bytes):
|
||||
error = urllib.error.HTTPError(
|
||||
"https://api.example.test/v1/audio/transcriptions",
|
||||
code,
|
||||
"error",
|
||||
{},
|
||||
BytesIO(body),
|
||||
)
|
||||
return mock.patch(
|
||||
"chatballs.integrations.proxy.build_opener",
|
||||
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
|
||||
)
|
||||
|
||||
def _transcribe(self):
|
||||
return self._provider().transcribe(
|
||||
audio=b"0" * 16, filename="voice.ogg", content_type="audio/ogg", model="m"
|
||||
)
|
||||
|
||||
def test_denied_request_does_not_leak_the_provider_answer(self) -> None:
|
||||
body = json.dumps(
|
||||
{"error": {"message": "Subscription is not supported for service accounts"}}
|
||||
).encode()
|
||||
with self._fail_with(403, body), self.assertRaises(ProviderError) as caught:
|
||||
self._transcribe()
|
||||
message = str(caught.exception)
|
||||
self.assertNotIn("Subscription", message)
|
||||
self.assertNotIn("403", message)
|
||||
self.assertIn("ключ", message)
|
||||
|
||||
def test_missing_endpoint_tells_where_to_look(self) -> None:
|
||||
with self._fail_with(404, b"not found"), self.assertRaises(ProviderError) as caught:
|
||||
self._transcribe()
|
||||
self.assertIn("расшифров", str(caught.exception).lower())
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Ход агента по шагам: транзакция — сеть — транзакция — сеть — транзакция.
|
||||
|
||||
Ответ клиенту складывается из двух обращений к провайдеру (вектор вопроса и
|
||||
сам ответ) и нескольких обращений к базе между ними. Сделанные подряд, они
|
||||
держат транзакцию организации открытой всё время ожидания провайдера — а это
|
||||
минуты (chatballs.ai.invocation). Здесь работа разложена так, чтобы каждое
|
||||
обращение к базе шло своей короткой транзакцией, а походы наружу оставались
|
||||
между ними.
|
||||
|
||||
Порядок шагов у вызывающего (chatballs.conversations.ai_turn):
|
||||
|
||||
1. в транзакции: `plan_query_embedding`
|
||||
2. вне транзакции: `run_query_embedding`
|
||||
3. в транзакции: `plan_chat`
|
||||
4. вне транзакции: `run_turn_chat`
|
||||
5. в транзакции: `record_turn` и запись ответа
|
||||
|
||||
Шаги `run_*` ошибок провайдера не поднимают: отказ — это такой же результат
|
||||
хода, его пишут в журнал и разбирают в диалоге (передачей оператору).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.ai.invocation import (
|
||||
ChatJob,
|
||||
EmbeddingJob,
|
||||
prepare_chat,
|
||||
prepare_embedding,
|
||||
record_chat,
|
||||
record_embedding,
|
||||
run_chat,
|
||||
run_embedding,
|
||||
)
|
||||
from chatballs.ai.models import AIAgent
|
||||
from chatballs.ai.provider.base import ChatResult, EmbeddingResult, ProviderError
|
||||
from chatballs.ai.retrieval import merge_hits
|
||||
from chatballs.ai.runtime import build_turn_messages
|
||||
|
||||
FRAGMENT_LIMIT = 5
|
||||
|
||||
|
||||
def _elapsed_ms(started: float) -> int:
|
||||
return int((time.monotonic() - started) * 1000)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QueryEmbedding:
|
||||
"""Вектор вопроса. Пустой вектор — обычное дело: остаётся лексический поиск."""
|
||||
|
||||
vector: list[float] | None = None
|
||||
model: str = ""
|
||||
latency_ms: int = 0
|
||||
results: list[EmbeddingResult] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TurnPlan:
|
||||
"""Готовый запрос к модели и то, на чём он основан."""
|
||||
|
||||
job: ChatJob
|
||||
fragment_ids: list[int]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TurnAnswer:
|
||||
"""Итог похода к модели: либо ответ, либо отказ, и сколько это заняло."""
|
||||
|
||||
result: ChatResult | None = None
|
||||
error: ProviderError | None = None
|
||||
latency_ms: int = 0
|
||||
|
||||
|
||||
def plan_query_embedding(*, agent: AIAgent, query: str) -> EmbeddingJob | None:
|
||||
"""Шаг в транзакции: чем считать вектор вопроса. None — считать нечем."""
|
||||
|
||||
if not query.strip():
|
||||
return None
|
||||
try:
|
||||
return prepare_embedding(
|
||||
channel=agent.channel,
|
||||
texts=[query],
|
||||
model=settings.CHATBALLS_AI_EMBEDDING_MODEL,
|
||||
timeout=settings.CHATBALLS_AI_TURN_TIMEOUT,
|
||||
)
|
||||
except ProviderError:
|
||||
# Провайдера нет или он не настроен: семантический поиск необязателен.
|
||||
return None
|
||||
|
||||
|
||||
def run_query_embedding(job: EmbeddingJob | None) -> QueryEmbedding:
|
||||
"""Шаг без транзакции: обращение к провайдеру за вектором."""
|
||||
|
||||
if job is None:
|
||||
return QueryEmbedding()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
results = run_embedding(job)
|
||||
except ProviderError:
|
||||
# Знания найдутся лексическим поиском; ход из-за этого не срывается.
|
||||
return QueryEmbedding(latency_ms=_elapsed_ms(started))
|
||||
return QueryEmbedding(
|
||||
vector=results[0].vector if results else None,
|
||||
model=job.model,
|
||||
latency_ms=_elapsed_ms(started),
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
def plan_chat(
|
||||
*,
|
||||
agent: AIAgent,
|
||||
message: str,
|
||||
history: list[dict] | None = None,
|
||||
embedding: QueryEmbedding | None = None,
|
||||
style_guard: bool = True,
|
||||
) -> TurnPlan:
|
||||
"""Шаг в транзакции: поиск знаний, сборка промпта и выбор модели.
|
||||
|
||||
Заодно здесь оседает журнальная строка о векторе вопроса: считали его
|
||||
снаружи транзакции, а писать её всё равно в базу.
|
||||
"""
|
||||
embedding = embedding or QueryEmbedding()
|
||||
if embedding.results:
|
||||
record_embedding(
|
||||
channel=agent.channel,
|
||||
model=embedding.model,
|
||||
purpose="retrieval_query",
|
||||
results=embedding.results,
|
||||
latency_ms=embedding.latency_ms,
|
||||
)
|
||||
fragments = merge_hits(agent, message, embedding.vector, limit=FRAGMENT_LIMIT)
|
||||
job = prepare_chat(
|
||||
channel=agent.channel,
|
||||
messages=build_turn_messages(
|
||||
agent=agent,
|
||||
message=message,
|
||||
history=history,
|
||||
fragments=fragments,
|
||||
style_guard=style_guard,
|
||||
),
|
||||
model=agent.model,
|
||||
params=agent.model_params or None,
|
||||
timeout=settings.CHATBALLS_AI_TURN_TIMEOUT,
|
||||
)
|
||||
return TurnPlan(job=job, fragment_ids=[fragment.id for fragment in fragments])
|
||||
|
||||
|
||||
def run_turn_chat(plan: TurnPlan) -> TurnAnswer:
|
||||
"""Шаг без транзакции: обращение к модели за ответом."""
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
result = run_chat(plan.job)
|
||||
except ProviderError as error:
|
||||
return TurnAnswer(error=error, latency_ms=_elapsed_ms(started))
|
||||
return TurnAnswer(result=result, latency_ms=_elapsed_ms(started))
|
||||
|
||||
|
||||
def record_turn(*, agent: AIAgent, plan: TurnPlan, answer: TurnAnswer) -> None:
|
||||
"""Шаг в транзакции: строка журнала вызовов — и об ответе, и об отказе."""
|
||||
|
||||
record_chat(
|
||||
channel=agent.channel,
|
||||
job=plan.job,
|
||||
purpose="agent_chat",
|
||||
result=answer.result,
|
||||
error=answer.error,
|
||||
latency_ms=answer.latency_ms,
|
||||
used_fragment_ids=plan.fragment_ids,
|
||||
)
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Ход AI по входящему сообщению — отдельная работа, а не часть приёма.
|
||||
|
||||
Раньше ответ считался прямо в приёме: цикл опроса мессенджеров и HTTP-запрос
|
||||
виджета ждали провайдера минутами, держа открытой транзакцию организации, — и
|
||||
всё это время ни одно другое входящее не забиралось. Теперь приём доводит дело
|
||||
до записи сообщения и ставит ход в очередь событий, а считает его роль событий
|
||||
(`run_worker --role=events`), которую можно держать в нескольких процессах.
|
||||
|
||||
Границы транзакций здесь и есть главное: каждое обращение к базе идёт своей
|
||||
короткой транзакцией, походы к провайдеру и в мессенджер остаются между ними.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.models import HISTORY_LIMIT_DEFAULT, AIAgent
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.turn import (
|
||||
plan_chat,
|
||||
plan_query_embedding,
|
||||
record_turn,
|
||||
run_query_embedding,
|
||||
run_turn_chat,
|
||||
)
|
||||
from chatballs.conversations import ai_turn_result, transports
|
||||
from chatballs.conversations.models import (
|
||||
AiTurnState,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
MessageKind,
|
||||
)
|
||||
from chatballs.conversations.transcription import (
|
||||
TranscriptionJob,
|
||||
mark_transcription_failed,
|
||||
prepare_transcription,
|
||||
run_transcription,
|
||||
store_transcription,
|
||||
)
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AI_TURN_REQUESTED = "conversation.ai_turn_requested"
|
||||
# Агрегат события — диалог: ходы одного диалога обрабатываются строго по
|
||||
# очереди (chatballs.events.services.claim_next_outbox_event).
|
||||
AGGREGATE_TYPE = "Conversation"
|
||||
|
||||
_ROLE = {
|
||||
MessageAuthor.CONTACT: "user",
|
||||
MessageAuthor.AI: "assistant",
|
||||
MessageAuthor.OPERATOR: "assistant",
|
||||
MessageAuthor.SYSTEM: "system",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Turn:
|
||||
"""Всё о ходе, прочитанное из базы первым шагом."""
|
||||
|
||||
message: Message
|
||||
conversation: Conversation
|
||||
agent: AIAgent
|
||||
user_id: str
|
||||
query: str
|
||||
history: list[dict]
|
||||
is_new_conversation: bool = False
|
||||
transcription_job: TranscriptionJob | None = None
|
||||
embedding_job: object | None = None
|
||||
# Ход прерван на подготовке, и клиенту есть что сказать: текст уходит ему
|
||||
# уже вне транзакции, как и обычный ответ.
|
||||
stopped: bool = False
|
||||
outgoing: str = ""
|
||||
|
||||
|
||||
def request_ai_turn(
|
||||
*,
|
||||
message: Message,
|
||||
user_id: str,
|
||||
context: TenantContext,
|
||||
is_new_conversation: bool = False,
|
||||
) -> None:
|
||||
"""Шаг в транзакции приёма: пометить сообщение и поставить ход в очередь."""
|
||||
|
||||
message.ai_turn_state = AiTurnState.PENDING
|
||||
message.save(update_fields=["ai_turn_state"])
|
||||
enqueue_event(
|
||||
DomainEvent(
|
||||
aggregate_type=AGGREGATE_TYPE,
|
||||
aggregate_id=str(message.conversation_id),
|
||||
event_type=AI_TURN_REQUESTED,
|
||||
payload={
|
||||
"messageId": message.id,
|
||||
"userId": user_id,
|
||||
# Про новый диалог операторов уже позвали при приёме: второй
|
||||
# оклик из-за нерасшифрованного голосового был бы лишним.
|
||||
"isNewConversation": is_new_conversation,
|
||||
},
|
||||
tenant_context=context,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def conversation_is_thinking(conversation_id: int) -> bool:
|
||||
"""Есть ли по диалогу ход, который прямо сейчас считается.
|
||||
|
||||
По этому же признаку виджет показывает клиенту, что ответ пишется.
|
||||
"""
|
||||
return Message.objects.filter(
|
||||
conversation_id=conversation_id,
|
||||
ai_turn_state__in=(AiTurnState.PENDING, AiTurnState.RUNNING),
|
||||
).exists()
|
||||
|
||||
|
||||
def _history(conversation: Conversation, limit: int) -> list[dict]:
|
||||
# С конца и с ограничением в базе: длинный диалог не поднимается в память
|
||||
# целиком ради последних сообщений. Самое новое — входящее, по которому
|
||||
# идёт ход, оно уходит модели отдельно.
|
||||
latest = conversation.messages.order_by("-created_at", "-id")[: limit + 1]
|
||||
prior = list(reversed(latest))[:-1]
|
||||
# Голосовые попадают в контекст стенограммой.
|
||||
return [
|
||||
{"role": _ROLE.get(m.author_type, "user"), "content": m.text or m.transcript}
|
||||
for m in prior
|
||||
if m.text or m.transcript
|
||||
]
|
||||
|
||||
|
||||
def _expired(message: Message) -> bool:
|
||||
deadline = timedelta(seconds=settings.CHATBALLS_AI_TURN_DEADLINE_SECONDS)
|
||||
return timezone.now() - message.created_at > deadline
|
||||
|
||||
|
||||
def _plan_transcription(message: Message, channel) -> TranscriptionJob | None:
|
||||
"""Голосовое без стенограммы: чем её снять. None — снимать нечем."""
|
||||
|
||||
if message.kind != MessageKind.VOICE or message.transcript:
|
||||
return None
|
||||
try:
|
||||
return prepare_transcription(channel, message)
|
||||
except ProviderError as error:
|
||||
logger.info("Voice transcription unavailable for message %s: %s", message.id, error)
|
||||
return None
|
||||
|
||||
|
||||
def _begin(*, message_id: int, user_id: str, is_new: bool, context: TenantContext) -> Turn | None:
|
||||
"""Шаг в транзакции: взять ход в работу — или отказаться от него.
|
||||
|
||||
Отказ здесь нормален и молчалив: событие могло приехать вторым заходом
|
||||
после сбоя, диалог мог уйти оператору, а ход мог пролежать в очереди
|
||||
дольше, чем ответ имеет смысл.
|
||||
"""
|
||||
message = (
|
||||
Message.objects.select_related(
|
||||
"conversation__channel__ai_agent",
|
||||
"conversation__channel__organization",
|
||||
"conversation__contact",
|
||||
"conversation__connection",
|
||||
)
|
||||
.filter(id=message_id)
|
||||
.first()
|
||||
)
|
||||
if message is None:
|
||||
return None
|
||||
if message.ai_turn_state not in (AiTurnState.PENDING, AiTurnState.RUNNING):
|
||||
return None
|
||||
conversation = message.conversation
|
||||
channel = conversation.channel
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
if conversation.control_mode != ControlMode.AI or agent is None or not agent.is_active:
|
||||
# Диалог успел уйти человеку либо агента отключили: отвечать не нужно.
|
||||
message.ai_turn_state = AiTurnState.DONE
|
||||
message.save(update_fields=["ai_turn_state"])
|
||||
return None
|
||||
turn = Turn(
|
||||
message=message,
|
||||
conversation=conversation,
|
||||
agent=agent,
|
||||
user_id=user_id,
|
||||
query=message.text or message.transcript,
|
||||
history=_history(conversation, agent.history_limit or HISTORY_LIMIT_DEFAULT),
|
||||
is_new_conversation=is_new,
|
||||
)
|
||||
message.ai_turn_state = AiTurnState.RUNNING
|
||||
message.save(update_fields=["ai_turn_state"])
|
||||
if _expired(message):
|
||||
turn.stopped = True
|
||||
turn.outgoing = ai_turn_result.store_failure(
|
||||
turn=turn, context=context, error="turn deadline passed"
|
||||
)
|
||||
return turn
|
||||
turn.transcription_job = _plan_transcription(message, channel)
|
||||
if turn.transcription_job is not None:
|
||||
# Вопрос станет известен после расшифровки — вместе с ним и вектор.
|
||||
return turn
|
||||
if not turn.query.strip():
|
||||
# Голосовое, которое нечем расшифровать, и прочее «отвечать не на что».
|
||||
ai_turn_result.store_voice_without_transcript(turn=turn, context=context)
|
||||
return None
|
||||
turn.embedding_job = plan_query_embedding(agent=agent, query=turn.query)
|
||||
return turn
|
||||
|
||||
|
||||
def _run_transcription(turn: Turn) -> str:
|
||||
"""Шаг без транзакции: голос в текст."""
|
||||
|
||||
try:
|
||||
return run_transcription(turn.transcription_job)
|
||||
except ProviderError as error:
|
||||
logger.info(
|
||||
"Voice transcription unavailable for message %s: %s", turn.message.id, error
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _apply_transcript(*, turn: Turn, transcript: str, context: TenantContext) -> bool:
|
||||
"""Шаг в транзакции: сохранить стенограмму. False — хода не будет."""
|
||||
|
||||
if not transcript.strip():
|
||||
mark_transcription_failed(turn.message)
|
||||
ai_turn_result.store_voice_without_transcript(turn=turn, context=context)
|
||||
return False
|
||||
store_transcription(turn.message, transcript)
|
||||
turn.query = transcript
|
||||
turn.embedding_job = plan_query_embedding(agent=turn.agent, query=transcript)
|
||||
return True
|
||||
|
||||
|
||||
def _deliver(turn: Turn, text: str) -> None:
|
||||
"""Шаг без транзакции: ответ уходит клиенту в его канал.
|
||||
|
||||
Веб-виджет забирает ответ поллингом — для него отправка пустая.
|
||||
"""
|
||||
if not text or turn.conversation.connection is None:
|
||||
return
|
||||
transports.send_reply(
|
||||
turn.conversation.connection,
|
||||
chat_id=turn.conversation.external_chat_id,
|
||||
user_id=turn.user_id,
|
||||
text=text,
|
||||
)
|
||||
|
||||
|
||||
def run_requested_turn(payload: dict, context: TenantContext) -> None:
|
||||
"""Ход целиком: короткие транзакции и походы наружу между ними."""
|
||||
|
||||
message_id = int(payload.get("messageId") or 0)
|
||||
user_id = str(payload.get("userId") or "")
|
||||
is_new = bool(payload.get("isNewConversation"))
|
||||
with tenant_atomic(context):
|
||||
turn = _begin(message_id=message_id, user_id=user_id, is_new=is_new, context=context)
|
||||
if turn is None:
|
||||
return
|
||||
if turn.stopped:
|
||||
_deliver(turn, turn.outgoing)
|
||||
return
|
||||
|
||||
if turn.transcription_job is not None:
|
||||
transcript = _run_transcription(turn)
|
||||
with tenant_atomic(context):
|
||||
if not _apply_transcript(turn=turn, transcript=transcript, context=context):
|
||||
return
|
||||
|
||||
embedding = run_query_embedding(turn.embedding_job)
|
||||
failure = None
|
||||
with tenant_atomic(context):
|
||||
try:
|
||||
plan = plan_chat(
|
||||
agent=turn.agent,
|
||||
message=turn.query,
|
||||
history=turn.history,
|
||||
embedding=embedding,
|
||||
)
|
||||
except ProviderError as error:
|
||||
# Провайдер не настроен вовсе — тот же отказ хода, что и молчание
|
||||
# модели: клиент получает понятный текст, диалог уходит человеку.
|
||||
failure = ai_turn_result.store_failure(turn=turn, context=context, error=error)
|
||||
if failure is not None:
|
||||
_deliver(turn, failure)
|
||||
return
|
||||
|
||||
answer = run_turn_chat(plan)
|
||||
with tenant_atomic(context):
|
||||
record_turn(agent=turn.agent, plan=plan, answer=answer)
|
||||
if answer.error is not None:
|
||||
outgoing = ai_turn_result.store_failure(
|
||||
turn=turn, context=context, error=answer.error
|
||||
)
|
||||
else:
|
||||
outgoing = ai_turn_result.store_answer(
|
||||
turn=turn, context=context, text=answer.result.text
|
||||
)
|
||||
_deliver(turn, outgoing)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Что делать с результатом хода AI: ответ клиенту либо передача оператору.
|
||||
|
||||
Отделено от оркестрации (chatballs.conversations.ai_turn) намеренно: там —
|
||||
порядок шагов и границы транзакций, здесь — правила диалога. Обе функции
|
||||
вызывают внутри транзакции и обе возвращают текст, который нужно отправить
|
||||
клиенту: сама отправка — это сеть, и её место снаружи транзакции.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.runtime import HANDOFF_TOKEN
|
||||
from chatballs.conversations.models import (
|
||||
AiTurnState,
|
||||
ExpectedResponder,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.notifications.models import NotificationAudience, NotificationType
|
||||
from chatballs.notifications.services import notify, notify_management
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - только для подсказок типов
|
||||
from chatballs.conversations.ai_turn import Turn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _finish(message: Message, state: str) -> None:
|
||||
message.ai_turn_state = state
|
||||
message.save(update_fields=["ai_turn_state"])
|
||||
|
||||
|
||||
def _contact_name(turn: Turn) -> str:
|
||||
return turn.conversation.contact.name or t("conversations.guest")
|
||||
|
||||
|
||||
def store_answer(*, turn: Turn, context: TenantContext, text: str) -> str:
|
||||
"""Ответ модели: запись в диалог и, если модель попросила, передача оператору.
|
||||
|
||||
Возвращает текст для отправки клиенту.
|
||||
"""
|
||||
conversation = turn.conversation
|
||||
reply = text
|
||||
handoff = HANDOFF_TOKEN in reply
|
||||
if handoff:
|
||||
reply = reply.replace(HANDOFF_TOKEN, "").strip()
|
||||
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=reply)
|
||||
conversation.last_activity_at = timezone.now()
|
||||
if handoff:
|
||||
enter_queue(conversation)
|
||||
else:
|
||||
conversation.expected_responder = ExpectedResponder.CUSTOMER
|
||||
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
|
||||
_finish(turn.message, AiTurnState.DONE)
|
||||
|
||||
if handoff:
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.AI_HANDED_OVER,
|
||||
text="AI передал диалог оператору",
|
||||
)
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
title=f"AI передал диалог · {_contact_name(turn)}",
|
||||
title_key="notifications.ai_handed_over",
|
||||
text_params={"contact": _contact_name(turn)},
|
||||
body=turn.query[:120],
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
dedup_key=f"handoff:{conversation.id}",
|
||||
)
|
||||
return reply
|
||||
|
||||
|
||||
def store_voice_without_transcript(*, turn: Turn, context: TenantContext) -> None:
|
||||
"""Отвечать не на что: голосовое без стенограммы уходит оператору.
|
||||
|
||||
Это не сбой AI, и клиент не должен видеть извинений за поломку: ему просто
|
||||
ответит человек.
|
||||
"""
|
||||
conversation = turn.conversation
|
||||
enter_queue(conversation)
|
||||
conversation.save(update_fields=QUEUE_FIELDS)
|
||||
_finish(turn.message, AiTurnState.FAILED)
|
||||
if turn.is_new_conversation:
|
||||
# Про новый диалог операторов уже позвали при приёме.
|
||||
return
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
title=f"Нужен оператор · {_contact_name(turn)}",
|
||||
title_key="notifications.operator_needed",
|
||||
text_params={"contact": _contact_name(turn)},
|
||||
body="Голосовое без расшифровки",
|
||||
body_key="notifications.voice_without_transcript",
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
dedup_key=f"media:{conversation.id}",
|
||||
)
|
||||
|
||||
|
||||
def store_failure(*, turn: Turn, context: TenantContext, error: object) -> str:
|
||||
"""Ответа не будет: диалог уходит оператору, клиент получает понятный текст.
|
||||
|
||||
Сбой AI не должен «терять» сообщение — ни отказ провайдера, ни ход,
|
||||
просроченный в очереди.
|
||||
"""
|
||||
conversation = turn.conversation
|
||||
channel = conversation.channel
|
||||
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
|
||||
|
||||
enter_queue(conversation)
|
||||
conversation.last_activity_at = timezone.now()
|
||||
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.AI_UNAVAILABLE,
|
||||
text="AI недоступен — диалог передан оператору",
|
||||
)
|
||||
fallback = t(
|
||||
"conversations.ai_unavailable_reply",
|
||||
language=customer_language(channel.organization),
|
||||
)
|
||||
Message.objects.create(
|
||||
conversation=conversation, author_type=MessageAuthor.AI, text=fallback
|
||||
)
|
||||
_finish(turn.message, AiTurnState.FAILED)
|
||||
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
title=f"Нужен оператор · {_contact_name(turn)}",
|
||||
title_key="notifications.operator_needed",
|
||||
text_params={"contact": _contact_name(turn)},
|
||||
body="AI временно недоступен, диалог ждёт ответа",
|
||||
body_key="notifications.ai_unavailable_waiting",
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
dedup_key=f"aifail:{conversation.id}",
|
||||
)
|
||||
notify_management(
|
||||
context=context,
|
||||
type=NotificationType.AI_STOPPED,
|
||||
title=f"Ошибка AI · {channel.name}",
|
||||
body="AI временно недоступен, диалог передан оператору",
|
||||
title_key="notifications.ai_error",
|
||||
body_key="notifications.ai_unavailable_handed_over",
|
||||
text_params={"channel": channel.name},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
dedup_key=f"aierror:{conversation.id}",
|
||||
)
|
||||
return fallback
|
||||
@@ -10,3 +10,4 @@ class ConversationsConfig(AppConfig):
|
||||
def ready(self) -> None:
|
||||
# Свежесть диалога поддерживает сигнал: сообщения создаются в семи местах.
|
||||
from chatballs.conversations import signals # noqa: F401
|
||||
from chatballs.conversations import event_handlers # noqa: F401 (register outbox handlers)
|
||||
@@ -40,6 +40,7 @@ from chatballs.identity.models import AuditEvent
|
||||
PROVIDER_CODE = {
|
||||
"MAX": "MAX",
|
||||
"TELEGRAM": "TG",
|
||||
"VK": "VK",
|
||||
"WEB": "WEB",
|
||||
"EMAIL": "EMAIL",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Обработчики outbox-событий домена диалогов."""
|
||||
|
||||
from chatballs.conversations.ai_turn import AI_TURN_REQUESTED, run_requested_turn
|
||||
from chatballs.events.handlers import register
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@register(AI_TURN_REQUESTED, manages_own_transaction=True)
|
||||
def handle_ai_turn_requested(payload: dict, context: TenantContext | None) -> None:
|
||||
"""Ход AI сам управляет транзакциями: он ходит к провайдеру и в мессенджер,
|
||||
и держать ради этого одну транзакцию на весь обработчик нельзя."""
|
||||
if context is None: # pragma: no cover - событие диалога всегда арендное
|
||||
return
|
||||
run_requested_turn(payload, context)
|
||||
@@ -1,22 +1,23 @@
|
||||
"""Inbound ingest for messenger connections (M2a).
|
||||
"""Приём входящих из подключений (M2a).
|
||||
|
||||
One inbound message -> contact/conversation/message -> AI turn (if the dialog is
|
||||
AI-controlled) -> outbound reply. Idempotent via the events InboxEvent.
|
||||
Одно входящее -> контакт/диалог/сообщение -> заявка на ход AI, если диалог
|
||||
ведёт агент. Повторы отсекаются через InboxEvent.
|
||||
|
||||
Обращений наружу здесь нет и быть не должно: приём вызывают цикл опроса
|
||||
мессенджеров и HTTP-запрос виджета, и ждать провайдера ни тот, ни другой не
|
||||
может. Ответ считает роль событий (chatballs.conversations.ai_turn).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.runtime import HANDOFF_TOKEN
|
||||
from chatballs.channels.runtime import run_channel_turn
|
||||
from chatballs.conversations import transports
|
||||
from chatballs.conversations.ai_turn import request_ai_turn
|
||||
from chatballs.conversations.contact_avatars import refresh_contact_avatar
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
@@ -28,27 +29,17 @@ from chatballs.conversations.models import (
|
||||
Message,
|
||||
MessageAuthor,
|
||||
MessageKind,
|
||||
SystemEvent,
|
||||
TranscriptStatus,
|
||||
)
|
||||
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, is_waiting
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.events.models import EventOwnership, InboxEvent
|
||||
from chatballs.i18n import t
|
||||
from chatballs.notifications.models import NotificationAudience, NotificationType
|
||||
from chatballs.notifications.services import notify, notify_management
|
||||
from chatballs.notifications.services import notify
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HISTORY_LIMIT = 20
|
||||
_ROLE = {
|
||||
MessageAuthor.CONTACT: "user",
|
||||
MessageAuthor.AI: "assistant",
|
||||
MessageAuthor.OPERATOR: "assistant",
|
||||
MessageAuthor.SYSTEM: "system",
|
||||
}
|
||||
|
||||
|
||||
def _already_processed(context: TenantContext, source: str, external_id: str, text: str) -> bool:
|
||||
"""Отметить сообщение обработанным; True — оно уже приходило.
|
||||
@@ -74,102 +65,6 @@ def _already_processed(context: TenantContext, source: str, external_id: str, te
|
||||
return True
|
||||
|
||||
|
||||
def _history(conversation: Conversation) -> list[dict]:
|
||||
messages = list(conversation.messages.order_by("created_at"))
|
||||
prior = messages[:-1][-_HISTORY_LIMIT:] # без только что сохранённого входящего
|
||||
# Голосовые попадают в контекст стенограммой.
|
||||
return [{"role": _ROLE.get(m.author_type, "user"), "content": m.text or m.transcript} for m in prior if m.text or m.transcript]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TranscriptionJob:
|
||||
"""Всё, что нужно провайдеру, — уже прочитанное из базы и хранилища.
|
||||
|
||||
Разложено на три шага (``prepare`` → ``run`` → ``store``), чтобы вызывающий
|
||||
мог держать транзакцию только вокруг первого и третьего: обращение к
|
||||
провайдеру ждёт ответа десятки секунд, и всё это время транзакция занимала
|
||||
бы соединение из пула (chatballs.tenancy.middleware).
|
||||
"""
|
||||
|
||||
provider: object
|
||||
model: str
|
||||
audio: bytes
|
||||
filename: str
|
||||
content_type: str
|
||||
|
||||
|
||||
def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
|
||||
"""Шаг в транзакции: провайдер организации, модель и байты аудио."""
|
||||
from chatballs.ai.provider.factory import get_provider
|
||||
from chatballs.ai.provider.routing import (
|
||||
DEFAULT_TRANSCRIPTION_MODEL,
|
||||
resolve_transcription_model,
|
||||
)
|
||||
|
||||
if not message.audio:
|
||||
return None
|
||||
provider = get_provider(channel=channel)
|
||||
try:
|
||||
model = resolve_transcription_model(channel)
|
||||
except ProviderError:
|
||||
model = DEFAULT_TRANSCRIPTION_MODEL # тестовый провайдер без интеграции
|
||||
with message.audio.open("rb") as handle:
|
||||
audio = handle.read()
|
||||
return TranscriptionJob(
|
||||
provider=provider,
|
||||
model=model,
|
||||
audio=audio,
|
||||
filename=message.audio.name.rsplit("/", 1)[-1],
|
||||
content_type=message.audio_content_type or "audio/ogg",
|
||||
)
|
||||
|
||||
|
||||
def run_transcription(job: TranscriptionJob) -> str:
|
||||
"""Шаг без транзакции: обращение к провайдеру."""
|
||||
return job.provider.transcribe(
|
||||
audio=job.audio,
|
||||
filename=job.filename,
|
||||
content_type=job.content_type,
|
||||
model=job.model,
|
||||
).strip()
|
||||
|
||||
|
||||
def store_transcription(message: Message, transcript: str) -> None:
|
||||
"""Шаг в транзакции: сохранить стенограмму и статус."""
|
||||
message.transcript = transcript
|
||||
message.transcript_status = TranscriptStatus.READY if transcript else TranscriptStatus.FAILED
|
||||
message.save(update_fields=["transcript", "transcript_status"])
|
||||
|
||||
|
||||
def mark_transcription_failed(message: Message) -> None:
|
||||
"""Статус FAILED — оператор повторит кнопкой."""
|
||||
message.transcript_status = TranscriptStatus.FAILED
|
||||
message.save(update_fields=["transcript_status"])
|
||||
|
||||
|
||||
def transcribe_voice_message(channel, message: Message, *, raise_errors: bool = False) -> str:
|
||||
"""Стенограмма голосового через BYOK-провайдера организации; пустая строка,
|
||||
если провайдер не умеет или недоступен (статус FAILED — оператор повторит кнопкой).
|
||||
|
||||
Три шага подряд, в транзакции вызывающего: так входящее сообщение
|
||||
обрабатывается целиком (ingest_inbound). Оператору, нажавшему «расшифровать»,
|
||||
ждать под транзакцией незачем — там шаги разнесены (voice_views).
|
||||
"""
|
||||
try:
|
||||
job = prepare_transcription(channel, message)
|
||||
if job is None:
|
||||
return ""
|
||||
transcript = run_transcription(job)
|
||||
except ProviderError as error:
|
||||
logger.info("Voice transcription unavailable for message %s: %s", message.id, error)
|
||||
mark_transcription_failed(message)
|
||||
if raise_errors:
|
||||
raise
|
||||
return ""
|
||||
store_transcription(message, transcript)
|
||||
return transcript
|
||||
|
||||
|
||||
def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
channel = integration.channel
|
||||
if channel is None:
|
||||
@@ -371,13 +266,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
transports.send_contact_ack(integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=ack)
|
||||
return
|
||||
|
||||
# Голосовое: AI отвечает текстом по стенограмме (BYOK-провайдер). Если
|
||||
# расшифровка недоступна, а также для файлов без текста — диалог уходит
|
||||
# оператору, как при недоступном AI, но без имитации сбоя.
|
||||
ai_input = inbound.text
|
||||
if is_voice and conversation.control_mode == ControlMode.AI and ai_available:
|
||||
ai_input = transcribe_voice_message(channel, message)
|
||||
if (is_voice and not ai_input) or files_only:
|
||||
# Файлы без текста: отвечать не на что — диалог уходит оператору, как при
|
||||
# недоступном AI, но без имитации сбоя. Голосовое сюда не попадает: его
|
||||
# расшифровка — это обращение к провайдеру, и она идёт ходом AI.
|
||||
if files_only:
|
||||
if conversation.control_mode == ControlMode.AI:
|
||||
enter_queue(conversation)
|
||||
conversation.save(update_fields=QUEUE_FIELDS)
|
||||
@@ -391,8 +283,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
title=f"Нужен оператор · {contact.name or 'Гость'}",
|
||||
title_key="notifications.operator_needed",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
body="Голосовое без расшифровки" if is_voice else message_text[:120],
|
||||
body_key="notifications.voice_without_transcript" if is_voice else "",
|
||||
body=message_text[:120],
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
@@ -405,93 +296,15 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
if conversation.control_mode != ControlMode.AI:
|
||||
return
|
||||
|
||||
try:
|
||||
result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation))
|
||||
except ProviderError as error:
|
||||
# Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
|
||||
# оператору, уведомляем и отвечаем клиенту понятным fallback.
|
||||
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
|
||||
enter_queue(conversation)
|
||||
conversation.last_activity_at = timezone.now()
|
||||
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.AI_UNAVAILABLE,
|
||||
text="AI недоступен — диалог передан оператору",
|
||||
)
|
||||
fallback = "Извините, прямо сейчас не получается ответить. Я передал ваш вопрос специалисту — он скоро подключится."
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback)
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
title=f"Нужен оператор · {contact.name or 'Гость'}",
|
||||
title_key="notifications.operator_needed",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
body="AI временно недоступен, диалог ждёт ответа",
|
||||
body_key="notifications.ai_unavailable_waiting",
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
dedup_key=f"aifail:{conversation.id}",
|
||||
)
|
||||
notify_management(
|
||||
context=context,
|
||||
type=NotificationType.AI_STOPPED,
|
||||
title=f"Ошибка AI · {channel.name}",
|
||||
body="AI временно недоступен, диалог передан оператору",
|
||||
title_key="notifications.ai_error",
|
||||
body_key="notifications.ai_unavailable_handed_over",
|
||||
text_params={"channel": channel.name},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
dedup_key=f"aierror:{conversation.id}",
|
||||
)
|
||||
transports.send_reply(integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=fallback)
|
||||
return
|
||||
|
||||
reply = result.text
|
||||
handoff = HANDOFF_TOKEN in reply
|
||||
if handoff:
|
||||
reply = reply.replace(HANDOFF_TOKEN, "").strip()
|
||||
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=reply)
|
||||
conversation.last_activity_at = timezone.now()
|
||||
if handoff:
|
||||
enter_queue(conversation)
|
||||
else:
|
||||
conversation.expected_responder = ExpectedResponder.CUSTOMER
|
||||
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
|
||||
|
||||
if handoff:
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.AI_HANDED_OVER,
|
||||
text="AI передал диалог оператору",
|
||||
)
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
title=f"AI передал диалог · {contact.name or 'Гость'}",
|
||||
title_key="notifications.ai_handed_over",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
body=ai_input[:120],
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
dedup_key=f"handoff:{conversation.id}",
|
||||
)
|
||||
|
||||
if reply:
|
||||
transports.send_reply(
|
||||
integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=reply
|
||||
)
|
||||
# Ход AI — отдельная работа: обращение к модели ждёт ответа секунды и
|
||||
# десятки секунд, а приём входящих столько ждать не может. Здесь только
|
||||
# заявка; считает ход роль событий (chatballs.conversations.ai_turn).
|
||||
request_ai_turn(
|
||||
message=message,
|
||||
user_id=inbound.user_id,
|
||||
context=context,
|
||||
is_new_conversation=is_new,
|
||||
)
|
||||
|
||||
|
||||
def _store_attachment(integration, inbound_file, message: Message) -> None:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-20 02:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0025_contact_avatar_contact_avatar_content_type_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='ai_turn_state',
|
||||
field=models.CharField(choices=[('NONE', 'Ход не нужен'), ('PENDING', 'Ожидает'), ('RUNNING', 'Считается'), ('DONE', 'Отвечено'), ('FAILED', 'Не удалось')], default='NONE', max_length=8),
|
||||
),
|
||||
]
|
||||
@@ -324,6 +324,22 @@ class MessageKind(models.TextChoices):
|
||||
FILE = "file", "Файл"
|
||||
|
||||
|
||||
class AiTurnState(models.TextChoices):
|
||||
"""Состояние хода AI по входящему сообщению.
|
||||
|
||||
Ответ считается не в приёме, а отдельной ролью воркера
|
||||
(chatballs.conversations.ai_turn), поэтому у входящего появилось состояние.
|
||||
По нему видно, что ответ ещё считается — виджет показывает «печатает», — и
|
||||
по нему же повторная доставка события не приводит ко второму ответу.
|
||||
"""
|
||||
|
||||
NONE = "NONE", "Ход не нужен"
|
||||
PENDING = "PENDING", "Ожидает"
|
||||
RUNNING = "RUNNING", "Считается"
|
||||
DONE = "DONE", "Отвечено"
|
||||
FAILED = "FAILED", "Не удалось"
|
||||
|
||||
|
||||
class TranscriptStatus(models.TextChoices):
|
||||
# Расшифровка голосового (дизайн-базлайн v2, кадр H): по кнопке, через
|
||||
# BYOK-провайдера организации (решение владельца 2026-09-04).
|
||||
@@ -375,6 +391,10 @@ class Message(TenantRelationModel):
|
||||
transcript_status = models.CharField(
|
||||
max_length=8, choices=TranscriptStatus.choices, default=TranscriptStatus.NONE
|
||||
)
|
||||
# Ход AI по этому сообщению: ожидает, считается, отвечено, не удалось.
|
||||
ai_turn_state = models.CharField(
|
||||
max_length=8, choices=AiTurnState.choices, default=AiTurnState.NONE
|
||||
)
|
||||
# Файл/фото (kind=FILE): вложение с исходным именем, типом и размером.
|
||||
attachment = models.FileField(upload_to=message_attachment_upload_path, max_length=512, blank=True)
|
||||
attachment_name = models.CharField(max_length=255, blank=True)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Ход AI как отдельная работа: приём не ждёт модель, ответ считается событием."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.ai_turn import AI_TURN_REQUESTED
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
from chatballs.conversations.models import (
|
||||
AiTurnState,
|
||||
ControlMode,
|
||||
ExpectedResponder,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
)
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.events.handlers import dispatch
|
||||
from chatballs.events.models import OutboxEvent
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.testing import ai_answer, run_pending_ai_turns
|
||||
|
||||
|
||||
class AiTurnQueueTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization, code="line", name="Линия"
|
||||
)
|
||||
AIAgent.objects.create(
|
||||
channel=self.channel,
|
||||
name="Агент",
|
||||
model="openai/gpt-4o-mini",
|
||||
status=AIAgentStatus.ACTIVE,
|
||||
)
|
||||
self.integration = Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
name="Bot",
|
||||
secret="token",
|
||||
channel=self.channel,
|
||||
)
|
||||
self.inbound = InboundMessage(
|
||||
external_id="ext-1",
|
||||
user_id="u-1",
|
||||
chat_id="c-1",
|
||||
text="Здравствуйте",
|
||||
display_name="Гость",
|
||||
)
|
||||
|
||||
def _inbound_message(self) -> Message:
|
||||
return Message.objects.get(author_type=MessageAuthor.CONTACT)
|
||||
|
||||
def _ai_messages(self):
|
||||
return Message.objects.filter(author_type=MessageAuthor.AI)
|
||||
|
||||
def test_ingest_queues_the_turn_and_does_not_call_the_model(self) -> None:
|
||||
# Главное свойство всей развязки: приём не ждёт провайдера.
|
||||
with mock.patch("chatballs.ai.provider.local.LocalProvider.chat") as chat:
|
||||
ingest_inbound(self.integration, self.inbound)
|
||||
|
||||
chat.assert_not_called()
|
||||
message = self._inbound_message()
|
||||
self.assertEqual(message.ai_turn_state, AiTurnState.PENDING)
|
||||
self.assertFalse(self._ai_messages().exists())
|
||||
self.assertTrue(
|
||||
OutboxEvent.objects.filter(
|
||||
event_type=AI_TURN_REQUESTED, aggregate_id=str(message.conversation_id)
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_turn_answers_and_closes_the_message(self) -> None:
|
||||
with (
|
||||
ai_answer("Здравствуйте!"),
|
||||
mock.patch(
|
||||
"chatballs.conversations.transports.send_reply", return_value=True
|
||||
) as send,
|
||||
):
|
||||
ingest_inbound(self.integration, self.inbound)
|
||||
self.assertEqual(run_pending_ai_turns(), 1)
|
||||
|
||||
self.assertEqual(self._ai_messages().get().text, "Здравствуйте!")
|
||||
self.assertEqual(self._inbound_message().ai_turn_state, AiTurnState.DONE)
|
||||
conversation = self.channel.conversations.get()
|
||||
self.assertEqual(conversation.control_mode, ControlMode.AI)
|
||||
self.assertEqual(conversation.expected_responder, ExpectedResponder.CUSTOMER)
|
||||
send.assert_called_once()
|
||||
|
||||
def test_repeated_delivery_does_not_answer_twice(self) -> None:
|
||||
# Событие могут привезти второй раз: процесс упал между ответом и
|
||||
# отметкой о нём. Второй ответ клиенту — это хуже, чем ни одного.
|
||||
with (
|
||||
ai_answer("Здравствуйте!"),
|
||||
mock.patch("chatballs.conversations.transports.send_reply", return_value=True),
|
||||
):
|
||||
ingest_inbound(self.integration, self.inbound)
|
||||
event = OutboxEvent.objects.get(event_type=AI_TURN_REQUESTED)
|
||||
dispatch(event)
|
||||
dispatch(event)
|
||||
|
||||
self.assertEqual(self._ai_messages().count(), 1)
|
||||
|
||||
def test_turn_for_a_dialog_taken_by_an_operator_is_dropped(self) -> None:
|
||||
with (
|
||||
ai_answer("Здравствуйте!"),
|
||||
mock.patch("chatballs.conversations.transports.send_reply", return_value=True),
|
||||
):
|
||||
ingest_inbound(self.integration, self.inbound)
|
||||
conversation = self.channel.conversations.get()
|
||||
conversation.control_mode = ControlMode.HUMAN
|
||||
conversation.save(update_fields=["control_mode"])
|
||||
run_pending_ai_turns()
|
||||
|
||||
self.assertFalse(self._ai_messages().exists())
|
||||
self.assertEqual(self._inbound_message().ai_turn_state, AiTurnState.DONE)
|
||||
|
||||
@override_settings(CHATBALLS_AI_TURN_DEADLINE_SECONDS=0)
|
||||
def test_expired_turn_goes_to_the_operator_instead_of_the_model(self) -> None:
|
||||
# Ответ, пролежавший в очереди, клиенту уже не нужен — нужен человек.
|
||||
with (
|
||||
mock.patch("chatballs.ai.provider.local.LocalProvider.chat") as chat,
|
||||
mock.patch(
|
||||
"chatballs.conversations.transports.send_reply", return_value=True
|
||||
) as send,
|
||||
):
|
||||
ingest_inbound(self.integration, self.inbound)
|
||||
run_pending_ai_turns()
|
||||
|
||||
chat.assert_not_called()
|
||||
conversation = self.channel.conversations.get()
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
|
||||
self.assertEqual(conversation.expected_responder, ExpectedResponder.OPERATOR)
|
||||
self.assertEqual(self._inbound_message().ai_turn_state, AiTurnState.FAILED)
|
||||
self.assertTrue(self._ai_messages().filter(text__contains="специалисту").exists())
|
||||
# Клиент получает этот текст в своём канале, а не только в базе.
|
||||
send.assert_called_once()
|
||||
@@ -20,6 +20,8 @@ from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
|
||||
from chatballs.testing import ai_answer, run_pending_ai_turns
|
||||
|
||||
EMAIL_CONFIG = {
|
||||
|
||||
"email": "support@example.com",
|
||||
@@ -505,14 +507,16 @@ class EmailIngestThreadMetaTests(TestCase):
|
||||
|
||||
with (
|
||||
|
||||
mock.patch("chatballs.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Ответ")),
|
||||
ai_answer("Ответ"),
|
||||
|
||||
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True),
|
||||
mock.patch("chatballs.conversations.transports.send_reply", return_value=True),
|
||||
|
||||
):
|
||||
|
||||
ingest_inbound(self.integration, inbound)
|
||||
|
||||
run_pending_ai_turns()
|
||||
|
||||
|
||||
|
||||
def test_subject_pinned_to_first_message_id_follows_last(self) -> None:
|
||||
|
||||
@@ -22,7 +22,7 @@ from chatballs.integrations.models import (
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import TenantAPIClient as APIClient, run_pending_ai_turns
|
||||
|
||||
|
||||
def _connection(channel: Channel) -> Integration:
|
||||
@@ -71,33 +71,21 @@ class OperatorOnlyIngestTests(TestCase):
|
||||
|
||||
|
||||
|
||||
def _ingest_without_ai(self, inbound: InboundMessage) -> tuple[mock.Mock, mock.Mock]:
|
||||
def _ingest_without_ai(self, inbound: InboundMessage) -> tuple[int, mock.Mock]:
|
||||
|
||||
with (
|
||||
|
||||
mock.patch(
|
||||
|
||||
"chatballs.conversations.ingest.run_channel_turn"
|
||||
|
||||
) as ai_turn,
|
||||
|
||||
mock.patch(
|
||||
|
||||
"chatballs.conversations.ingest.transports.send_reply"
|
||||
|
||||
) as send,
|
||||
|
||||
):
|
||||
with mock.patch("chatballs.conversations.transports.send_reply") as send:
|
||||
|
||||
ingest_inbound(self.integration, inbound)
|
||||
|
||||
return ai_turn, send
|
||||
turns = run_pending_ai_turns()
|
||||
|
||||
return turns, send
|
||||
|
||||
|
||||
|
||||
def test_new_dialog_starts_in_queue_without_ai_fallback(self) -> None:
|
||||
|
||||
ai_turn, send = self._ingest_without_ai(
|
||||
turns, send = self._ingest_without_ai(
|
||||
|
||||
InboundMessage(
|
||||
|
||||
@@ -135,7 +123,7 @@ class OperatorOnlyIngestTests(TestCase):
|
||||
|
||||
)
|
||||
|
||||
ai_turn.assert_not_called()
|
||||
self.assertEqual(turns, 0)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
@@ -179,7 +167,7 @@ class OperatorOnlyIngestTests(TestCase):
|
||||
|
||||
|
||||
|
||||
ai_turn, send = self._ingest_without_ai(
|
||||
turns, send = self._ingest_without_ai(
|
||||
|
||||
InboundMessage(
|
||||
|
||||
@@ -211,7 +199,7 @@ class OperatorOnlyIngestTests(TestCase):
|
||||
|
||||
self.assertEqual(conversation.messages.count(), 1)
|
||||
|
||||
ai_turn.assert_not_called()
|
||||
self.assertEqual(turns, 0)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import HumanUser, Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.notifications.models import Notification, NotificationType
|
||||
from chatballs.testing import tenant_context_for
|
||||
from chatballs.testing import ai_answer, tenant_context_for
|
||||
|
||||
|
||||
class QueueTestBase(TestCase):
|
||||
@@ -55,10 +55,7 @@ class QueueTestBase(TestCase):
|
||||
text=text,
|
||||
display_name=chat_id,
|
||||
)
|
||||
with (
|
||||
mock.patch("chatballs.conversations.ingest.run_channel_turn"),
|
||||
mock.patch("chatballs.conversations.ingest.transports.send_reply"),
|
||||
):
|
||||
with mock.patch("chatballs.conversations.transports.send_reply"):
|
||||
ingest_inbound(self.integration, inbound)
|
||||
|
||||
def _waiting_order(self) -> list[int]:
|
||||
@@ -155,10 +152,9 @@ class NewDialogNotificationTests(QueueTestBase):
|
||||
is_active=True,
|
||||
)
|
||||
self.assertTrue(agent.is_active)
|
||||
with mock.patch(
|
||||
"chatballs.conversations.ingest.run_channel_turn",
|
||||
return_value=mock.Mock(text="Здравствуйте!"),
|
||||
), mock.patch("chatballs.conversations.ingest.transports.send_reply"):
|
||||
with ai_answer("Здравствуйте!"), mock.patch(
|
||||
"chatballs.conversations.transports.send_reply"
|
||||
):
|
||||
ingest_inbound(
|
||||
self.integration,
|
||||
InboundMessage(
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Транспорт ВКонтакте: разбор апдейтов, курсор Long Poll, отправка.
|
||||
|
||||
Тесты идут без базы: транспорту нужны только поля подключения, поэтому вместо
|
||||
записи в базу здесь простая заглушка. Сеть закрыта подменой вызова API и
|
||||
опроса сервера событий.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from chatballs.conversations.transports import vk, vk_media, vk_send
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.integrations.checks import VkRejected
|
||||
|
||||
SERVER = {"server": "https://lp.vk.com/wh1", "key": "lp-key", "ts": "100"}
|
||||
PROFILE = {"id": 77, "first_name": "Иван", "last_name": "Петров", "screen_name": "ivan", "photo_100": "https://vk.com/ivan.jpg"}
|
||||
|
||||
|
||||
def _integration(**config):
|
||||
return SimpleNamespace(
|
||||
id=1,
|
||||
secret="vk-community-token",
|
||||
poll_marker="",
|
||||
config={"bot_id": "42", **config},
|
||||
organization=SimpleNamespace(language="ru"),
|
||||
)
|
||||
|
||||
|
||||
def _update(**message):
|
||||
payload = {"from_id": 77, "peer_id": 77, "id": 500, "text": "Здравствуйте", **message}
|
||||
return {"type": "message_new", "object": {"message": payload}}
|
||||
|
||||
|
||||
class _Api:
|
||||
"""Подмена вызова API: ответ на метод и журнал обращений."""
|
||||
|
||||
def __init__(self, **responses):
|
||||
self.responses = {"users.get": [PROFILE], "groups.getLongPollServer": SERVER, **responses}
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
|
||||
def __call__(self, integration, method, params=None, *, post=False):
|
||||
self.calls.append((method, dict(params or {})))
|
||||
response = self.responses.get(method)
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
return response
|
||||
|
||||
def methods(self) -> list[str]:
|
||||
return [method for method, _params in self.calls]
|
||||
|
||||
def params(self, method: str) -> dict:
|
||||
return next(params for name, params in self.calls if name == method)
|
||||
|
||||
|
||||
class VkInboundTests(SimpleTestCase):
|
||||
def setUp(self) -> None:
|
||||
vk.reset()
|
||||
self.addCleanup(vk.reset)
|
||||
|
||||
def _poll(self, check_result, *, api=None, integration=None):
|
||||
api = api or _Api()
|
||||
integration = integration or _integration()
|
||||
with mock.patch.object(vk.vk_api, "call", api):
|
||||
with mock.patch.object(vk, "request_json", return_value=check_result) as request:
|
||||
messages, marker = vk.poll_updates(integration)
|
||||
return messages, marker, api, request
|
||||
|
||||
def test_message_becomes_inbound_with_profile_from_one_request(self) -> None:
|
||||
api = _Api()
|
||||
messages, marker, api, _request = self._poll(
|
||||
{"ts": "101", "updates": [_update(), _update(id=501, text="Ещё вопрос")]},
|
||||
api=api,
|
||||
)
|
||||
self.assertEqual(marker, "101")
|
||||
self.assertEqual([message.text for message in messages], ["Здравствуйте", "Ещё вопрос"])
|
||||
self.assertEqual(messages[0].external_id, "500")
|
||||
self.assertEqual(messages[0].user_id, "77")
|
||||
self.assertEqual(messages[0].chat_id, "77")
|
||||
self.assertEqual(messages[0].display_name, "Иван Петров")
|
||||
self.assertEqual(messages[0].username, "ivan")
|
||||
self.assertEqual(messages[0].avatar_url, "https://vk.com/ivan.jpg")
|
||||
# Профиль спрашивается один раз на пачку, а не на каждое сообщение.
|
||||
self.assertEqual(api.methods().count("users.get"), 1)
|
||||
self.assertEqual(api.params("users.get")["user_ids"], "77")
|
||||
|
||||
def test_community_own_message_and_other_events_are_ignored(self) -> None:
|
||||
messages, _marker, _api, _request = self._poll(
|
||||
{
|
||||
"ts": "102",
|
||||
"updates": [
|
||||
_update(from_id=-42),
|
||||
{"type": "group_join", "object": {"user_id": 77}},
|
||||
],
|
||||
}
|
||||
)
|
||||
self.assertEqual(messages, [])
|
||||
|
||||
def test_profile_failure_does_not_lose_the_message(self) -> None:
|
||||
api = _Api(**{"users.get": VkRejected("VK отклонил запрос")})
|
||||
messages, _marker, _api, _request = self._poll({"ts": "103", "updates": [_update()]}, api=api)
|
||||
self.assertEqual([message.text for message in messages], ["Здравствуйте"])
|
||||
self.assertEqual(messages[0].display_name, "")
|
||||
|
||||
def test_first_cycle_takes_position_from_vk_and_next_one_from_the_marker(self) -> None:
|
||||
api = _Api()
|
||||
integration = _integration()
|
||||
with mock.patch.object(vk.vk_api, "call", api):
|
||||
with mock.patch.object(vk, "request_json", return_value={"ts": "101", "updates": []}) as request:
|
||||
vk.poll_updates(integration)
|
||||
integration.poll_marker = "101"
|
||||
vk.poll_updates(integration)
|
||||
self.assertIn("ts=100", request.call_args_list[0].args[0])
|
||||
self.assertIn("ts=101", request.call_args_list[1].args[0])
|
||||
# Адрес Long Poll выдаётся один раз и живёт в памяти процесса.
|
||||
self.assertEqual(api.methods().count("groups.getLongPollServer"), 1)
|
||||
|
||||
def test_zero_position_of_a_fresh_community_is_kept(self) -> None:
|
||||
# Сообществу, которому ещё не писали, ВКонтакте отдаёт ts=0. Пустая
|
||||
# позиция в опросе возвращает ts=-1 — курсор, с которого поток уже не
|
||||
# читается, и первое же сообщение клиента прошло бы мимо.
|
||||
api = _Api()
|
||||
api.responses["groups.getLongPollServer"] = {**SERVER, "ts": 0}
|
||||
with mock.patch.object(vk.vk_api, "call", api):
|
||||
with mock.patch.object(vk, "request_json", return_value={"ts": "0", "updates": []}) as request:
|
||||
_messages, marker = vk.poll_updates(_integration())
|
||||
self.assertIn("ts=0", request.call_args_list[0].args[0])
|
||||
self.assertEqual(marker, "0")
|
||||
|
||||
def test_outdated_position_is_retried_with_the_one_vk_returned(self) -> None:
|
||||
api = _Api()
|
||||
integration = _integration()
|
||||
integration.poll_marker = "90"
|
||||
answers = [{"failed": 1, "ts": "100"}, {"ts": "101", "updates": [_update()]}]
|
||||
with mock.patch.object(vk.vk_api, "call", api):
|
||||
with mock.patch.object(vk, "request_json", side_effect=answers) as request:
|
||||
messages, marker = vk.poll_updates(integration)
|
||||
self.assertEqual(marker, "101")
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertIn("ts=100", request.call_args_list[1].args[0])
|
||||
|
||||
def test_expired_key_takes_a_new_one_and_keeps_the_position(self) -> None:
|
||||
api = _Api()
|
||||
api.responses["groups.getLongPollServer"] = {**SERVER, "key": "fresh-key", "ts": "900"}
|
||||
integration = _integration()
|
||||
integration.poll_marker = "90"
|
||||
answers = [{"failed": 2}, {"ts": "91", "updates": []}]
|
||||
with mock.patch.object(vk.vk_api, "call", api):
|
||||
with mock.patch.object(vk, "request_json", side_effect=answers) as request:
|
||||
_messages, marker = vk.poll_updates(integration)
|
||||
self.assertEqual(marker, "91")
|
||||
retry_url = request.call_args_list[1].args[0]
|
||||
self.assertIn("key=fresh-key", retry_url)
|
||||
self.assertIn("ts=90", retry_url)
|
||||
|
||||
def test_lost_history_takes_both_the_key_and_the_position(self) -> None:
|
||||
api = _Api()
|
||||
api.responses["groups.getLongPollServer"] = {**SERVER, "key": "fresh-key", "ts": "900"}
|
||||
integration = _integration()
|
||||
integration.poll_marker = "90"
|
||||
answers = [{"failed": 3}, {"ts": "901", "updates": []}]
|
||||
with mock.patch.object(vk.vk_api, "call", api):
|
||||
with mock.patch.object(vk, "request_json", side_effect=answers) as request:
|
||||
_messages, marker = vk.poll_updates(integration)
|
||||
self.assertEqual(marker, "901")
|
||||
self.assertIn("ts=900", request.call_args_list[1].args[0])
|
||||
|
||||
def test_broken_connection_is_a_poll_failure_and_forgets_the_session(self) -> None:
|
||||
api = _Api()
|
||||
integration = _integration()
|
||||
with mock.patch.object(vk.vk_api, "call", api):
|
||||
with mock.patch.object(vk, "request_json", side_effect=TimeoutError("timed out")):
|
||||
with self.assertRaises(PollFailed):
|
||||
vk.poll_updates(integration)
|
||||
self.assertEqual(vk._sessions, {})
|
||||
|
||||
def test_token_never_leaks_into_the_failure_text(self) -> None:
|
||||
api = _Api()
|
||||
leak = OSError("HTTP Error 401: https://api.vk.com/method/users.get?access_token=vk1.a.SECRET&v=5.199")
|
||||
with mock.patch.object(vk.vk_api, "call", api):
|
||||
with mock.patch.object(vk, "request_json", side_effect=leak):
|
||||
with self.assertRaises(PollFailed) as failure:
|
||||
vk.poll_updates(_integration())
|
||||
self.assertNotIn("vk1.a.SECRET", str(failure.exception))
|
||||
self.assertIn("access_token=***", str(failure.exception))
|
||||
|
||||
def test_group_is_asked_once_when_the_connection_was_never_checked(self) -> None:
|
||||
api = _Api(**{"groups.getById": {"groups": [{"id": 42, "name": "Acme"}]}})
|
||||
integration = _integration()
|
||||
integration.config.pop("bot_id")
|
||||
with mock.patch.object(vk.vk_api, "call", api):
|
||||
with mock.patch.object(vk, "request_json", return_value={"ts": "101", "updates": []}):
|
||||
vk.poll_updates(integration)
|
||||
self.assertEqual(api.params("groups.getLongPollServer")["group_id"], "42")
|
||||
|
||||
|
||||
class VkAttachmentTests(SimpleTestCase):
|
||||
def setUp(self) -> None:
|
||||
vk.reset()
|
||||
self.addCleanup(vk.reset)
|
||||
|
||||
def test_photo_takes_the_largest_size(self) -> None:
|
||||
attachment = {
|
||||
"type": "photo",
|
||||
"photo": {"sizes": [
|
||||
{"url": "https://vk.com/small.jpg", "width": 75, "height": 75},
|
||||
{"url": "https://vk.com/large.jpg", "width": 1280, "height": 960},
|
||||
]},
|
||||
}
|
||||
files = vk_media.file_attachments({"attachments": [attachment]})
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertEqual(files[0].url, "https://vk.com/large.jpg")
|
||||
self.assertTrue(files[0].is_image)
|
||||
|
||||
def test_document_keeps_its_name_and_size(self) -> None:
|
||||
attachment = {"type": "doc", "doc": {"url": "https://vk.com/f.pdf", "title": "Счёт.pdf", "size": 2048}}
|
||||
files = vk_media.file_attachments({"attachments": [attachment]})
|
||||
self.assertEqual(files[0].name, "Счёт.pdf")
|
||||
self.assertEqual(files[0].content_type, "application/pdf")
|
||||
self.assertEqual(files[0].size, 2048)
|
||||
self.assertFalse(files[0].is_image)
|
||||
|
||||
def test_voice_message_is_taken_in_ogg(self) -> None:
|
||||
attachment = {
|
||||
"type": "audio_message",
|
||||
"audio_message": {"duration": 7, "link_ogg": "https://vk.com/v.ogg", "link_mp3": "https://vk.com/v.mp3"},
|
||||
}
|
||||
url, duration, mime, unavailable = vk_media.voice_attachment({"attachments": [attachment]})
|
||||
self.assertEqual((url, duration, mime, unavailable), ("https://vk.com/v.ogg", 7, "audio/ogg", False))
|
||||
|
||||
def test_voice_without_a_link_still_reaches_the_operator(self) -> None:
|
||||
_url, _duration, _mime, unavailable = vk_media.voice_attachment(
|
||||
{"attachments": [{"type": "audio_message", "audio_message": {"duration": 3}}]}
|
||||
)
|
||||
self.assertTrue(unavailable)
|
||||
|
||||
def test_attachment_the_channel_cannot_show_does_not_swallow_the_message(self) -> None:
|
||||
update = _update(text="", attachments=[{"type": "video", "video": {"id": 1}}])
|
||||
with mock.patch.object(vk, "customer_language", return_value="ru"):
|
||||
message = vk._normalize(_integration(), update)
|
||||
self.assertIsNotNone(message)
|
||||
self.assertTrue(message.text)
|
||||
|
||||
|
||||
class VkOutboundTests(SimpleTestCase):
|
||||
def test_text_goes_out_by_post_with_a_deduplication_id(self) -> None:
|
||||
api = _Api(**{"messages.send": {"response": 1}})
|
||||
posts = []
|
||||
|
||||
def record(integration, method, params=None, *, post=False):
|
||||
posts.append(post)
|
||||
return api(integration, method, params, post=post)
|
||||
|
||||
with mock.patch.object(vk_send.vk_api, "call", record):
|
||||
self.assertTrue(vk_send.send_text(_integration(), chat_id="77", user_id="77", text="Ответ"))
|
||||
params = api.params("messages.send")
|
||||
self.assertEqual(params["peer_id"], "77")
|
||||
self.assertEqual(params["message"], "Ответ")
|
||||
self.assertTrue(params["random_id"])
|
||||
self.assertEqual(posts, [True])
|
||||
|
||||
def test_call_invite_carries_a_link_button(self) -> None:
|
||||
api = _Api(**{"messages.send": {"response": 1}})
|
||||
with mock.patch.object(vk_send.vk_api, "call", api):
|
||||
with mock.patch.object(vk_send, "customer_language", return_value="ru"):
|
||||
sent = vk_send.send_call_invite(
|
||||
_integration(), chat_id="77", user_id="77", text="Звонок", url="https://hub.test/calls/abc"
|
||||
)
|
||||
self.assertTrue(sent)
|
||||
self.assertIn("https://hub.test/calls/abc", api.params("messages.send")["keyboard"])
|
||||
|
||||
def test_rejected_send_is_reported_as_failure(self) -> None:
|
||||
api = _Api(**{"messages.send": VkRejected("ВКонтакте отклонил запрос (7): access denied")})
|
||||
with mock.patch.object(vk_send.vk_api, "call", api):
|
||||
self.assertFalse(vk_send.send_text(_integration(), chat_id="77", user_id="", text="Ответ"))
|
||||
|
||||
def test_photo_is_uploaded_and_attached(self) -> None:
|
||||
api = _Api(
|
||||
**{
|
||||
"photos.getMessagesUploadServer": {"upload_url": "https://upload.vk.com/1"},
|
||||
"photos.saveMessagesPhoto": [{"owner_id": 5, "id": 9}],
|
||||
"messages.send": {"response": 1},
|
||||
}
|
||||
)
|
||||
with mock.patch.object(vk_send.vk_api, "call", api):
|
||||
with mock.patch.object(
|
||||
vk_media, "request_json_multipart", return_value={"server": "1", "photo": "[]", "hash": "h"}
|
||||
):
|
||||
sent = vk_send.send_file(
|
||||
_integration(),
|
||||
chat_id="77",
|
||||
user_id="77",
|
||||
content=b"binary",
|
||||
filename="photo.jpg",
|
||||
content_type="image/jpeg",
|
||||
caption="Схема",
|
||||
)
|
||||
self.assertTrue(sent)
|
||||
self.assertEqual(api.params("messages.send")["attachment"], "photo5_9")
|
||||
self.assertEqual(api.params("messages.send")["message"], "Схема")
|
||||
@@ -16,7 +16,8 @@ from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.conversations import ai_turn
|
||||
from chatballs.testing import TenantAPIClient as APIClient, ai_answer, run_pending_ai_turns
|
||||
|
||||
|
||||
class VoiceAiReplyTests(TestCase):
|
||||
@@ -30,48 +31,72 @@ class VoiceAiReplyTests(TestCase):
|
||||
)
|
||||
self.inbound = InboundMessage(external_id="v-1", user_id="u-1", chat_id="c-1", text="", display_name="Ольга", voice_file_id="f-1", voice_duration=5, voice_mime="audio/ogg")
|
||||
|
||||
def _ingest(self, transcribe, turn):
|
||||
def _ingest(self, transcribe, answer="Ответ"):
|
||||
"""Приём голосового и ход AI по нему.
|
||||
|
||||
Расшифровка — обращение к провайдеру, поэтому она идёт не в приёме, а
|
||||
в ходе (chatballs.conversations.ai_turn); тест повторяет этот порядок.
|
||||
"""
|
||||
with (
|
||||
mock.patch("chatballs.conversations.ingest.transports.download_voice", return_value=(b"OGG", "audio/ogg")),
|
||||
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
|
||||
mock.patch("chatballs.conversations.transports.send_reply", return_value=True) as send,
|
||||
mock.patch("chatballs.ai.provider.local.LocalProvider.transcribe", **transcribe),
|
||||
mock.patch("chatballs.conversations.ingest.run_channel_turn", **turn) as run,
|
||||
mock.patch("chatballs.conversations.ai_turn.plan_chat", wraps=ai_turn.plan_chat) as plan,
|
||||
ai_answer(answer),
|
||||
tenant_atomic(self.organization.id),
|
||||
):
|
||||
ingest_inbound(self.integration, self.inbound)
|
||||
return send, run
|
||||
run_pending_ai_turns()
|
||||
return send, plan
|
||||
|
||||
def test_ai_answers_voice_by_transcript(self) -> None:
|
||||
send, run = self._ingest({"return_value": "Можно оформить возврат?"}, {"return_value": mock.Mock(text="Да, возврат возможен в течение 14 дней.")})
|
||||
send, plan = self._ingest(
|
||||
{"return_value": "Можно оформить возврат?"},
|
||||
answer="Да, возврат возможен в течение 14 дней.",
|
||||
)
|
||||
conversation = self.channel.conversations.get()
|
||||
voice = conversation.messages.get(kind=MessageKind.VOICE)
|
||||
self.assertEqual(voice.transcript, "Можно оформить возврат?")
|
||||
self.assertEqual(voice.transcript_status, TranscriptStatus.READY)
|
||||
run.assert_called_once()
|
||||
self.assertEqual(run.call_args.kwargs["message"], "Можно оформить возврат?")
|
||||
plan.assert_called_once()
|
||||
self.assertEqual(plan.call_args.kwargs["message"], "Можно оформить возврат?")
|
||||
reply = conversation.messages.get(author_type=MessageAuthor.AI)
|
||||
self.assertIn("возврат", reply.text)
|
||||
send.assert_called_once()
|
||||
self.assertEqual(conversation.control_mode, ControlMode.AI)
|
||||
|
||||
def test_without_transcription_dialog_goes_to_operator(self) -> None:
|
||||
send, run = self._ingest({"side_effect": ProviderError("нет STT")}, {"return_value": mock.Mock(text="x")})
|
||||
send, plan = self._ingest({"side_effect": ProviderError("нет STT")})
|
||||
conversation = self.channel.conversations.get()
|
||||
voice = conversation.messages.get(kind=MessageKind.VOICE)
|
||||
self.assertEqual(voice.transcript_status, TranscriptStatus.FAILED)
|
||||
run.assert_not_called()
|
||||
plan.assert_not_called()
|
||||
send.assert_not_called()
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
|
||||
|
||||
def test_transcript_is_in_ai_history(self) -> None:
|
||||
from chatballs.conversations.ingest import _history
|
||||
from chatballs.conversations.ai_turn import _history
|
||||
|
||||
self._ingest({"return_value": "Первый вопрос"}, {"return_value": mock.Mock(text="Ответ")})
|
||||
self._ingest({"return_value": "Первый вопрос"})
|
||||
conversation = self.channel.conversations.get()
|
||||
conversation.messages.create(author_type=MessageAuthor.CONTACT, text="Второй")
|
||||
roles = [(h["role"], h["content"]) for h in _history(conversation)]
|
||||
roles = [(h["role"], h["content"]) for h in _history(conversation, 20)]
|
||||
self.assertEqual(roles, [("user", "Первый вопрос"), ("assistant", "Ответ")])
|
||||
|
||||
def test_ai_history_window_follows_agent_setting(self) -> None:
|
||||
self.channel.ai_agent.history_limit = 3
|
||||
self.channel.ai_agent.save(update_fields=["history_limit"])
|
||||
self._ingest({"return_value": "Первый вопрос"})
|
||||
conversation = self.channel.conversations.get()
|
||||
for number in range(1, 6):
|
||||
conversation.messages.create(author_type=MessageAuthor.CONTACT, text=f"Сообщение {number}")
|
||||
self.inbound = InboundMessage(external_id="t-2", user_id="u-1", chat_id="c-1", text="Последнее", display_name="Ольга")
|
||||
_, plan = self._ingest({"return_value": ""})
|
||||
history = [item["content"] for item in plan.call_args.kwargs["history"]]
|
||||
# Три сообщения перед новым; само новое уходит модели отдельно.
|
||||
self.assertEqual(history, ["Сообщение 3", "Сообщение 4", "Сообщение 5"])
|
||||
self.assertEqual(plan.call_args.kwargs["message"], "Последнее")
|
||||
|
||||
|
||||
class CommunicationSettingsTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
|
||||
@@ -4,7 +4,6 @@ from unittest import mock
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
@@ -28,6 +27,7 @@ from chatballs.identity.models import (
|
||||
)
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.notifications.models import Notification, NotificationAudience, NotificationType
|
||||
from chatballs.testing import ai_answer, ai_failure, run_pending_ai_turns
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
@@ -65,13 +65,11 @@ class IngestProviderFailureTests(TestCase):
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"chatballs.conversations.ingest.run_channel_turn",
|
||||
side_effect=ProviderError("provider is down"),
|
||||
),
|
||||
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
|
||||
ai_failure("provider is down"),
|
||||
mock.patch("chatballs.conversations.transports.send_reply", return_value=True) as send,
|
||||
):
|
||||
ingest_inbound(self.integration, self.inbound)
|
||||
run_pending_ai_turns()
|
||||
|
||||
conversation = self.channel.conversations.get()
|
||||
# Диалог передан оператору, ответчик — оператор.
|
||||
@@ -309,10 +307,11 @@ class ContactShareIngestTests(TestCase):
|
||||
external_id="ext-1", user_id="u1", chat_id="c1", text="Привет", display_name="Иван", username="ivan"
|
||||
)
|
||||
with (
|
||||
mock.patch("chatballs.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Здравствуйте!")),
|
||||
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True),
|
||||
ai_answer("Здравствуйте!"),
|
||||
mock.patch("chatballs.conversations.transports.send_reply", return_value=True),
|
||||
):
|
||||
ingest_inbound(self.integration, inbound)
|
||||
run_pending_ai_turns()
|
||||
|
||||
identity = ConnectionIdentity.objects.get(connection=self.integration, external_user_id="u1")
|
||||
self.assertEqual(identity.username, "ivan")
|
||||
@@ -323,13 +322,13 @@ class ContactShareIngestTests(TestCase):
|
||||
inbound = InboundMessage(
|
||||
external_id="ext-2", user_id="u1", chat_id="c1", text="", display_name="Иван", username="ivan", phone="+79991234567"
|
||||
)
|
||||
with (
|
||||
mock.patch("chatballs.conversations.ingest.run_channel_turn") as ai_turn,
|
||||
mock.patch("chatballs.conversations.ingest.transports.send_contact_ack", return_value=True) as ack,
|
||||
):
|
||||
with mock.patch(
|
||||
"chatballs.conversations.ingest.transports.send_contact_ack", return_value=True
|
||||
) as ack:
|
||||
ingest_inbound(self.integration, inbound)
|
||||
|
||||
ai_turn.assert_not_called()
|
||||
# Ход AI даже не заявлен: отвечать на присланный контакт нечего.
|
||||
self.assertEqual(run_pending_ai_turns(), 0)
|
||||
ack.assert_called_once()
|
||||
contact = ConnectionIdentity.objects.get(connection=self.integration, external_user_id="u1").contact
|
||||
self.assertEqual(contact.phone, "+79991234567")
|
||||
@@ -525,6 +524,7 @@ class WebchatContactTests(TestCase):
|
||||
)
|
||||
|
||||
response = self._post_message("Здравствуйте")
|
||||
run_pending_ai_turns()
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
conversation = Conversation.objects.get(channel=self.channel)
|
||||
@@ -555,11 +555,9 @@ class WebchatContactTests(TestCase):
|
||||
)
|
||||
|
||||
def test_provider_error_hands_off_without_500(self) -> None:
|
||||
with mock.patch(
|
||||
"chatballs.conversations.ingest.run_channel_turn",
|
||||
side_effect=ProviderError("AI недоступен"),
|
||||
):
|
||||
with ai_failure("AI недоступен"):
|
||||
response = self._post_message("Здравствуйте")
|
||||
run_pending_ai_turns()
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
conversation = Conversation.objects.get(channel=self.channel)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Расшифровка голосовых сообщений через BYOK-провайдера организации.
|
||||
|
||||
Три шага (``prepare`` → ``run`` → ``store``) вместо одной функции: обращение к
|
||||
провайдеру ждёт ответа десятки секунд, и всё это время транзакция занимала бы
|
||||
соединение из пула (chatballs.tenancy.middleware). Кто может разнести шаги —
|
||||
разносит: ход AI (chatballs.conversations.ai_turn) и кнопка «расшифровать» в
|
||||
рабочем месте (chatballs.conversations.voice_views).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.conversations.models import Message, TranscriptStatus
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TranscriptionJob:
|
||||
"""Всё, что нужно провайдеру, — уже прочитанное из базы и хранилища.
|
||||
|
||||
Разложено на три шага (``prepare`` → ``run`` → ``store``), чтобы вызывающий
|
||||
мог держать транзакцию только вокруг первого и третьего: обращение к
|
||||
провайдеру ждёт ответа десятки секунд, и всё это время транзакция занимала
|
||||
бы соединение из пула (chatballs.tenancy.middleware).
|
||||
"""
|
||||
|
||||
provider: object
|
||||
model: str
|
||||
audio: bytes
|
||||
filename: str
|
||||
content_type: str
|
||||
|
||||
|
||||
def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
|
||||
"""Шаг в транзакции: провайдер организации, модель и байты аудио."""
|
||||
from chatballs.ai.provider.factory import get_transcription_provider
|
||||
from chatballs.ai.provider.routing import (
|
||||
DEFAULT_TRANSCRIPTION_MODEL,
|
||||
resolve_transcription_model,
|
||||
)
|
||||
|
||||
if not message.audio:
|
||||
return None
|
||||
provider = get_transcription_provider(channel=channel)
|
||||
try:
|
||||
model = resolve_transcription_model(channel)
|
||||
except ProviderError:
|
||||
model = DEFAULT_TRANSCRIPTION_MODEL # тестовый провайдер без интеграции
|
||||
with message.audio.open("rb") as handle:
|
||||
audio = handle.read()
|
||||
return TranscriptionJob(
|
||||
provider=provider,
|
||||
model=model,
|
||||
audio=audio,
|
||||
filename=message.audio.name.rsplit("/", 1)[-1],
|
||||
content_type=message.audio_content_type or "audio/ogg",
|
||||
)
|
||||
|
||||
|
||||
def run_transcription(job: TranscriptionJob) -> str:
|
||||
"""Шаг без транзакции: обращение к провайдеру."""
|
||||
return job.provider.transcribe(
|
||||
audio=job.audio,
|
||||
filename=job.filename,
|
||||
content_type=job.content_type,
|
||||
model=job.model,
|
||||
).strip()
|
||||
|
||||
|
||||
def store_transcription(message: Message, transcript: str) -> None:
|
||||
"""Шаг в транзакции: сохранить стенограмму и статус."""
|
||||
message.transcript = transcript
|
||||
message.transcript_status = TranscriptStatus.READY if transcript else TranscriptStatus.FAILED
|
||||
message.save(update_fields=["transcript", "transcript_status"])
|
||||
|
||||
|
||||
def mark_transcription_failed(message: Message) -> None:
|
||||
"""Статус FAILED — оператор повторит кнопкой."""
|
||||
message.transcript_status = TranscriptStatus.FAILED
|
||||
message.save(update_fields=["transcript_status"])
|
||||
@@ -2,6 +2,8 @@ from chatballs.conversations.transports import backoff
|
||||
from chatballs.conversations.transports import email as _email
|
||||
from chatballs.conversations.transports import max as _max
|
||||
from chatballs.conversations.transports import telegram as _telegram
|
||||
from chatballs.conversations.transports import vk as _vk
|
||||
from chatballs.conversations.transports import vk_send as _vk_send
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
@@ -9,6 +11,7 @@ from chatballs.integrations.models import IntegrationProvider
|
||||
_POLL = {
|
||||
IntegrationProvider.MAX: _max.poll_updates,
|
||||
IntegrationProvider.TELEGRAM: _telegram.poll_updates,
|
||||
IntegrationProvider.VK: _vk.poll_updates,
|
||||
IntegrationProvider.EMAIL: _email.poll_updates,
|
||||
}
|
||||
def _web_noop(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
@@ -19,6 +22,7 @@ def _web_noop(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
_SEND = {
|
||||
IntegrationProvider.MAX: _max.send_text,
|
||||
IntegrationProvider.TELEGRAM: _telegram.send_text,
|
||||
IntegrationProvider.VK: _vk_send.send_text,
|
||||
IntegrationProvider.WEB: _web_noop,
|
||||
IntegrationProvider.EMAIL: _email.send_text,
|
||||
}
|
||||
@@ -28,6 +32,9 @@ _SEND = {
|
||||
_CONTACT_REQUEST = {
|
||||
IntegrationProvider.MAX: _max.send_contact_request,
|
||||
IntegrationProvider.TELEGRAM: _telegram.send_contact_request,
|
||||
# ВКонтакте телефон не отдаёт и кнопки для этого не имеет — просьба
|
||||
# уходит обычным сообщением, как и почтой.
|
||||
IntegrationProvider.VK: _vk_send.send_text,
|
||||
IntegrationProvider.WEB: _web_noop,
|
||||
# Email: кнопок нет — просьба уходит обычным письмом.
|
||||
IntegrationProvider.EMAIL: _email.send_text,
|
||||
@@ -37,6 +44,7 @@ _CONTACT_REQUEST = {
|
||||
_CONTACT_ACK = {
|
||||
IntegrationProvider.MAX: _max.send_text,
|
||||
IntegrationProvider.TELEGRAM: _telegram.send_contact_ack,
|
||||
IntegrationProvider.VK: _vk_send.send_text,
|
||||
IntegrationProvider.WEB: _web_noop,
|
||||
IntegrationProvider.EMAIL: _email.send_text,
|
||||
}
|
||||
@@ -46,6 +54,7 @@ _CONTACT_ACK = {
|
||||
_CALL_INVITE = {
|
||||
IntegrationProvider.MAX: _max.send_call_invite,
|
||||
IntegrationProvider.TELEGRAM: _telegram.send_call_invite,
|
||||
IntegrationProvider.VK: _vk_send.send_call_invite,
|
||||
}
|
||||
|
||||
# Провайдеры-мессенджеры, у которых есть транспорт приёма/отправки.
|
||||
@@ -119,6 +128,7 @@ def _web_file_noop(integration, *, chat_id: str, user_id: str, content: bytes, f
|
||||
_FILE_SEND = {
|
||||
IntegrationProvider.TELEGRAM: _telegram.send_file,
|
||||
IntegrationProvider.MAX: _max.send_file,
|
||||
IntegrationProvider.VK: _vk_send.send_file,
|
||||
IntegrationProvider.EMAIL: _email.send_file,
|
||||
IntegrationProvider.WEB: _web_file_noop,
|
||||
}
|
||||
@@ -132,6 +142,8 @@ def download_file(integration, inbound_file) -> tuple[bytes, str]:
|
||||
return content, inbound_file.content_type or guessed
|
||||
if integration.provider == IntegrationProvider.MAX and inbound_file.url:
|
||||
return _max.download_file(integration, inbound_file.url, inbound_file.content_type)
|
||||
if integration.provider == IntegrationProvider.VK and inbound_file.url:
|
||||
return _vk.download_file(integration, inbound_file.url, inbound_file.content_type)
|
||||
raise ValueError(t("conversations.file_download_unsupported"))
|
||||
|
||||
|
||||
@@ -181,6 +193,9 @@ def download_avatar(integration, inbound) -> tuple[bytes, str] | None:
|
||||
if integration.provider == IntegrationProvider.MAX and inbound.avatar_url:
|
||||
content, _content_type = _max.download_file(integration, inbound.avatar_url, "")
|
||||
return (content, source) if content else None
|
||||
if integration.provider == IntegrationProvider.VK and inbound.avatar_url:
|
||||
content, _content_type = _vk.download_file(integration, inbound.avatar_url, "")
|
||||
return (content, source) if content else None
|
||||
return None
|
||||
|
||||
|
||||
@@ -191,6 +206,8 @@ def download_voice(integration, inbound) -> tuple[bytes, str]:
|
||||
return _telegram.download_voice(integration, inbound.voice_file_id)
|
||||
if integration.provider == IntegrationProvider.MAX and inbound.voice_url:
|
||||
return _max.download_voice(integration, inbound.voice_url)
|
||||
if integration.provider == IntegrationProvider.VK and inbound.voice_url:
|
||||
return _vk.download_voice(integration, inbound.voice_url, inbound.voice_mime)
|
||||
raise ValueError(t("conversations.voice_download_unsupported"))
|
||||
|
||||
|
||||
|
||||
@@ -20,9 +20,12 @@ from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Первая пауза — два цикла опроса, дальше удвоение до четверти часа.
|
||||
# Первая пауза — два цикла опроса, дальше удвоение до минуты. Потолок был
|
||||
# четвертью часа, пока опрос и ответы AI жили в одном процессе: длинная пауза
|
||||
# берегла общий цикл. Теперь опрос ничего не ждёт, а четверть часа тишины после
|
||||
# одного сетевого сбоя клиент видит как «бот молчит».
|
||||
FIRST_DELAY_SECONDS = 6.0
|
||||
MAX_DELAY_SECONDS = 900.0
|
||||
MAX_DELAY_SECONDS = 60.0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -56,8 +59,8 @@ def record_failure(integration, error: object) -> None:
|
||||
)
|
||||
elif delay >= MAX_DELAY_SECONDS and (previous is None or previous.delay < MAX_DELAY_SECONDS):
|
||||
logger.warning(
|
||||
"%s poll keeps failing for integration %s: %s (retrying every %.0f min)",
|
||||
integration.provider, integration.id, error, MAX_DELAY_SECONDS / 60,
|
||||
"%s poll keeps failing for integration %s: %s (retrying every %.0fs)",
|
||||
integration.provider, integration.id, error, MAX_DELAY_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,10 @@ def _bot_started(update: dict) -> InboundMessage | None:
|
||||
|
||||
|
||||
def _normalize(update: dict) -> InboundMessage | None:
|
||||
logger.info("MAX raw update: %s", json.dumps(update, ensure_ascii=False))
|
||||
# Сырой апдейт нужен при разборе настройки, а не в каждой строке журнала
|
||||
# рабочего сервера: поля MAX документированы не полностью, и посмотреть их
|
||||
# глазами иногда надо — но по включённому DEBUG.
|
||||
logger.debug("MAX raw update: %s", json.dumps(update, ensure_ascii=False))
|
||||
update_type = update.get("update_type") or update.get("updateType")
|
||||
if update_type == "bot_started":
|
||||
return _bot_started(update)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""VK community transport: приём сообщений (Bots Long Poll API).
|
||||
|
||||
``groups.getLongPollServer`` отдаёт адрес сервера, ключ и позицию в потоке
|
||||
событий, дальше сервер опрашивается ``act=a_check``. Отправка живёт в
|
||||
``vk_send``, разбор вложений — в ``vk_media``, доступ к API — в ``vk_api``.
|
||||
|
||||
Позиция потока хранится в ``poll_marker``, а адрес сервера и ключ — в памяти
|
||||
процесса: их выдают на несколько часов, и колонка под них означала бы запись в
|
||||
базу на каждом цикле опроса (``transports.backoff`` устроен так же).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.conversations.transports import vk_api, vk_media
|
||||
from chatballs.conversations.transports.base import InboundMessage, first, request_json
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.integrations.checks import vk_group
|
||||
from chatballs.integrations.outbound import mask_url_secrets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Session:
|
||||
server: str
|
||||
key: str
|
||||
group_id: str
|
||||
|
||||
|
||||
_sessions: dict[int, _Session] = {}
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Забыть выданные адреса Long Poll (тесты, смена токена подключения)."""
|
||||
_sessions.clear()
|
||||
|
||||
|
||||
def _ts(value: object, fallback: str = "") -> str:
|
||||
"""Позиция потока строкой.
|
||||
|
||||
Ноль — это тоже позиция: у сообщества, которому ещё не писали, ВКонтакте
|
||||
отдаёт ``ts: 0``. Обычное ``or`` приняло бы её за отсутствие значения, а
|
||||
опрос с пустой позицией возвращает ``ts: -1`` — курсор, с которого поток
|
||||
уже не читается.
|
||||
"""
|
||||
return fallback if value is None or value == "" else str(value)
|
||||
|
||||
|
||||
def _group_id(integration) -> str:
|
||||
"""Идентификатор сообщества: из настроек подключения или у самого ВКонтакте.
|
||||
|
||||
Проверка подключения кладёт его в конфигурацию, но опрос обязан работать и
|
||||
до первой проверки: иначе подключение, созданное в обход кнопки
|
||||
«Проверить», молча не принимало бы сообщения.
|
||||
"""
|
||||
configured = str(integration.config.get("bot_id") or "")
|
||||
if configured:
|
||||
return configured
|
||||
group_id = str(vk_group(vk_api.call(integration, "groups.getById")).get("id") or "")
|
||||
if not group_id:
|
||||
raise PollFailed(t("conversations.vk_no_group"))
|
||||
return group_id
|
||||
|
||||
|
||||
def _open_session(integration, group_id: str = "") -> tuple[_Session, str]:
|
||||
"""Новый адрес Long Poll; вторым значением — позиция потока от ВКонтакте."""
|
||||
group = group_id or _group_id(integration)
|
||||
response = vk_api.call(integration, "groups.getLongPollServer", {"group_id": group}) or {}
|
||||
session = _Session(
|
||||
server=str(response.get("server") or ""),
|
||||
key=str(response.get("key") or ""),
|
||||
group_id=group,
|
||||
)
|
||||
if not session.server or not session.key:
|
||||
raise PollFailed(t("conversations.vk_longpoll_unavailable"))
|
||||
_sessions[integration.id] = session
|
||||
return session, _ts(response.get("ts"))
|
||||
|
||||
|
||||
def _check(integration, session: _Session, ts: str) -> dict:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"act": "a_check",
|
||||
"key": session.key,
|
||||
"ts": ts,
|
||||
"wait": settings.CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS,
|
||||
}
|
||||
)
|
||||
return request_json(f"{session.server}?{query}", proxy_url=vk_api.proxy(integration))
|
||||
|
||||
|
||||
def _recover(integration, session: _Session, ts: str, data: dict) -> tuple[dict, str]:
|
||||
"""Ответ ``failed``: позиция устарела (1) либо ключ и история потеряны (2, 3).
|
||||
|
||||
Восстанавливаемся тут же, а не на следующем цикле: иначе подключение висело
|
||||
бы с протухшим ключом, а поллер всё это время считал бы, что сообщений
|
||||
просто нет.
|
||||
"""
|
||||
failed = int(data.get("failed") or 0)
|
||||
if failed == 1:
|
||||
ts = _ts(data.get("ts"), ts)
|
||||
else:
|
||||
session, fresh_ts = _open_session(integration, session.group_id)
|
||||
# Потеря истории (3) обесценивает и позицию: со старой сервер не отдаст
|
||||
# ничего. Протухший ключ (2) позицию не трогает.
|
||||
ts = ts if failed == 2 else fresh_ts
|
||||
retried = _check(integration, session, ts)
|
||||
if retried.get("failed"):
|
||||
raise PollFailed(t("conversations.vk_longpoll_failed", code=retried.get("failed")))
|
||||
return retried, ts
|
||||
|
||||
|
||||
def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
if not integration.secret:
|
||||
return [], integration.poll_marker
|
||||
try:
|
||||
session = _sessions.get(integration.id)
|
||||
ts = integration.poll_marker
|
||||
if session is None or not ts:
|
||||
session, fresh_ts = _open_session(integration, session.group_id if session else "")
|
||||
ts = ts or fresh_ts
|
||||
data = _check(integration, session, ts)
|
||||
if data.get("failed"):
|
||||
data, ts = _recover(integration, session, ts, data)
|
||||
messages = _messages(integration, data.get("updates") or [])
|
||||
except vk_api.CALL_ERRORS as error:
|
||||
# Сессия могла протухнуть вместе со связью — следующая попытка начнёт с
|
||||
# нового адреса, а не с сохранённого мёртвого.
|
||||
_sessions.pop(integration.id, None)
|
||||
raise PollFailed(mask_url_secrets(error)) from error
|
||||
return messages, _ts(data.get("ts"), ts)
|
||||
|
||||
|
||||
def _messages(integration, updates: list) -> list[InboundMessage]:
|
||||
inbound = [message for message in (_normalize(integration, u) for u in updates) if message is not None]
|
||||
return _with_profiles(integration, inbound)
|
||||
|
||||
|
||||
def _normalize(integration, update: dict) -> InboundMessage | None:
|
||||
if update.get("type") != "message_new":
|
||||
return None
|
||||
payload = update.get("object") or {}
|
||||
# С версии 5.103 сообщение лежит в object.message, до неё — прямо в object.
|
||||
message = payload.get("message") or payload
|
||||
from_id = message.get("from_id")
|
||||
peer_id = message.get("peer_id")
|
||||
external_id = first(message, "id", "conversation_message_id")
|
||||
if from_id is None or peer_id is None or external_id is None:
|
||||
return None
|
||||
if int(from_id) < 0:
|
||||
# Сообщение самого сообщества (ответ из диалогов ВКонтакте) — не входящее.
|
||||
return None
|
||||
voice_url, voice_duration, voice_mime, voice_unavailable = vk_media.voice_attachment(message)
|
||||
files = vk_media.file_attachments(message)
|
||||
text = _text(integration, message, has_content=bool(files or voice_url or voice_unavailable))
|
||||
if not text and not files and not voice_url and not voice_unavailable:
|
||||
return None
|
||||
return InboundMessage(
|
||||
external_id=str(external_id),
|
||||
user_id=str(from_id),
|
||||
chat_id=str(peer_id),
|
||||
text=text,
|
||||
display_name="",
|
||||
voice_url=voice_url,
|
||||
voice_duration=voice_duration,
|
||||
voice_mime=voice_mime,
|
||||
voice_unavailable=voice_unavailable,
|
||||
files=files,
|
||||
)
|
||||
|
||||
|
||||
def _text(integration, message: dict, *, has_content: bool) -> str:
|
||||
"""Текст реплики; для непоказуемого вложения — след вместо пустоты."""
|
||||
text = str(message.get("text") or "")
|
||||
unsupported = vk_media.unsupported_attachments(message)
|
||||
if unsupported:
|
||||
logger.info("VK attachment types not shown to the operator: %s", ", ".join(unsupported))
|
||||
if text or has_content or not unsupported:
|
||||
return text
|
||||
# Вложение, которое канал показать не может: пустой текст обернулся бы
|
||||
# потерей реплики, поэтому оператор видит хотя бы её след.
|
||||
return t("conversations.attachment_unsupported", language=customer_language(integration.organization))
|
||||
|
||||
|
||||
def _with_profiles(integration, messages: list[InboundMessage]) -> list[InboundMessage]:
|
||||
"""Имя, логин и фото отправителей — одним запросом на пачку.
|
||||
|
||||
В апдейте ВКонтакте профиля нет, его отдаёт ``users.get``. Спрашивать его на
|
||||
каждое сообщение значило бы упереться в частоту обращений на оживлённом
|
||||
сообществе, поэтому запрос один на цикл опроса.
|
||||
"""
|
||||
user_ids = sorted({message.user_id for message in messages if message.user_id.isdigit()})
|
||||
if not user_ids:
|
||||
return messages
|
||||
try:
|
||||
response = vk_api.call(
|
||||
integration,
|
||||
"users.get",
|
||||
{"user_ids": ",".join(user_ids), "fields": "photo_100,screen_name"},
|
||||
)
|
||||
except vk_api.CALL_ERRORS as error:
|
||||
# Без профиля сообщение всё равно доезжает: имя контакта уточнится на
|
||||
# следующем сообщении, а терять реплику из-за справки нельзя.
|
||||
logger.warning("VK users.get failed for integration %s: %s", integration.id, mask_url_secrets(error))
|
||||
return messages
|
||||
profiles = {str(user.get("id")): user for user in (response or []) if isinstance(user, dict)}
|
||||
return [_with_profile(message, profiles.get(message.user_id)) for message in messages]
|
||||
|
||||
|
||||
def _with_profile(message: InboundMessage, profile: dict | None) -> InboundMessage:
|
||||
if not profile:
|
||||
return message
|
||||
name = " ".join(part for part in (profile.get("first_name"), profile.get("last_name")) if part)
|
||||
screen_name = str(profile.get("screen_name") or "")
|
||||
return replace(
|
||||
message,
|
||||
display_name=str(name or screen_name),
|
||||
username=screen_name,
|
||||
avatar_url=str(profile.get("photo_100") or ""),
|
||||
)
|
||||
|
||||
|
||||
def download_file(integration, url: str, content_type: str) -> tuple[bytes, str]:
|
||||
"""Скачивание вложения по прямому адресу из апдейта."""
|
||||
return vk_api.download(integration, url), content_type or "application/octet-stream"
|
||||
|
||||
|
||||
def download_voice(integration, url: str, content_type: str = "") -> tuple[bytes, str]:
|
||||
return vk_api.download(integration, url), content_type or "audio/ogg"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Доступ к API ВКонтакте: адрес подключения, прокси, вызов метода, скачивание.
|
||||
|
||||
Ключ доступа сообщества уходит параметром запроса — заголовка авторизации у
|
||||
ВКонтакте нет. Поэтому ни один адрес не печатается как есть: и журнал, и статус
|
||||
подключения получают его через ``mask_url_secrets``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
from chatballs.conversations.transports.base import download_bytes
|
||||
from chatballs.integrations.checks import DEFAULT_VK_BASE_URL, VkRejected, vk_call
|
||||
from chatballs.integrations.outbound import host_of
|
||||
|
||||
NETWORK_ERRORS = (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError)
|
||||
# Отказ провайдера и обрыв связи обрабатываются одинаково: подключение на этом
|
||||
# цикле не работает, а чем именно — видно из текста ошибки.
|
||||
CALL_ERRORS = (VkRejected, *NETWORK_ERRORS)
|
||||
|
||||
|
||||
def base(integration) -> str:
|
||||
return (integration.config.get("base_url") or DEFAULT_VK_BASE_URL).rstrip("/")
|
||||
|
||||
|
||||
def proxy(integration) -> str:
|
||||
return integration.config.get("proxy_url", "")
|
||||
|
||||
|
||||
def call(integration, method: str, params: dict | None = None, *, post: bool = False):
|
||||
"""Метод API ВКонтакте от имени подключения; возвращает содержимое response."""
|
||||
return vk_call(
|
||||
base_url=base(integration),
|
||||
method=method,
|
||||
secret=integration.secret,
|
||||
params=params,
|
||||
proxy_url=proxy(integration),
|
||||
post=post,
|
||||
)
|
||||
|
||||
|
||||
def download(integration, url: str) -> bytes:
|
||||
"""Скачивание по прямому адресу из ответа провайдера.
|
||||
|
||||
Хост из ``base_url`` подключения владелец назвал сам, поэтому он остаётся
|
||||
разрешённым, даже если ведёт внутрь сети (chatballs.integrations.outbound).
|
||||
"""
|
||||
return download_bytes(url, proxy_url=proxy(integration), allowed_host=host_of(base(integration)))
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Вложения ВКонтакте: разбор входящих и загрузка исходящих.
|
||||
|
||||
Разбор здесь чистый — на вход словарь сообщения, на выход значения для
|
||||
``InboundMessage``. Загрузка идёт в три шага (получить адрес загрузки, залить
|
||||
файл, сохранить его у провайдера), поэтому наружу она принимает вызов API и
|
||||
прокси подключения, а не само подключение: так модуль не зависит от транспорта
|
||||
и проверяется без базы.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from chatballs.conversations.transports.base import (
|
||||
InboundFile,
|
||||
first,
|
||||
guess_content_type,
|
||||
request_json_multipart,
|
||||
safe_filename,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Вложения, которые канал умеет показать оператору. Остальные (видео, товар,
|
||||
# запись на стене) ВКонтакте отдаёт только по отдельному запросу с правами,
|
||||
# которых у сообщества нет.
|
||||
KNOWN_ATTACHMENTS = ("photo", "doc", "audio_message", "sticker", "graffiti")
|
||||
|
||||
|
||||
def photo_url(photo: dict) -> str:
|
||||
"""Самый крупный размер фотографии.
|
||||
|
||||
Набор размеров зависит от исходника, поэтому выбираем по площади, а не по
|
||||
буквенному типу: у маленьких снимков крупных типов просто нет.
|
||||
"""
|
||||
sizes = [size for size in (photo.get("sizes") or []) if size.get("url")]
|
||||
if not sizes:
|
||||
return ""
|
||||
largest = max(sizes, key=lambda size: int(size.get("width") or 0) * int(size.get("height") or 0))
|
||||
return str(largest.get("url") or "")
|
||||
|
||||
|
||||
def voice_attachment(message: dict) -> tuple[str, int, str, bool]:
|
||||
"""Голосовое сообщение: адрес, длительность, mime и признак «нечего скачать».
|
||||
|
||||
Последнее значение означает, что голосовое в сообщении было, а адреса в нём
|
||||
нет. Такая реплика всё равно доезжает до оператора заглушкой: молча терять
|
||||
сказанное клиентом нельзя.
|
||||
"""
|
||||
for attachment in message.get("attachments") or []:
|
||||
if attachment.get("type") != "audio_message":
|
||||
continue
|
||||
payload = attachment.get("audio_message") or {}
|
||||
ogg = str(payload.get("link_ogg") or "")
|
||||
url = ogg or str(payload.get("link_mp3") or "")
|
||||
duration = int(payload.get("duration") or 0)
|
||||
return url, duration, ("audio/ogg" if ogg else "audio/mpeg") if url else "", not url
|
||||
return "", 0, "", False
|
||||
|
||||
|
||||
def file_attachments(message: dict) -> tuple[InboundFile, ...]:
|
||||
"""Фото, документы, стикеры и граффити — каждый становится своей репликой."""
|
||||
files: list[InboundFile] = []
|
||||
for attachment in message.get("attachments") or []:
|
||||
kind = attachment.get("type")
|
||||
if kind == "photo":
|
||||
url = photo_url(attachment.get("photo") or {})
|
||||
if url:
|
||||
files.append(InboundFile(name="photo.jpg", content_type="image/jpeg", url=url, is_image=True))
|
||||
elif kind in ("sticker", "graffiti"):
|
||||
url = _image_from_list(attachment.get(kind) or {})
|
||||
if url:
|
||||
files.append(InboundFile(name=f"{kind}.png", content_type="image/png", url=url, is_image=True))
|
||||
elif kind == "doc":
|
||||
document = attachment.get("doc") or {}
|
||||
url = str(document.get("url") or "")
|
||||
if not url:
|
||||
continue
|
||||
name = safe_filename(document.get("title") or "", "document")
|
||||
mime = guess_content_type(name)
|
||||
files.append(
|
||||
InboundFile(
|
||||
name=name,
|
||||
content_type=mime,
|
||||
size=int(document.get("size") or 0),
|
||||
url=url,
|
||||
is_image=mime.startswith("image/"),
|
||||
)
|
||||
)
|
||||
return tuple(files)
|
||||
|
||||
|
||||
def unsupported_attachments(message: dict) -> tuple[str, ...]:
|
||||
"""Типы вложений, которые разобрать не удалось, — для журнала."""
|
||||
return tuple(
|
||||
str(attachment.get("type") or "")
|
||||
for attachment in message.get("attachments") or []
|
||||
if attachment.get("type") not in KNOWN_ATTACHMENTS
|
||||
)
|
||||
|
||||
|
||||
def _image_from_list(payload: dict) -> str:
|
||||
"""Самая крупная картинка стикера или граффити."""
|
||||
images = [image for image in (payload.get("images") or []) if image.get("url")]
|
||||
if not images:
|
||||
return str(payload.get("url") or "")
|
||||
largest = max(images, key=lambda image: int(image.get("width") or 0) * int(image.get("height") or 0))
|
||||
return str(largest.get("url") or "")
|
||||
|
||||
|
||||
def upload_attachment(
|
||||
*,
|
||||
api: Callable[..., object],
|
||||
proxy_url: str,
|
||||
peer_id: str,
|
||||
content: bytes,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
) -> str:
|
||||
"""Загрузить файл и вернуть строку attachment для messages.send.
|
||||
|
||||
Фото и документы у ВКонтакте загружаются разными парами методов, но шаги
|
||||
одинаковы: получить адрес загрузки, отправить файл на него, сохранить
|
||||
результат. Пустая строка — загрузка не удалась, отправку продолжать нечем.
|
||||
"""
|
||||
as_photo = content_type in ("image/jpeg", "image/png", "image/gif", "image/webp")
|
||||
if as_photo:
|
||||
server = api("photos.getMessagesUploadServer", {"peer_id": peer_id})
|
||||
uploaded = _upload(server, proxy_url=proxy_url, field="photo", filename=filename, content=content, content_type=content_type)
|
||||
if not uploaded.get("photo"):
|
||||
return ""
|
||||
saved = api(
|
||||
"photos.saveMessagesPhoto",
|
||||
{"server": uploaded.get("server", ""), "photo": uploaded.get("photo", ""), "hash": uploaded.get("hash", "")},
|
||||
post=True,
|
||||
)
|
||||
item = (saved or [{}])[0] if isinstance(saved, list) else {}
|
||||
return _attachment_id("photo", item)
|
||||
server = api("docs.getMessagesUploadServer", {"type": "doc", "peer_id": peer_id})
|
||||
uploaded = _upload(server, proxy_url=proxy_url, field="file", filename=filename, content=content, content_type=content_type)
|
||||
if not uploaded.get("file"):
|
||||
return ""
|
||||
saved = api("docs.save", {"file": uploaded.get("file", "")}, post=True)
|
||||
item = (saved or {}).get("doc") or {} if isinstance(saved, dict) else {}
|
||||
return _attachment_id("doc", item)
|
||||
|
||||
|
||||
def _upload(server: object, *, proxy_url: str, field: str, filename: str, content: bytes, content_type: str) -> dict:
|
||||
upload_url = str((server or {}).get("upload_url") or "") if isinstance(server, dict) else ""
|
||||
if not upload_url:
|
||||
return {}
|
||||
return request_json_multipart(
|
||||
upload_url,
|
||||
fields={},
|
||||
file_field=field,
|
||||
filename=filename,
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
|
||||
|
||||
def _attachment_id(kind: str, item: dict) -> str:
|
||||
owner_id = first(item, "owner_id", default="")
|
||||
item_id = first(item, "id", default="")
|
||||
return f"{kind}{owner_id}_{item_id}" if owner_id != "" and item_id != "" else ""
|
||||
@@ -0,0 +1,84 @@
|
||||
"""VK community transport: отправка сообщений (messages.send).
|
||||
|
||||
Текст, приглашение на звонок кнопкой-ссылкой и файл оператора. Голосовых здесь
|
||||
нет намеренно: ВКонтакте принимает голосовое только в ogg/opus, а композер
|
||||
записывает webm/opus, поэтому VK не зарегистрирован в ``_VOICE_SEND`` и
|
||||
интерфейс не предлагает записать голосовое в этом канале.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
|
||||
from chatballs.conversations.transports import vk_api, vk_media
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.integrations.outbound import mask_url_secrets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _send(integration, *, chat_id: str, user_id: str, params: dict) -> bool:
|
||||
target = chat_id or user_id
|
||||
if not integration.secret or not target:
|
||||
return False
|
||||
body = {
|
||||
"peer_id": target,
|
||||
# random_id обязателен: по нему ВКонтакте отбрасывает повторы. Отправку
|
||||
# мы не повторяем, поэтому значение своё на каждый вызов.
|
||||
"random_id": random.getrandbits(31),
|
||||
**params,
|
||||
}
|
||||
try:
|
||||
# POST: ответ агента длиннее, чем разумно укладывать в адрес запроса.
|
||||
vk_api.call(integration, "messages.send", body, post=True)
|
||||
return True
|
||||
except vk_api.CALL_ERRORS as error:
|
||||
logger.warning("VK send failed for integration %s: %s", integration.id, mask_url_secrets(error))
|
||||
return False
|
||||
|
||||
|
||||
def send_text(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, params={"message": text})
|
||||
|
||||
|
||||
def _caption(integration, key: str) -> str:
|
||||
"""Подпись кнопки читает клиент — язык организации, а не язык запроса."""
|
||||
return t(key, language=customer_language(integration.organization))
|
||||
|
||||
|
||||
def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url: str) -> bool:
|
||||
# Приглашение на онлайн-звонок: кнопка-ссылка под сообщением.
|
||||
button = {"action": {"type": "open_link", "link": url, "label": _caption(integration, "conversations.button_join_call")}}
|
||||
keyboard = {"inline": True, "buttons": [[button]]}
|
||||
return _send(
|
||||
integration,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
params={"message": text, "keyboard": json.dumps(keyboard, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filename: str, content_type: str, caption: str = "") -> bool:
|
||||
"""Файл оператора: загрузка у провайдера, затем сообщение со ссылкой на неё."""
|
||||
target = chat_id or user_id
|
||||
if not integration.secret or not target:
|
||||
return False
|
||||
try:
|
||||
attachment = vk_media.upload_attachment(
|
||||
api=functools.partial(vk_api.call, integration),
|
||||
proxy_url=vk_api.proxy(integration),
|
||||
peer_id=target,
|
||||
content=content,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
)
|
||||
except vk_api.CALL_ERRORS as error:
|
||||
logger.warning("VK upload failed for integration %s: %s", integration.id, mask_url_secrets(error))
|
||||
return False
|
||||
if not attachment:
|
||||
return False
|
||||
params = {"attachment": attachment, **({"message": caption[:4000]} if caption else {})}
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, params=params)
|
||||
@@ -75,7 +75,7 @@ class MessageTranscribeView(ConversationViewBase):
|
||||
tenant_manages_own_transaction = True
|
||||
|
||||
def post(self, request: Request, message_id: int) -> Response:
|
||||
from chatballs.conversations.ingest import (
|
||||
from chatballs.conversations.transcription import (
|
||||
mark_transcription_failed,
|
||||
prepare_transcription,
|
||||
run_transcription,
|
||||
|
||||
@@ -10,11 +10,28 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
EventHandler = Callable[[dict, TenantContext | None], None]
|
||||
_REGISTRY: dict[str, EventHandler] = {}
|
||||
# Обработчики, которые открывают транзакции сами (см. `register`).
|
||||
_OWN_TRANSACTION: set[str] = set()
|
||||
|
||||
|
||||
def register(event_type: str) -> Callable[[EventHandler], EventHandler]:
|
||||
def register(
|
||||
event_type: str, *, manages_own_transaction: bool = False
|
||||
) -> Callable[[EventHandler], EventHandler]:
|
||||
"""Зарегистрировать обработчик события.
|
||||
|
||||
По умолчанию обработчик выполняется целиком в одной транзакции: так у него
|
||||
есть RLS-контекст и атомарность, и думать об этом не нужно. Обработчику,
|
||||
который ходит наружу — к модели, в мессенджер, — такая транзакция стоит
|
||||
соединения из пула на всё время ожидания. Он выставляет
|
||||
`manages_own_transaction` и открывает `tenant_atomic` сам, вокруг обращений
|
||||
к базе. Забытый блок не опасен: без транзакции RLS-настройка пуста и строки
|
||||
просто не видны — ошибка проявится сразу.
|
||||
"""
|
||||
|
||||
def decorator(handler: EventHandler) -> EventHandler:
|
||||
_REGISTRY[event_type] = handler
|
||||
if manages_own_transaction:
|
||||
_OWN_TRANSACTION.add(event_type)
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
@@ -29,5 +46,8 @@ def dispatch(event: OutboxEvent) -> None:
|
||||
if context is None:
|
||||
handler(event.payload, None)
|
||||
return
|
||||
if event.event_type in _OWN_TRANSACTION:
|
||||
handler(event.payload, context)
|
||||
return
|
||||
with tenant_atomic(context):
|
||||
handler(event.payload, context)
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from django.core.management import call_command
|
||||
@@ -11,7 +12,12 @@ from chatballs.conversations.maintenance import close_stale_conversations
|
||||
from chatballs.conversations.poller import poll_all_messengers
|
||||
from chatballs.events.handlers import dispatch
|
||||
from chatballs.events.models import OutboxStatus
|
||||
from chatballs.events.services import claim_next_outbox_event, mark_retry
|
||||
from chatballs.events.services import (
|
||||
OUTBOX_DB,
|
||||
claim_next_outbox_event,
|
||||
mark_retry,
|
||||
release_stale_processing,
|
||||
)
|
||||
from chatballs.notifications.binding import poll_notifier_bots
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
@@ -20,16 +26,42 @@ from chatballs.updates.services import check_for_updates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Роли воркера. Разделены потому, что работы у них разного веса: опрос
|
||||
# подключений — это короткие запросы и записи в базу, а обработка событий может
|
||||
# ждать модель десятки секунд. В одном процессе второе перекрывало первое, и
|
||||
# входящие переставали забираться на всё время ответа AI.
|
||||
ROLE_ALL = "all"
|
||||
ROLE_POLLER = "poller"
|
||||
ROLE_EVENTS = "events"
|
||||
|
||||
MESSENGER_POLL_INTERVAL = 3.0 # seconds between messenger long-poll cycles
|
||||
MAINTENANCE_INTERVAL = 3600.0 # seconds between maintenance cycles (auto-close stale dialogs)
|
||||
CALL_SWEEP_INTERVAL = 10.0 # seconds between call timeout sweeps (invite expiry, stuck connect)
|
||||
# Пороги очереди задаются в минутах, поэтому раз в полминуты — с запасом:
|
||||
# проверка дешёвая, а повтор гасится dedup-ключом уровня.
|
||||
QUEUE_SWEEP_INTERVAL = 30.0 # seconds between waiting-queue escalation sweeps
|
||||
# Возврат событий, взятых в работу упавшим процессом.
|
||||
STALE_SWEEP_INTERVAL = 60.0
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Runs the local domain event worker (outbox dispatch + messenger inbound polling)."
|
||||
help = (
|
||||
"Runs the local domain event worker. Roles: 'poller' polls messenger "
|
||||
"connections and runs sweeps, 'events' dispatches the outbox (AI turns), "
|
||||
"'all' does both in one process (development default)."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument(
|
||||
"--role",
|
||||
choices=[ROLE_ALL, ROLE_POLLER, ROLE_EVENTS],
|
||||
default=os.environ.get("CHATBALLS_WORKER_ROLE", ROLE_ALL),
|
||||
help=(
|
||||
"Что делает этот процесс. Опрос держат в одном экземпляре "
|
||||
"(курсоры подключений и паузы после сбоя живут в его памяти), "
|
||||
"роль событий масштабируется репликами."
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tenant_contexts():
|
||||
@@ -38,35 +70,54 @@ class Command(BaseCommand):
|
||||
organization, actor_kind=TenantActorKind.SYSTEM
|
||||
)
|
||||
|
||||
def _for_each_tenant(self, operation, failure: str) -> None:
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
operation(context)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception(failure)
|
||||
|
||||
def _dispatch_one(self) -> bool:
|
||||
"""Взять и обработать одно событие. False — событий нет."""
|
||||
|
||||
try:
|
||||
event = claim_next_outbox_event()
|
||||
except Exception: # pragma: no cover
|
||||
# Отравленное событие не должно ронять процесс: иначе воркер
|
||||
# уходит в краш-петлю и вместе с outbox встают поллинг
|
||||
# мессенджеров и таймауты звонков.
|
||||
logger.exception("Outbox claim cycle failed")
|
||||
time.sleep(1)
|
||||
return False
|
||||
if event is None:
|
||||
return False
|
||||
try:
|
||||
logger.info("Processing outbox event %s", event.id)
|
||||
dispatch(event)
|
||||
event.status = OutboxStatus.PROCESSED
|
||||
event.processed_at = timezone.now()
|
||||
event.save(
|
||||
using=OUTBOX_DB,
|
||||
update_fields=["status", "processed_at"],
|
||||
)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.exception("Outbox event failed: %s", event.id)
|
||||
mark_retry(event, str(exc))
|
||||
return True
|
||||
|
||||
def handle(self, *args: object, **options: object) -> None:
|
||||
self.stdout.write("Hub worker started")
|
||||
role = str(options["role"])
|
||||
does_events = role in (ROLE_ALL, ROLE_EVENTS)
|
||||
does_polling = role in (ROLE_ALL, ROLE_POLLER)
|
||||
self.stdout.write(f"Hub worker started (role={role})")
|
||||
last_poll = 0.0
|
||||
last_maintenance = 0.0
|
||||
last_call_sweep = 0.0
|
||||
last_queue_sweep = 0.0
|
||||
last_stale_sweep = 0.0
|
||||
while True:
|
||||
try:
|
||||
event = claim_next_outbox_event()
|
||||
except Exception: # pragma: no cover
|
||||
# Отравленное событие не должно ронять процесс: иначе воркер
|
||||
# уходит в краш-петлю и вместе с outbox встают поллинг
|
||||
# мессенджеров и таймауты звонков.
|
||||
logger.exception("Outbox claim cycle failed")
|
||||
time.sleep(1)
|
||||
continue
|
||||
if event is not None:
|
||||
try:
|
||||
logger.info("Processing outbox event %s", event.id)
|
||||
dispatch(event)
|
||||
event.status = OutboxStatus.PROCESSED
|
||||
event.processed_at = timezone.now()
|
||||
event.save(
|
||||
using="platform",
|
||||
update_fields=["status", "processed_at"],
|
||||
)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.exception("Outbox event failed: %s", event.id)
|
||||
mark_retry(event, str(exc))
|
||||
worked = self._dispatch_one() if does_events else False
|
||||
|
||||
# Дальше идут периодические работы. Раньше обработка события
|
||||
# обрывала цикл на `continue`, и при непрерывном потоке событий —
|
||||
@@ -74,37 +125,27 @@ class Command(BaseCommand):
|
||||
# переставали забираться входящие сообщения и истекать приглашения
|
||||
# на звонки. Проверки дешёвые: почти всегда это сравнение времени.
|
||||
now = time.monotonic()
|
||||
if now - last_poll >= MESSENGER_POLL_INTERVAL:
|
||||
if does_events and now - last_stale_sweep >= STALE_SWEEP_INTERVAL:
|
||||
last_stale_sweep = now
|
||||
try:
|
||||
released = release_stale_processing()
|
||||
if released:
|
||||
logger.warning("Released %s stale outbox event(s)", released)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Stale outbox sweep failed")
|
||||
if does_polling and now - last_poll >= MESSENGER_POLL_INTERVAL:
|
||||
last_poll = now
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
poll_all_messengers(context)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Messenger polling cycle failed")
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
poll_notifier_bots(context)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Notifier polling cycle failed")
|
||||
if now - last_call_sweep >= CALL_SWEEP_INTERVAL:
|
||||
self._for_each_tenant(poll_all_messengers, "Messenger polling cycle failed")
|
||||
self._for_each_tenant(poll_notifier_bots, "Notifier polling cycle failed")
|
||||
if does_polling and now - last_call_sweep >= CALL_SWEEP_INTERVAL:
|
||||
last_call_sweep = now
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
expire_stale_calls(context)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Call sweep cycle failed")
|
||||
if now - last_queue_sweep >= QUEUE_SWEEP_INTERVAL:
|
||||
self._for_each_tenant(expire_stale_calls, "Call sweep cycle failed")
|
||||
if does_polling and now - last_queue_sweep >= QUEUE_SWEEP_INTERVAL:
|
||||
last_queue_sweep = now
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
sweep_waiting_conversations(context)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Waiting queue sweep cycle failed")
|
||||
if now - last_maintenance >= MAINTENANCE_INTERVAL:
|
||||
self._for_each_tenant(
|
||||
sweep_waiting_conversations, "Waiting queue sweep cycle failed"
|
||||
)
|
||||
if does_polling and now - last_maintenance >= MAINTENANCE_INTERVAL:
|
||||
last_maintenance = now
|
||||
# Канал релизов спрашивается не чаще раза в несколько часов:
|
||||
# интервал держит сама проверка по времени последнего ответа.
|
||||
@@ -112,12 +153,7 @@ class Command(BaseCommand):
|
||||
check_for_updates()
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Update check cycle failed")
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
close_stale_conversations(context)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Maintenance cycle failed")
|
||||
self._for_each_tenant(close_stale_conversations, "Maintenance cycle failed")
|
||||
try:
|
||||
# Просроченные сессии Django сам не удаляет, а их накопление
|
||||
# утяжеляет карточку сотрудника: владельца сессии видно
|
||||
@@ -127,5 +163,5 @@ class Command(BaseCommand):
|
||||
logger.exception("Session cleanup failed")
|
||||
# Спим только когда работы нет: иначе очередь событий разбиралась бы
|
||||
# по одному событию в секунду.
|
||||
if event is None:
|
||||
if not worked:
|
||||
time.sleep(1)
|
||||
@@ -3,6 +3,7 @@ from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Exists, OuterRef
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.events.context import get_correlation_id
|
||||
@@ -12,6 +13,11 @@ from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
# Где живёт outbox. Захват идёт по всем организациям сразу, поэтому читает и
|
||||
# отмечает события роль platform, а не app (chatballs.tenancy.routing).
|
||||
OUTBOX_DB = "platform"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DomainEvent:
|
||||
aggregate_type: str
|
||||
@@ -92,24 +98,60 @@ def mark_retry(event: OutboxEvent, error: str, max_attempts: int = 5) -> None:
|
||||
event.status = OutboxStatus.DEAD_LETTER if event.attempts >= max_attempts else OutboxStatus.FAILED
|
||||
event.next_attempt_at = timezone.now() + timedelta(seconds=min(300, 2**event.attempts))
|
||||
event.save(
|
||||
using="platform",
|
||||
using=OUTBOX_DB,
|
||||
update_fields=["attempts", "last_error", "status", "next_attempt_at"],
|
||||
)
|
||||
|
||||
|
||||
def claim_next_outbox_event() -> OutboxEvent | None:
|
||||
with transaction.atomic(using="platform"):
|
||||
# Сколько событию отведено на обработку. Роль событий работает в нескольких
|
||||
# процессах, и взятое в работу событие не должно достаться второму; но и
|
||||
# пропасть навсегда, если процесс упал посреди обработки, оно тоже не должно.
|
||||
# Срок хранится в `next_attempt_at`: у поля ровно этот смысл — «не раньше».
|
||||
PROCESSING_LEASE_SECONDS = 300
|
||||
|
||||
|
||||
def claim_next_outbox_event(*, lease_seconds: int = PROCESSING_LEASE_SECONDS) -> OutboxEvent | None:
|
||||
"""Взять следующее событие в работу.
|
||||
|
||||
События одного объекта идут строго по очереди: пока по агрегату есть
|
||||
событие в работе, следующее не выдаётся. Иначе два ответа AI одному
|
||||
диалогу считались бы параллельно и приезжали клиенту вперемешку.
|
||||
"""
|
||||
now = timezone.now()
|
||||
busy = OutboxEvent.objects.using(OUTBOX_DB).filter(
|
||||
status=OutboxStatus.PROCESSING,
|
||||
aggregate_type=OuterRef("aggregate_type"),
|
||||
aggregate_id=OuterRef("aggregate_id"),
|
||||
)
|
||||
with transaction.atomic(using=OUTBOX_DB):
|
||||
event = (
|
||||
OutboxEvent.objects.using("platform").select_for_update(skip_locked=True)
|
||||
OutboxEvent.objects.using(OUTBOX_DB).select_for_update(skip_locked=True)
|
||||
.filter(
|
||||
status__in=[OutboxStatus.PENDING, OutboxStatus.FAILED],
|
||||
next_attempt_at__lte=timezone.now(),
|
||||
next_attempt_at__lte=now,
|
||||
)
|
||||
.filter(~Exists(busy))
|
||||
.order_by("next_attempt_at", "created_at")
|
||||
.first()
|
||||
)
|
||||
if event is None:
|
||||
return None
|
||||
event.status = OutboxStatus.PROCESSING
|
||||
event.save(using="platform", update_fields=["status"])
|
||||
event.next_attempt_at = now + timedelta(seconds=lease_seconds)
|
||||
event.save(using=OUTBOX_DB, update_fields=["status", "next_attempt_at"])
|
||||
return event
|
||||
|
||||
|
||||
def release_stale_processing() -> int:
|
||||
"""Вернуть в очередь события, взятые в работу и не доведённые до конца.
|
||||
|
||||
Процесс мог упасть или его перезапустили между `claim` и записью
|
||||
результата. Без возврата такое событие остаётся `PROCESSING` навсегда — а
|
||||
вместе с ним встаёт и весь агрегат, потому что следующие события того же
|
||||
объекта ждут его.
|
||||
"""
|
||||
return (
|
||||
OutboxEvent.objects.using(OUTBOX_DB)
|
||||
.filter(status=OutboxStatus.PROCESSING, next_attempt_at__lte=timezone.now())
|
||||
.update(status=OutboxStatus.PENDING)
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Очередь событий: порядок внутри агрегата и возврат зависших."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.events.models import EventOwnership, OutboxEvent, OutboxStatus
|
||||
from chatballs.events.services import claim_next_outbox_event, release_stale_processing
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantActorKind
|
||||
|
||||
|
||||
class OutboxClaimTests(TestCase):
|
||||
"""Захват событий.
|
||||
|
||||
В проде outbox читает роль platform, в тестах отдельного соединения под неё
|
||||
нет — все алиасы смотрят в одну базу. Подменяется только алиас: сами
|
||||
запросы те же, что в проде.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
patch = mock.patch("chatballs.events.services.OUTBOX_DB", "default")
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
|
||||
def _event(self, aggregate_id: str, event_type: str = "conversation.ai_turn_requested"):
|
||||
return OutboxEvent.objects.create(
|
||||
aggregate_type="Conversation",
|
||||
aggregate_id=aggregate_id,
|
||||
event_type=event_type,
|
||||
payload={},
|
||||
ownership=EventOwnership.TENANT,
|
||||
organization=self.organization,
|
||||
actor_kind=TenantActorKind.MACHINE,
|
||||
)
|
||||
|
||||
def test_second_event_of_the_same_aggregate_waits(self) -> None:
|
||||
# Два ответа одному диалогу не считаются параллельно: иначе они
|
||||
# приезжают клиенту вперемешку.
|
||||
self._event("7")
|
||||
self._event("7")
|
||||
|
||||
first = claim_next_outbox_event()
|
||||
self.assertIsNotNone(first)
|
||||
self.assertIsNone(claim_next_outbox_event())
|
||||
|
||||
def test_other_aggregates_are_not_blocked(self) -> None:
|
||||
self._event("7")
|
||||
self._event("8")
|
||||
|
||||
self.assertIsNotNone(claim_next_outbox_event())
|
||||
self.assertIsNotNone(claim_next_outbox_event())
|
||||
|
||||
def test_stale_processing_returns_to_the_queue(self) -> None:
|
||||
# Процесс упал между взятием события и записью результата: без
|
||||
# возврата оно осталось бы в работе навсегда, а вместе с ним встал бы
|
||||
# весь диалог.
|
||||
event = self._event("7")
|
||||
claimed = claim_next_outbox_event()
|
||||
self.assertEqual(claimed.id, event.id)
|
||||
|
||||
self.assertEqual(release_stale_processing(), 0)
|
||||
|
||||
OutboxEvent.objects.filter(id=event.id).update(
|
||||
next_attempt_at=timezone.now() - timedelta(seconds=1)
|
||||
)
|
||||
self.assertEqual(release_stale_processing(), 1)
|
||||
event.refresh_from_db()
|
||||
self.assertEqual(event.status, OutboxStatus.PENDING)
|
||||
self.assertIsNotNone(claim_next_outbox_event())
|
||||
@@ -58,6 +58,7 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.system.assigned_to": "Conversation assigned to {operator}",
|
||||
"conversations.system.assignment_expired": "{operator} did not pick the conversation up — it is back in the queue",
|
||||
"conversations.system.ai_handed_over": "AI handed the conversation to an operator",
|
||||
"conversations.ai_unavailable_reply": "Sorry, I cannot answer right now. I have passed your question to a specialist — they will join shortly.",
|
||||
"conversations.system.ai_unavailable": "AI is unavailable — the conversation was handed to an operator",
|
||||
"conversations.system.call_accepted": "The customer accepted the call invitation",
|
||||
"conversations.system.call_cancelled": "The operator cancelled the call invitation",
|
||||
@@ -146,6 +147,7 @@ MESSAGES: dict[str, object] = {
|
||||
"settings.http_or_https": "http or https",
|
||||
"settings.integration_not_found": "Integration not found",
|
||||
"settings.invalid_address": "Invalid address",
|
||||
"settings.invalid_port": "Port must be a number from 1 to 65535",
|
||||
"settings.items_list_expected": "A list of items is expected",
|
||||
"settings.language_unsupported": "This language is not supported",
|
||||
"settings.migration_running": "A migration is already running",
|
||||
@@ -357,6 +359,7 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.role_conflict": "User already has a different role in this organization",
|
||||
"identity.token_required": "token is required",
|
||||
"identity.totp_challenge_inactive": "TOTP challenge is not active",
|
||||
"notifications.conversation_id_number": "conversationId must be a number",
|
||||
"notifications.ids_list_or_all": "ids must be a list or use all=true",
|
||||
"notifications.push_types_list": "pushTypes must be a list",
|
||||
"notifications.enabled_bool": "enabled must be true or false",
|
||||
@@ -373,8 +376,10 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.activity_started": "Conversation · {channel}",
|
||||
"conversations.field_too_long": "Field {field}: no longer than {limit} characters",
|
||||
"ai.provider_no_transcription": "The {provider} provider does not support audio transcription",
|
||||
"ai.transcription_failed": "Transcription failed: {error}",
|
||||
"ai.transcription_failed_http": "Transcription failed: HTTP {code} {detail}",
|
||||
"ai.transcription_denied": "The provider refused the transcription request: check the key and the access to the transcription model in the integration settings",
|
||||
"ai.transcription_failed": "The provider could not transcribe the recording. Try again",
|
||||
"ai.transcription_unreachable": "The transcription provider is unavailable. Try again",
|
||||
"ai.transcription_unsupported": "This provider cannot transcribe speech. Pick another integration for transcription on the agent card",
|
||||
"api.expected_integer": "{name}: an integer is expected",
|
||||
"api.expected_positive": "{name}: a number greater than zero is expected",
|
||||
"api.expected_record_id": "{name}: a record identifier is expected",
|
||||
@@ -472,6 +477,10 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.file_download_unsupported": "File download is not supported for this provider",
|
||||
"conversations.telegram_getfile_failed": "Telegram getFile failed",
|
||||
"conversations.voice_download_unsupported": "Voice download is not supported for this provider",
|
||||
"conversations.attachment_unsupported": "An attachment this channel cannot show",
|
||||
"conversations.vk_no_group": "Could not identify the VK community — check the connection",
|
||||
"conversations.vk_longpoll_unavailable": "VK did not return an address for receiving messages",
|
||||
"conversations.vk_longpoll_failed": "VK interrupted message delivery (code {code})",
|
||||
"portals.article_object_required": "Article must be an object",
|
||||
"portals.locale_string": "Locale must be a string",
|
||||
"portals.summary_string": "Summary must be a string",
|
||||
@@ -501,6 +510,9 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.type_dialog_assigned": "A conversation was assigned to me",
|
||||
"notifications.type_dialog_waiting_long": "A conversation has been waiting a long time",
|
||||
"notifications.type_ai_stopped": "AI stopped by an error",
|
||||
"integrations.check_rejected": "The provider refused the check ({status})",
|
||||
"integrations.check_rejected_access": "The provider refused the check ({status}): check the key and whether the provider is reachable from your network — region, proxy",
|
||||
"integrations.check_rejected_reason": "The provider refused the check ({status}): {reason}",
|
||||
"integrations.check_no_connection": "No connection: {error}",
|
||||
"integrations.check_api_key_missing": "The API key is not set",
|
||||
"integrations.check_base_url_missing": "Base URL is not set",
|
||||
@@ -511,6 +523,11 @@ MESSAGES: dict[str, object] = {
|
||||
"integrations.check_endpoint_answered": "The endpoint answered {status}",
|
||||
"integrations.check_key_accepted": "the key was accepted",
|
||||
"integrations.check_bot_connected": "the bot is connected",
|
||||
"integrations.check_vk_rejected": "VK rejected the request ({code}): {reason}",
|
||||
"integrations.check_vk_scopes": "The access key lacks the required permissions: issue it with the community messages and community management scopes",
|
||||
"integrations.check_vk_no_group": "The key does not belong to a community: a community access key is required, not a personal one",
|
||||
"integrations.check_vk_longpoll_off": "Event delivery is off in the community: turn on the Long Poll API in the community management",
|
||||
"integrations.check_vk_message_event_off": "The incoming message event is not enabled in the Long Poll API settings",
|
||||
"integrations.check_demo": "Demo provider: answers from the agent's knowledge, with no external calls or keys",
|
||||
"integrations.check_unsupported": "Checking is not supported for this connection type",
|
||||
"integrations.check_web_not_bound": "The connection is not bound to a channel — the widget is inactive",
|
||||
@@ -565,4 +582,5 @@ MESSAGES: dict[str, object] = {
|
||||
"ai.demo_handover": "I'll check this with my colleagues and hand the dialog to a member of staff — they will answer during working hours.",
|
||||
"ai.demo_handover_suffix": " I'm handing the dialog to a member of staff — they will help from here.",
|
||||
"ai.unknown_answer_language": "Unknown agent answer language",
|
||||
"ai.history_limit_out_of_range": "Context size must be a whole number from 1 to {max}",
|
||||
}
|
||||
@@ -62,6 +62,7 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.system.ai_handed_over": "AI передал диалог оператору",
|
||||
"conversations.system.assigned_to": "Диалог назначен на {operator}",
|
||||
"conversations.system.assignment_expired": "{operator} не взял диалог — он вернулся в очередь",
|
||||
"conversations.ai_unavailable_reply": "Извините, прямо сейчас не получается ответить. Я передал ваш вопрос специалисту — он скоро подключится.",
|
||||
"conversations.system.ai_unavailable": "AI недоступен — диалог передан оператору",
|
||||
"conversations.system.call_accepted": "Клиент принял приглашение на звонок",
|
||||
"conversations.system.call_cancelled": "Сотрудник отменил приглашение на звонок",
|
||||
@@ -150,6 +151,7 @@ MESSAGES: dict[str, object] = {
|
||||
"settings.http_or_https": "http или https",
|
||||
"settings.integration_not_found": "Интеграция не найдена",
|
||||
"settings.invalid_address": "Некорректный адрес",
|
||||
"settings.invalid_port": "Порт — число от 1 до 65535",
|
||||
"settings.items_list_expected": "Ожидается список items",
|
||||
"settings.language_unsupported": "Этот язык не поддерживается",
|
||||
"settings.migration_running": "Перенос уже идёт",
|
||||
@@ -361,6 +363,7 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
|
||||
"identity.token_required": "Нужен токен",
|
||||
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
|
||||
"notifications.conversation_id_number": "conversationId должен быть числом",
|
||||
"notifications.ids_list_or_all": "ids — список, либо all=true",
|
||||
"notifications.push_types_list": "pushTypes — список",
|
||||
"notifications.enabled_bool": "enabled — да или нет",
|
||||
@@ -377,8 +380,10 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.activity_started": "Диалог · {channel}",
|
||||
"conversations.field_too_long": "Поле {field}: не длиннее {limit} символов",
|
||||
"ai.provider_no_transcription": "Провайдер {provider} не поддерживает расшифровку аудио",
|
||||
"ai.transcription_failed": "Расшифровка не удалась: {error}",
|
||||
"ai.transcription_failed_http": "Расшифровка не удалась: HTTP {code} {detail}",
|
||||
"ai.transcription_denied": "Провайдер не принял запрос на расшифровку: проверьте ключ и доступ к модели расшифровки в настройках интеграции",
|
||||
"ai.transcription_failed": "Провайдер не смог расшифровать запись. Попробуйте ещё раз",
|
||||
"ai.transcription_unreachable": "Провайдер расшифровки недоступен. Попробуйте ещё раз",
|
||||
"ai.transcription_unsupported": "Этот провайдер не умеет расшифровывать речь. Выберите на карточке агента другую интеграцию для расшифровки",
|
||||
"api.expected_integer": "{name}: ожидается целое число",
|
||||
"api.expected_positive": "{name}: ожидается число больше нуля",
|
||||
"api.expected_record_id": "{name}: ожидается идентификатор записи",
|
||||
@@ -476,6 +481,10 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.file_download_unsupported": "Этот канал не поддерживает скачивание файлов",
|
||||
"conversations.telegram_getfile_failed": "Telegram не отдал файл",
|
||||
"conversations.voice_download_unsupported": "Этот канал не поддерживает скачивание голосовых",
|
||||
"conversations.attachment_unsupported": "Вложение, которое канал не может показать",
|
||||
"conversations.vk_no_group": "Не удалось определить сообщество ВКонтакте — проверьте подключение",
|
||||
"conversations.vk_longpoll_unavailable": "ВКонтакте не выдал адрес для приёма сообщений",
|
||||
"conversations.vk_longpoll_failed": "ВКонтакте прервал приём сообщений (код {code})",
|
||||
"portals.article_object_required": "Статья должна быть объектом",
|
||||
"portals.locale_string": "Поле «locale» — строка",
|
||||
"portals.summary_string": "Поле «summary» — строка",
|
||||
@@ -505,6 +514,9 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.type_dialog_assigned": "Диалог назначили на меня",
|
||||
"notifications.type_dialog_waiting_long": "Диалог долго ждёт человека",
|
||||
"notifications.type_ai_stopped": "AI остановлен ошибкой",
|
||||
"integrations.check_rejected": "Провайдер отклонил проверку ({status})",
|
||||
"integrations.check_rejected_access": "Провайдер отклонил проверку ({status}): проверьте ключ и доступность провайдера из вашей сети — регион, прокси",
|
||||
"integrations.check_rejected_reason": "Провайдер отклонил проверку ({status}): {reason}",
|
||||
"integrations.check_no_connection": "Нет связи: {error}",
|
||||
"integrations.check_api_key_missing": "Не указан API-ключ",
|
||||
"integrations.check_base_url_missing": "Не указан Base URL",
|
||||
@@ -515,6 +527,11 @@ MESSAGES: dict[str, object] = {
|
||||
"integrations.check_endpoint_answered": "Эндпоинт ответил {status}",
|
||||
"integrations.check_key_accepted": "ключ принят",
|
||||
"integrations.check_bot_connected": "бот подключён",
|
||||
"integrations.check_vk_rejected": "ВКонтакте отклонил запрос ({code}): {reason}",
|
||||
"integrations.check_vk_scopes": "У ключа доступа нет нужных прав: выпустите его с правами «Сообщения сообщества» и «Управление сообществом»",
|
||||
"integrations.check_vk_no_group": "Ключ не принадлежит сообществу: нужен ключ доступа сообщества, а не пользователя",
|
||||
"integrations.check_vk_longpoll_off": "В сообществе выключен приём событий: включите Long Poll API в управлении сообществом",
|
||||
"integrations.check_vk_message_event_off": "В настройках Long Poll API не включено событие «Входящее сообщение»",
|
||||
"integrations.check_demo": "Демо-провайдер: отвечает по знаниям агента, без внешних запросов и ключей",
|
||||
"integrations.check_unsupported": "Проверка для этого типа подключения не поддерживается",
|
||||
"integrations.check_web_not_bound": "Подключение не привязано к каналу — виджет не активен",
|
||||
@@ -569,4 +586,5 @@ MESSAGES: dict[str, object] = {
|
||||
"ai.demo_handover": "Уточню этот вопрос у коллег и передам диалог сотруднику — он ответит в рабочее время.",
|
||||
"ai.demo_handover_suffix": " Передаю диалог сотруднику — он поможет дальше.",
|
||||
"ai.unknown_answer_language": "Неизвестный язык ответов агента",
|
||||
"ai.history_limit_out_of_range": "Размер контекста — целое число от 1 до {max}",
|
||||
}
|
||||
@@ -22,8 +22,14 @@ from chatballs.support_portals.addressing import normalize_domain
|
||||
class InstanceSettings(models.Model):
|
||||
SINGLETON_PK = 1
|
||||
|
||||
# Хост без схемы и порта: «crm.example.com» или «203.0.113.10».
|
||||
# Хост без схемы и порта: «crm.example.com» или «203.0.113.10». Именно
|
||||
# хост, а не адрес: по нему проверяются входящие Host, строятся домены
|
||||
# порталов и адреса TURN — порт им чужой.
|
||||
public_host = models.CharField(max_length=253, blank=True, default="")
|
||||
# Порт, если установку открывают не на стандартном для схемы: шлюз
|
||||
# опубликован как 8081, а 80-й занят панелью или другим сервисом. Пусто —
|
||||
# порт схемы (80/443), и в ссылках его нет.
|
||||
public_port = models.PositiveIntegerField(null=True, blank=True)
|
||||
# Предыдущий адрес: остаётся принятым, чтобы смена адреса не выбрасывала
|
||||
# того, кто её делает. Владелец меняет адрес заранее — до того, как домен
|
||||
# начал резолвиться и получил сертификат, — и сидит при этом на старом.
|
||||
@@ -179,18 +185,49 @@ def remember_default_language(language: str) -> None:
|
||||
row.save(update_fields=["default_language", "updated_at"])
|
||||
|
||||
|
||||
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
|
||||
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан."""
|
||||
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
||||
|
||||
host = normalize_domain(raw_host.partition(":")[0])
|
||||
if not host:
|
||||
|
||||
def split_address(raw: str, scheme: str) -> tuple[str, int | None] | None:
|
||||
"""Хост и порт из «host[:port]»; None — адрес не разобрать.
|
||||
|
||||
Порт схемы (80 у http, 443 у https) отбрасывается: в ссылке он лишний, а
|
||||
хранить его значило бы различать два одинаковых адреса.
|
||||
"""
|
||||
|
||||
host, separator, port_text = raw.strip().rpartition(":")
|
||||
if not separator:
|
||||
return normalize_domain(port_text), None
|
||||
host = normalize_domain(host)
|
||||
if not port_text.isdigit() or not 1 <= int(port_text) <= 65535:
|
||||
return None
|
||||
port = int(port_text)
|
||||
return host, None if port == _DEFAULT_PORTS.get(scheme) else port
|
||||
|
||||
|
||||
def format_address(host: str, port: int | None) -> str:
|
||||
"""Адрес для ссылок и для поля в «Настройках»: хост и порт, если он не схемы."""
|
||||
|
||||
return f"{host}:{port}" if host and port else host
|
||||
|
||||
|
||||
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
|
||||
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан.
|
||||
|
||||
Порт запоминается вместе с хостом: установку, открытую на ``ip:8081``,
|
||||
дальше открывают там же, и ссылки без порта вели бы в пустоту.
|
||||
"""
|
||||
|
||||
scheme = "https" if scheme == "https" else "http"
|
||||
parsed = split_address(raw_host, scheme)
|
||||
if parsed is None or not parsed[0]:
|
||||
return
|
||||
row = InstanceSettings.load()
|
||||
if row.public_host:
|
||||
return
|
||||
row.public_host = host
|
||||
row.public_scheme = "https" if scheme == "https" else "http"
|
||||
row.save(update_fields=["public_host", "public_scheme", "updated_at"])
|
||||
row.public_host, row.public_port = parsed
|
||||
row.public_scheme = scheme
|
||||
row.save(update_fields=["public_host", "public_port", "public_scheme", "updated_at"])
|
||||
|
||||
|
||||
def public_base_url() -> str:
|
||||
@@ -215,7 +252,7 @@ def public_base_url() -> str:
|
||||
row = None
|
||||
if row is not None and row.public_host:
|
||||
scheme = row.public_scheme or "http"
|
||||
return f"{scheme}://{row.public_host}"
|
||||
return f"{scheme}://{format_address(row.public_host, row.public_port)}"
|
||||
return str(settings.CHATBALLS_PUBLIC_BASE_URL).rstrip("/")
|
||||
|
||||
|
||||
|
||||
@@ -20,17 +20,20 @@ from chatballs.identity.instance_settings import (
|
||||
default_turn_urls,
|
||||
email_connection,
|
||||
email_from_address,
|
||||
format_address,
|
||||
invalidate_cache,
|
||||
public_base_url,
|
||||
split_address,
|
||||
)
|
||||
from chatballs.support_portals.addressing import normalize_domain, validate_domain
|
||||
from chatballs.support_portals.addressing import validate_domain
|
||||
|
||||
SCHEMES = ("http", "https")
|
||||
|
||||
|
||||
def instance_payload(row: InstanceSettings) -> dict:
|
||||
return {
|
||||
"publicHost": row.public_host,
|
||||
# Адрес целиком, с портом, если он не схемы: так его и вписывают.
|
||||
"publicHost": format_address(row.public_host, row.public_port),
|
||||
"publicScheme": row.public_scheme or "http",
|
||||
"publicUrl": public_base_url(),
|
||||
# Язык экранов, где организации ещё нет: логин, сброс пароля, мастер.
|
||||
@@ -77,12 +80,22 @@ class InstanceAddressView(APIView):
|
||||
body = request.data if isinstance(request.data, dict) else {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
# Владелец может вставить и целый URL из адресной строки — берём хост.
|
||||
raw_host = str(body.get("publicHost", row.public_host)).strip()
|
||||
scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower()
|
||||
if scheme not in SCHEMES:
|
||||
errors["publicScheme"] = t("settings.http_or_https")
|
||||
|
||||
# Владелец может вставить и целый URL из адресной строки — берём хост
|
||||
# и порт, путь отбрасываем.
|
||||
raw_host = str(
|
||||
body.get("publicHost", format_address(row.public_host, row.public_port))
|
||||
).strip()
|
||||
if "//" in raw_host:
|
||||
raw_host = raw_host.split("//", 1)[1]
|
||||
host = normalize_domain(raw_host.split("/", 1)[0].split(":", 1)[0])
|
||||
if not host:
|
||||
parsed = split_address(raw_host.split("/", 1)[0], scheme)
|
||||
host, port = parsed if parsed is not None else ("", None)
|
||||
if parsed is None:
|
||||
errors["publicHost"] = t("settings.invalid_port")
|
||||
elif not host:
|
||||
errors["publicHost"] = t("settings.address_required")
|
||||
else:
|
||||
try:
|
||||
@@ -92,10 +105,6 @@ class InstanceAddressView(APIView):
|
||||
except ValidationError:
|
||||
errors["publicHost"] = t("settings.invalid_address")
|
||||
|
||||
scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower()
|
||||
if scheme not in SCHEMES:
|
||||
errors["publicScheme"] = t("settings.http_or_https")
|
||||
|
||||
raw_language = str(body.get("defaultLanguage", row.default_language)).strip()
|
||||
language = normalize_language(raw_language)
|
||||
if raw_language and not language:
|
||||
@@ -108,6 +117,7 @@ class InstanceAddressView(APIView):
|
||||
|
||||
fields = [
|
||||
"public_host",
|
||||
"public_port",
|
||||
"public_scheme",
|
||||
"previous_public_host",
|
||||
"default_language",
|
||||
@@ -118,6 +128,7 @@ class InstanceAddressView(APIView):
|
||||
# сидя на старом, и не должен выпасть из установки в тот же миг.
|
||||
row.previous_public_host = row.public_host
|
||||
row.public_host = host
|
||||
row.public_port = port
|
||||
row.public_scheme = scheme
|
||||
row.default_language = language or DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("identity", "0039_remove_organization_currency"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="instancesettings",
|
||||
name="public_port",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -88,6 +88,33 @@ class InstanceAddressChangeTests(TestCase):
|
||||
|
||||
self.assertEqual(set(accepted_hosts()), {"crm.example.test", "203.0.113.10"})
|
||||
|
||||
def test_port_is_kept_apart_from_the_host(self) -> None:
|
||||
"""Шлюз на 8081: порт идёт в ссылки, но не в хост — по хосту
|
||||
проверяются входящие Host и строятся домены порталов."""
|
||||
response = self._patch("http://203.0.113.10:8081/login", scheme="http")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
payload = response.json()["instance"]
|
||||
self.assertEqual(payload["publicHost"], "203.0.113.10:8081")
|
||||
self.assertEqual(payload["publicUrl"], "http://203.0.113.10:8081")
|
||||
row = InstanceSettings.load()
|
||||
self.assertEqual((row.public_host, row.public_port), ("203.0.113.10", 8081))
|
||||
# Смена одного порта — не смена адреса: прежний хост не сдвигается.
|
||||
self.assertEqual(row.previous_public_host, "")
|
||||
|
||||
def test_scheme_port_is_dropped(self) -> None:
|
||||
self._patch("crm.example.test:8081")
|
||||
response = self._patch("crm.example.test:443")
|
||||
|
||||
self.assertEqual(response.json()["instance"]["publicUrl"], "https://crm.example.test")
|
||||
self.assertIsNone(InstanceSettings.load().public_port)
|
||||
|
||||
def test_bad_port_is_rejected(self) -> None:
|
||||
for address in ("203.0.113.10:0", "203.0.113.10:70000", "203.0.113.10:web", "203.0.113.10:"):
|
||||
with self.subTest(address=address):
|
||||
self.assertEqual(self._patch(address, scheme="http").status_code, 400)
|
||||
self.assertEqual(InstanceSettings.load().public_host, "203.0.113.10")
|
||||
|
||||
|
||||
class PortalDomainCollisionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
|
||||
@@ -54,6 +54,25 @@ class SetupWizardTests(TestCase):
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
self.assertEqual(InstanceSettings.load().public_host, "203.0.113.10")
|
||||
|
||||
def test_setup_on_a_custom_port_keeps_the_port_for_links(self) -> None:
|
||||
"""Шлюз опубликован на 8081: ссылки без порта вели бы в пустоту."""
|
||||
invalidate_cache()
|
||||
|
||||
response = self.complete(HTTP_HOST="203.0.113.10:8081")
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
row = InstanceSettings.load()
|
||||
self.assertEqual((row.public_host, row.public_port), ("203.0.113.10", 8081))
|
||||
invalidate_cache()
|
||||
self.assertEqual(public_base_url(), "http://203.0.113.10:8081")
|
||||
|
||||
def test_setup_on_the_scheme_port_stores_no_port(self) -> None:
|
||||
invalidate_cache()
|
||||
|
||||
self.complete(HTTP_HOST="crm.example.test:80")
|
||||
|
||||
self.assertIsNone(InstanceSettings.load().public_port)
|
||||
|
||||
def test_remembered_address_keeps_working_after_setup(self) -> None:
|
||||
self.complete(HTTP_HOST="crm.example.test")
|
||||
invalidate_cache()
|
||||
|
||||
@@ -7,6 +7,9 @@ identity method are not interchangeable:
|
||||
- Telegram: GET {base}/bot<token>/getMe (token in the path).
|
||||
- MAX: GET {base}/me, header `Authorization: <token>` (raw token; the
|
||||
query-param access_token is no longer supported).
|
||||
- VK: GET {base}/groups.getById + groups.getLongPollSettings, token in
|
||||
the query string (VK has no auth header); errors come back with
|
||||
HTTP 200 and an `error` body.
|
||||
|
||||
Each check returns (ok, detail, meta) and never raises; meta may carry
|
||||
{"bot_username": ...} parsed from the provider's identity response.
|
||||
@@ -16,20 +19,32 @@ from __future__ import annotations
|
||||
|
||||
import imaplib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import smtplib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.i18n import t, tn
|
||||
from chatballs.integrations.outbound import mask_url_secrets
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
# platform-api2.max.ru отдаёт неполную цепочку сертификата (verify failed);
|
||||
# рабочий и с валидным сертификатом — platform-api.max.ru.
|
||||
DEFAULT_MAX_BASE_URL = "https://platform-api.max.ru"
|
||||
DEFAULT_TELEGRAM_BASE_URL = "https://api.telegram.org"
|
||||
DEFAULT_VK_BASE_URL = "https://api.vk.com/method"
|
||||
# Версия API ВКонтакте: параметр обязателен в каждом запросе.
|
||||
VK_API_VERSION = "5.199"
|
||||
# Метод недоступен ключу с такими правами: Bots Long Poll требует прав
|
||||
# «Сообщения сообщества» и «Управление сообществом».
|
||||
VK_ACCESS_DENIED = 15
|
||||
|
||||
CheckResult = tuple[bool, str, dict]
|
||||
|
||||
@@ -46,13 +61,80 @@ def _get(url: str, *, headers: dict[str, str] | None = None, proxy_url: str = ""
|
||||
return response.status, data
|
||||
|
||||
|
||||
def _post_form(url: str, body: str, *, proxy_url: str = "") -> tuple[int, dict]:
|
||||
"""POST application/x-www-form-urlencoded — форма, которую ждёт ВКонтакте."""
|
||||
opener = build_opener(proxy_url)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body.encode("utf-8"),
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with opener.open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
|
||||
payload = response.read().decode("utf-8")
|
||||
try:
|
||||
data = json.loads(payload) if payload else {}
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
return response.status, data
|
||||
|
||||
|
||||
def _error_reason(body: str) -> str:
|
||||
"""Короткая причина из ответа провайдера.
|
||||
|
||||
Ответ бывает и JSON'ом провайдера, и HTML-страницей защиты перед ним —
|
||||
человеку нужна одна фраза, а не то и другое целиком.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
return str(error.get("message") or "")[:160]
|
||||
for key in ("message", "detail", "error_description"):
|
||||
if payload.get(key):
|
||||
return str(payload[key])[:160]
|
||||
if isinstance(error, str):
|
||||
return error[:160]
|
||||
text = re.sub(r"<[^>]+>", " ", body)
|
||||
text = " ".join(text.split())
|
||||
return text[:160]
|
||||
|
||||
|
||||
def _http_failure(error: urllib.error.HTTPError) -> str:
|
||||
"""Отказ провайдера словами, а не кодом.
|
||||
|
||||
Голый «HTTP 403» не говорит ничего: так отвечают и на чужой ключ, и на
|
||||
запрос из закрытого региона, и на блокировку самого прокси. Причину, если
|
||||
провайдер её назвал, показываем сразу; ответ целиком уходит в журнал.
|
||||
"""
|
||||
try:
|
||||
body = error.read().decode("utf-8", "replace")
|
||||
except (OSError, ValueError):
|
||||
body = ""
|
||||
logger.warning(
|
||||
"Integration check rejected: HTTP %s %s — %s",
|
||||
error.code,
|
||||
mask_url_secrets(getattr(error, "url", "")),
|
||||
body[:500],
|
||||
)
|
||||
reason = _error_reason(body)
|
||||
if reason:
|
||||
return t("integrations.check_rejected_reason", status=error.code, reason=reason)
|
||||
if error.code in (401, 403):
|
||||
return t("integrations.check_rejected_access", status=error.code)
|
||||
return t("integrations.check_rejected", status=error.code)
|
||||
|
||||
|
||||
def _safe(fn) -> CheckResult:
|
||||
try:
|
||||
return fn()
|
||||
except urllib.error.HTTPError as error:
|
||||
return False, f"HTTP {error.code}: {error.reason}", {}
|
||||
return False, _http_failure(error), {}
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||
return False, t("integrations.check_no_connection", error=error), {}
|
||||
return False, t("integrations.check_no_connection", error=mask_url_secrets(error)), {}
|
||||
|
||||
|
||||
def check_openrouter(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult:
|
||||
@@ -189,6 +271,102 @@ def check_telegram(*, secret: str, base_url: str, proxy_url: str = "") -> CheckR
|
||||
return _safe(run)
|
||||
|
||||
|
||||
class VkRejected(Exception):
|
||||
"""ВКонтакте отклонил запрос: текст уже пригоден для показа человеку."""
|
||||
|
||||
def __init__(self, message: str, code: int = 0) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def vk_call(*, base_url: str, method: str, secret: str, params: dict | None = None, proxy_url: str = "", post: bool = False):
|
||||
"""Вызов метода API ВКонтакте; возвращает содержимое поля response.
|
||||
|
||||
Токен уходит параметром запроса — заголовка авторизации у ВКонтакте нет.
|
||||
Поэтому адрес нигде не печатается как есть: и журнал, и статус подключения
|
||||
получают его через ``mask_url_secrets``.
|
||||
|
||||
Ошибку ВКонтакте отдаёт кодом 200 и телом ``error``, так что проверять
|
||||
статус недостаточно: отозванный токен выглядел бы успешной проверкой.
|
||||
"""
|
||||
base = (base_url or DEFAULT_VK_BASE_URL).rstrip("/")
|
||||
query = urlencode({**(params or {}), "access_token": secret, "v": VK_API_VERSION})
|
||||
if post:
|
||||
status, data = _post_form(f"{base}/{method}", query, proxy_url=proxy_url)
|
||||
else:
|
||||
status, data = _get(f"{base}/{method}?{query}", proxy_url=proxy_url)
|
||||
error = data.get("error")
|
||||
if isinstance(error, dict):
|
||||
raise VkRejected(
|
||||
t(
|
||||
"integrations.check_vk_rejected",
|
||||
code=error.get("error_code", ""),
|
||||
reason=str(error.get("error_msg") or "")[:160],
|
||||
),
|
||||
code=int(error.get("error_code") or 0),
|
||||
)
|
||||
if status != 200:
|
||||
raise VkRejected(t("integrations.check_provider_answered", provider="ВКонтакте", status=status))
|
||||
return data.get("response")
|
||||
|
||||
|
||||
def vk_group(response) -> dict:
|
||||
"""Сообщество из ответа groups.getById.
|
||||
|
||||
Форма ответа зависит от версии API: до 5.199 это список, дальше объект с
|
||||
полем groups. Подключение переживает обе.
|
||||
"""
|
||||
items = response.get("groups") if isinstance(response, dict) else response
|
||||
first_group = (items or [None])[0] if isinstance(items, list) else None
|
||||
return first_group if isinstance(first_group, dict) else {}
|
||||
|
||||
|
||||
def check_vk(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult:
|
||||
"""Сообщество ВКонтакте: кто мы и включён ли приём сообщений.
|
||||
|
||||
Ключ доступа сообщества сам называет сообщество, поэтому его идентификатор
|
||||
владельцу вводить не нужно — как имя бота у Telegram и MAX, он попадает в
|
||||
конфигурацию результатом проверки.
|
||||
|
||||
Выключенный Long Poll — это ошибка подключения: принимать сообщения в таком
|
||||
состоянии невозможно. Включаем не мы: настройки чужого сообщества меняет
|
||||
его владелец.
|
||||
"""
|
||||
if not secret:
|
||||
return False, t("integrations.check_bot_token_missing"), {}
|
||||
|
||||
def run() -> CheckResult:
|
||||
try:
|
||||
group = vk_group(vk_call(base_url=base_url, method="groups.getById", secret=secret, proxy_url=proxy_url))
|
||||
if not group.get("id"):
|
||||
return False, t("integrations.check_vk_no_group"), {}
|
||||
group_id = str(group["id"])
|
||||
long_poll = vk_call(
|
||||
base_url=base_url,
|
||||
method="groups.getLongPollSettings",
|
||||
secret=secret,
|
||||
params={"group_id": group_id},
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
except VkRejected as error:
|
||||
# 15 — метод недоступен ключу с такими правами. Без подсказки
|
||||
# владелец видел бы английское «no access» и не знал, что чинить.
|
||||
if error.code == VK_ACCESS_DENIED:
|
||||
return False, t("integrations.check_vk_scopes"), {}
|
||||
return False, str(error), {}
|
||||
name = str(group.get("name") or "")
|
||||
screen_name = str(group.get("screen_name") or "")
|
||||
meta = {"bot_id": group_id, "bot_username": screen_name, "bot_name": name or screen_name}
|
||||
settings_payload = long_poll if isinstance(long_poll, dict) else {}
|
||||
if not settings_payload.get("is_enabled"):
|
||||
return False, t("integrations.check_vk_longpoll_off"), meta
|
||||
if not (settings_payload.get("events") or {}).get("message_new"):
|
||||
return False, t("integrations.check_vk_message_event_off"), meta
|
||||
return True, f"ВКонтакте: {name or screen_name}", meta
|
||||
|
||||
return _safe(run)
|
||||
|
||||
|
||||
def check_demo(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult:
|
||||
"""Демо-провайдер не ходит в сеть — всегда готов."""
|
||||
return True, t("integrations.check_demo"), {}
|
||||
@@ -1,15 +1,21 @@
|
||||
"""Функции точки входа: что разрешено клиенту и оператору в диалогах через
|
||||
эту интеграцию — голосовые сообщения и онлайн-звонки (аудио/видео).
|
||||
|
||||
Звонки технически возможны там, где есть доставка приглашения: Telegram, MAX
|
||||
(кнопка-ссылка) и Web-виджет (баннер в виджете). Почта звонки не поддерживает.
|
||||
Звонки технически возможны там, где есть доставка приглашения: Telegram, MAX,
|
||||
ВКонтакте (кнопка-ссылка) и Web-виджет (баннер в виджете). Почта звонки не
|
||||
поддерживает.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from chatballs.integrations.models import Integration, IntegrationProvider
|
||||
|
||||
CALL_PROVIDERS = (IntegrationProvider.TELEGRAM, IntegrationProvider.MAX, IntegrationProvider.WEB)
|
||||
CALL_PROVIDERS = (
|
||||
IntegrationProvider.TELEGRAM,
|
||||
IntegrationProvider.MAX,
|
||||
IntegrationProvider.VK,
|
||||
IntegrationProvider.WEB,
|
||||
)
|
||||
|
||||
|
||||
def supports_calls(integration: Integration | None) -> bool:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Подключение-сообщество ВКонтакте: новое значение provider (choices)."""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("integrations", "0008_encrypted_column_width"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="integration",
|
||||
name="provider",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("OPENROUTER", "OpenRouter"),
|
||||
("CUSTOM", "Custom (OpenAI-compatible)"),
|
||||
("DEMO", "Демо-провайдер (без ключа)"),
|
||||
("MAX", "MAX"),
|
||||
("TELEGRAM", "Telegram"),
|
||||
("VK", "ВКонтакте"),
|
||||
("WEB", "Web-виджет"),
|
||||
("EMAIL", "Email (IMAP/SMTP)"),
|
||||
],
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -18,6 +18,7 @@ class IntegrationProvider(models.TextChoices):
|
||||
DEMO = "DEMO", "Демо-провайдер (без ключа)"
|
||||
MAX = "MAX", "MAX"
|
||||
TELEGRAM = "TELEGRAM", "Telegram"
|
||||
VK = "VK", "ВКонтакте"
|
||||
WEB = "WEB", "Web-виджет"
|
||||
EMAIL = "EMAIL", "Email (IMAP/SMTP)"
|
||||
|
||||
@@ -36,6 +37,8 @@ PROVIDER_KIND = {
|
||||
IntegrationProvider.DEMO: IntegrationKind.LLM_PROVIDER,
|
||||
IntegrationProvider.MAX: IntegrationKind.MESSENGER,
|
||||
IntegrationProvider.TELEGRAM: IntegrationKind.MESSENGER,
|
||||
# Сообщество ВКонтакте: приём через Bots Long Poll, отправка messages.send.
|
||||
IntegrationProvider.VK: IntegrationKind.MESSENGER,
|
||||
IntegrationProvider.WEB: IntegrationKind.MESSENGER,
|
||||
# Email-ящик — транспорт диалогов наравне с ботами (ADR-CHATBALLS-0035).
|
||||
IntegrationProvider.EMAIL: IntegrationKind.MESSENGER,
|
||||
|
||||
@@ -19,6 +19,7 @@ OpenAI-совместимый сервер или собственный Bot API
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
@@ -27,6 +28,23 @@ from chatballs.i18n import t
|
||||
HTTP_SCHEMES = frozenset({"http", "https"})
|
||||
PROXY_SCHEMES = frozenset({"http", "https", "socks5", "socks5h"})
|
||||
|
||||
# Параметры запроса, значение которых нельзя показывать ни в журнале, ни в
|
||||
# статусе подключения. У части провайдеров (ВКонтакте) секрет иначе не
|
||||
# передать: заголовка авторизации у них нет, токен уходит строкой запроса.
|
||||
_SECRET_QUERY_RE = re.compile(
|
||||
r"(access_token|api_key|key|password|token)=([^&\s'\"]+)", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def mask_url_secrets(text: object) -> str:
|
||||
"""Строка с адресом без значений секретных параметров запроса.
|
||||
|
||||
Сообщение об ошибке от urllib несёт в себе сам адрес, а адрес — токен.
|
||||
Без этой замены токен сообщества оседал бы в журнале установки и в поле
|
||||
последней ошибки подключения, которое видно в интерфейсе.
|
||||
"""
|
||||
return _SECRET_QUERY_RE.sub(lambda match: f"{match.group(1)}=***", str(text or ""))
|
||||
|
||||
|
||||
class OutboundUrlRejected(ValueError):
|
||||
"""Адрес не разрешён политикой исходящих запросов."""
|
||||
|
||||
@@ -18,6 +18,8 @@ import ssl
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.outbound import OutboundUrlRejected
|
||||
|
||||
@@ -85,6 +87,24 @@ def _blocked_scheme_handlers() -> list[urllib.request.BaseHandler]:
|
||||
return [_RefusedFileHandler(), _RefusedFTPHandler(), _RefusedDataHandler()]
|
||||
|
||||
|
||||
def user_agent() -> str:
|
||||
"""Чем продукт представляется чужим API.
|
||||
|
||||
Умолчание urllib — «Python-urllib/3.x», и защита перед API (Cloudflare)
|
||||
банит такой запрос до того, как его увидит сам провайдер: на бою это
|
||||
выглядело как 403 «error code: 1010» у провайдера, который через тот же
|
||||
прокси прекрасно отвечает браузеру. С обычным именем клиента запрос
|
||||
проходит. Заголовок ставится на opener, поэтому свой User-Agent
|
||||
конкретного запроса он не перебивает.
|
||||
"""
|
||||
return f"Chatballs/{getattr(settings, 'CHATBALLS_VERSION', 'dev')}"
|
||||
|
||||
|
||||
def _named(opener):
|
||||
opener.addheaders = [("User-Agent", user_agent())]
|
||||
return opener
|
||||
|
||||
|
||||
def build_opener(proxy_url: str, *, validate_redirect=None):
|
||||
"""urllib opener, проксирующий http/https/socks5 запросы.
|
||||
|
||||
@@ -96,12 +116,14 @@ def build_opener(proxy_url: str, *, validate_redirect=None):
|
||||
if validate_redirect is not None:
|
||||
blocked.append(_GuardedRedirectHandler(validate_redirect))
|
||||
if not proxy_url:
|
||||
return urllib.request.build_opener(*blocked)
|
||||
return _named(urllib.request.build_opener(*blocked))
|
||||
scheme = urllib.parse.urlparse(proxy_url).scheme.lower()
|
||||
if scheme in SOCKS_SCHEMES:
|
||||
return urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked)
|
||||
return urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
|
||||
return _named(urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked))
|
||||
return _named(
|
||||
urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -236,6 +236,7 @@ _CHECKS = {
|
||||
IntegrationProvider.DEMO: checks.check_demo,
|
||||
IntegrationProvider.MAX: checks.check_max,
|
||||
IntegrationProvider.TELEGRAM: checks.check_telegram,
|
||||
IntegrationProvider.VK: checks.check_vk,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Проверка подключения ВКонтакте: кто мы и включён ли приём сообщений.
|
||||
|
||||
Сеть закрыта подменой HTTP-вызова: проверка обязана разбирать ответ, а не
|
||||
ходить наружу. База здесь не нужна — проверка работает с секретом и адресом.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.error
|
||||
from unittest import mock
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from chatballs.integrations import checks
|
||||
|
||||
GROUP = {"groups": [{"id": 42, "name": "Acme Support", "screen_name": "acme"}]}
|
||||
LONG_POLL_ON = {"is_enabled": True, "events": {"message_new": 1}, "api_version": "5.199"}
|
||||
|
||||
|
||||
def _answers(*responses):
|
||||
"""Ответы VK по порядку вызовов: (status, payload)."""
|
||||
return mock.patch.object(checks, "_get", side_effect=[(200, response) for response in responses])
|
||||
|
||||
|
||||
class VkCheckTests(SimpleTestCase):
|
||||
def test_community_is_recognized_and_remembered(self) -> None:
|
||||
with _answers({"response": GROUP}, {"response": LONG_POLL_ON}) as get:
|
||||
ok, detail, meta = checks.check_vk(secret="community-token", base_url="")
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("Acme Support", detail)
|
||||
self.assertEqual(meta, {"bot_id": "42", "bot_username": "acme", "bot_name": "Acme Support"})
|
||||
# Идентификатор сообщества владелец не вводит: его называет сам токен.
|
||||
self.assertIn("groups.getById", get.call_args_list[0].args[0])
|
||||
self.assertIn("group_id=42", get.call_args_list[1].args[0])
|
||||
|
||||
def test_older_api_shape_is_accepted(self) -> None:
|
||||
with _answers({"response": [{"id": 42, "name": "Acme Support"}]}, {"response": LONG_POLL_ON}):
|
||||
ok, _detail, meta = checks.check_vk(secret="community-token", base_url="")
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(meta["bot_id"], "42")
|
||||
|
||||
def test_missing_token_is_reported_before_any_request(self) -> None:
|
||||
with mock.patch.object(checks, "_get") as get:
|
||||
ok, detail, _meta = checks.check_vk(secret="", base_url="")
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(detail)
|
||||
get.assert_not_called()
|
||||
|
||||
def test_rejected_token_is_an_error_even_with_http_200(self) -> None:
|
||||
# ВКонтакте отвечает на отозванный ключ кодом 200 и телом error.
|
||||
rejection = {"error": {"error_code": 5, "error_msg": "User authorization failed"}}
|
||||
with _answers(rejection):
|
||||
ok, detail, meta = checks.check_vk(secret="revoked", base_url="")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("User authorization failed", detail)
|
||||
self.assertEqual(meta, {})
|
||||
|
||||
def test_key_without_the_long_poll_permissions_says_what_to_fix(self) -> None:
|
||||
# ВКонтакте отвечает на нехватку прав кодом 15 и английским «no access»:
|
||||
# владельцу из него не видно, какое право включить.
|
||||
denied = {"error": {"error_code": 15, "error_msg": "Access denied: no access to call this method."}}
|
||||
with _answers({"response": GROUP}, denied):
|
||||
ok, detail, _meta = checks.check_vk(secret="messages-only", base_url="")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("Управление сообществом", detail)
|
||||
|
||||
def test_personal_token_without_a_community_is_rejected(self) -> None:
|
||||
with _answers({"response": {"groups": []}}):
|
||||
ok, detail, _meta = checks.check_vk(secret="user-token", base_url="")
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(detail)
|
||||
|
||||
def test_disabled_long_poll_is_an_error(self) -> None:
|
||||
with _answers({"response": GROUP}, {"response": {"is_enabled": False, "events": {}}}):
|
||||
ok, detail, meta = checks.check_vk(secret="community-token", base_url="")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("Long Poll", detail)
|
||||
# Сообщество уже опознано — идентификатор пригодится следующей проверке.
|
||||
self.assertEqual(meta["bot_id"], "42")
|
||||
|
||||
def test_long_poll_without_the_message_event_is_an_error(self) -> None:
|
||||
with _answers({"response": GROUP}, {"response": {"is_enabled": True, "events": {"message_new": 0}}}):
|
||||
ok, detail, _meta = checks.check_vk(secret="community-token", base_url="")
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(detail)
|
||||
|
||||
def test_broken_connection_does_not_raise(self) -> None:
|
||||
with mock.patch.object(checks, "_get", side_effect=urllib.error.URLError("no route")):
|
||||
ok, detail, _meta = checks.check_vk(secret="community-token", base_url="")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("no route", detail)
|
||||
|
||||
def test_token_never_leaks_into_the_status(self) -> None:
|
||||
failure = urllib.error.HTTPError(
|
||||
"https://api.vk.com/method/groups.getById?access_token=vk1.a.SECRET&v=5.199",
|
||||
403,
|
||||
"Forbidden",
|
||||
{},
|
||||
None,
|
||||
)
|
||||
with mock.patch.object(checks, "_get", side_effect=failure):
|
||||
_ok, detail, _meta = checks.check_vk(secret="vk1.a.SECRET", base_url="")
|
||||
self.assertNotIn("vk1.a.SECRET", detail)
|
||||
@@ -423,6 +423,81 @@ class CheckProxyTransportTests(TestCase):
|
||||
|
||||
|
||||
|
||||
class OutboundUserAgentTests(TestCase):
|
||||
|
||||
"""Продукт представляется своим именем: «Python-urllib» защита перед чужим
|
||||
API банит до самого API (на бою — Cloudflare «error code: 1010»)."""
|
||||
|
||||
def test_opener_introduces_the_product(self) -> None:
|
||||
from chatballs.integrations.proxy import build_opener, user_agent
|
||||
|
||||
for proxy in ("", "http://proxy:8080"):
|
||||
with self.subTest(proxy=proxy or "без прокси"):
|
||||
agents = dict(build_opener(proxy).addheaders)
|
||||
self.assertEqual(agents["User-Agent"], user_agent())
|
||||
self.assertNotIn("urllib", agents["User-Agent"])
|
||||
|
||||
def test_request_keeps_its_own_agent(self) -> None:
|
||||
import urllib.request
|
||||
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
request = urllib.request.Request(
|
||||
"https://api.example.test/v1/models", headers={"User-Agent": "Mine/1.0"}
|
||||
)
|
||||
opener = build_opener("")
|
||||
# urllib добавляет заголовки opener'а только к тем, которых нет в запросе.
|
||||
self.assertEqual(request.get_header("User-agent"), "Mine/1.0")
|
||||
self.assertTrue(any(name == "User-Agent" for name, _ in opener.addheaders))
|
||||
|
||||
|
||||
class CheckFailureTextTests(TestCase):
|
||||
|
||||
"""Отказ провайдера объясняется словами: голый код ничего не говорит."""
|
||||
|
||||
def _reject(self, code: int, body: bytes):
|
||||
import urllib.error
|
||||
from io import BytesIO
|
||||
|
||||
error = urllib.error.HTTPError(
|
||||
"https://api.example.test/v1/models", code, "Forbidden", {}, BytesIO(body)
|
||||
)
|
||||
return mock.patch(
|
||||
"chatballs.integrations.checks.build_opener",
|
||||
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
|
||||
)
|
||||
|
||||
def test_reason_from_the_provider_reaches_the_screen(self) -> None:
|
||||
body = json.dumps(
|
||||
{"error": {"message": "Your API key is invalid"}}
|
||||
).encode()
|
||||
with self._reject(403, body):
|
||||
ok, detail, _meta = checks.check_custom(
|
||||
secret="sk-test", base_url="https://api.example.test/v1"
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("Your API key is invalid", detail)
|
||||
self.assertIn("403", detail)
|
||||
|
||||
def test_html_block_page_is_squeezed_into_one_line(self) -> None:
|
||||
body = b"<html><head><title>Access denied</title></head><body><h1>Sorry, you have been blocked</h1></body></html>"
|
||||
with self._reject(403, body):
|
||||
ok, detail, _meta = checks.check_custom(
|
||||
secret="sk-test", base_url="https://api.example.test/v1"
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertNotIn("<", detail)
|
||||
self.assertIn("blocked", detail.lower())
|
||||
|
||||
def test_silent_refusal_tells_where_to_look(self) -> None:
|
||||
with self._reject(403, b""):
|
||||
ok, detail, _meta = checks.check_custom(
|
||||
secret="sk-test", base_url="https://api.example.test/v1"
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("регион", detail)
|
||||
|
||||
|
||||
class OpenRouterProviderProxyTests(TestCase):
|
||||
|
||||
def test_provider_routes_through_proxy_handler(self) -> None:
|
||||
|
||||
@@ -12,6 +12,7 @@ from chatballs.notifications.models import (
|
||||
NotificationRead,
|
||||
NotificationType,
|
||||
)
|
||||
from chatballs.notifications.realtime import notify_notifications_changed
|
||||
from chatballs.notifications.selectors import unread_for
|
||||
|
||||
# Реестр типов: дефолтный уровень и маршрут диплинка. Новый тип события —
|
||||
@@ -131,9 +132,32 @@ def notify_management(*, context, dedup_key: str = "", **notification_data) -> i
|
||||
return created
|
||||
|
||||
|
||||
def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False) -> int:
|
||||
# Маршруты, которыми уведомление ссылается на диалог. Кроме нынешнего «chat»
|
||||
# здесь старые значения: они лежат в уже созданных строках, и открытый диалог
|
||||
# обязан гасить и их тоже.
|
||||
CONVERSATION_ROUTES = ("chat", "salesDialogs", "conversations")
|
||||
|
||||
|
||||
def mark_read(
|
||||
*,
|
||||
context,
|
||||
ids: list[int] | None = None,
|
||||
all_unread: bool = False,
|
||||
conversation_id: int | None = None,
|
||||
) -> int:
|
||||
"""Отметить уведомления прочитанными: перечисленные, все или про диалог.
|
||||
|
||||
Про диалог — потому что открытый диалог и есть прочтение: оклик «клиент
|
||||
ждёт» бессмысленно висеть непрочитанным, когда сотрудник уже в переписке.
|
||||
Гасятся все уведомления об этом диалоге, а не только те, что клиент успел
|
||||
загрузить в шторку.
|
||||
"""
|
||||
queryset = unread_for(context)
|
||||
if not all_unread:
|
||||
if conversation_id is not None:
|
||||
queryset = queryset.filter(
|
||||
target_route__in=CONVERSATION_ROUTES, target_id=str(conversation_id)
|
||||
)
|
||||
elif not all_unread:
|
||||
queryset = queryset.filter(id__in=ids or [])
|
||||
rows = [
|
||||
NotificationRead(
|
||||
@@ -144,4 +168,8 @@ def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False
|
||||
for notification in queryset
|
||||
]
|
||||
NotificationRead.objects.bulk_create(rows, ignore_conflicts=True)
|
||||
if rows:
|
||||
# Счётчик непрочитанных живёт в шапке каждой открытой вкладки: без
|
||||
# события они разъезжаются до следующего опроса.
|
||||
notify_notifications_changed([context.actor_user.id])
|
||||
return len(rows)
|
||||
@@ -13,10 +13,11 @@ from chatballs.notifications.delivery import NOTIFICATION_CREATED
|
||||
from chatballs.notifications.models import (
|
||||
MessengerBinding,
|
||||
MessengerBindingCode,
|
||||
Notification,
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
)
|
||||
from chatballs.notifications.selectors import visible_for
|
||||
from chatballs.notifications.selectors import unread_for, visible_for
|
||||
from chatballs.notifications.services import notify
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import tenant_context_for
|
||||
@@ -217,6 +218,84 @@ class PollerSelectionTests(NotifierTestBase):
|
||||
self.assertNotIn("inactive-channel-client", polled_names)
|
||||
|
||||
|
||||
class OpenedConversationReadTests(NotifierTestBase):
|
||||
"""Открытый диалог гасит свои уведомления.
|
||||
|
||||
Раньше оклик «клиент ждёт» оставался непрочитанным, пока по нему не нажали
|
||||
в шторке: сотрудник мог отвечать в переписке, а счётчик в шапке продолжал
|
||||
звать его туда же.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def _notify(self, *, target_id: str, type=NotificationType.OPERATOR_REQUESTED):
|
||||
return notify(
|
||||
context=self.context,
|
||||
type=type,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title="Клиент ждёт",
|
||||
target_id=target_id,
|
||||
)
|
||||
|
||||
def test_opened_conversation_marks_only_its_own_notifications(self) -> None:
|
||||
opened = self._notify(target_id="17")
|
||||
another = self._notify(target_id="18")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/notifications/read/",
|
||||
data={"conversationId": 17},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["unreadCount"], 1)
|
||||
unread = list(unread_for(self.context).values_list("id", flat=True))
|
||||
self.assertEqual(unread, [another.id])
|
||||
self.assertNotIn(opened.id, unread)
|
||||
|
||||
def test_every_notification_about_the_conversation_is_marked(self) -> None:
|
||||
# В шторку помещается не всё: гасим по диалогу, а не по тому, что успел
|
||||
# загрузить клиент.
|
||||
waiting = self._notify(target_id="17")
|
||||
message = self._notify(target_id="17", type=NotificationType.DIALOG_NEW_MESSAGE)
|
||||
|
||||
self.client.post(
|
||||
"/api/v1/notifications/read/",
|
||||
data={"conversationId": 17},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
read = set(unread_for(self.context).values_list("id", flat=True))
|
||||
self.assertNotIn(waiting.id, read)
|
||||
self.assertNotIn(message.id, read)
|
||||
|
||||
def test_legacy_route_of_old_notifications_is_recognized(self) -> None:
|
||||
old = self._notify(target_id="17")
|
||||
Notification.objects.filter(id=old.id).update(target_route="salesDialogs")
|
||||
|
||||
self.client.post(
|
||||
"/api/v1/notifications/read/",
|
||||
data={"conversationId": 17},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(unread_for(self.context).count(), 0)
|
||||
|
||||
def test_broken_conversation_id_is_rejected(self) -> None:
|
||||
self._notify(target_id="17")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/notifications/read/",
|
||||
data={"conversationId": "17"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(unread_for(self.context).count(), 1)
|
||||
|
||||
class BindingApiTests(NotifierTestBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
@@ -129,8 +129,14 @@ class NotificationReadView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
conversation_id = request.data.get("conversationId")
|
||||
if request.data.get("all"):
|
||||
mark_read(context=request.tenant_context, all_unread=True)
|
||||
elif conversation_id is not None:
|
||||
# Сотрудник открыл диалог — окликать по нему больше нечем.
|
||||
if not isinstance(conversation_id, int) or isinstance(conversation_id, bool):
|
||||
return Response({"detail": t("notifications.conversation_id_number")}, status=400)
|
||||
mark_read(context=request.tenant_context, conversation_id=conversation_id)
|
||||
else:
|
||||
ids = request.data.get("ids")
|
||||
if not isinstance(ids, list):
|
||||
|
||||
@@ -23,6 +23,57 @@ def system_tenant_context(organization: Organization) -> TenantContext:
|
||||
return TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM)
|
||||
|
||||
|
||||
def run_pending_ai_turns() -> int:
|
||||
"""Прогнать поставленные ходы AI и вернуть их число.
|
||||
|
||||
Ход считается ролью событий воркера, которой в тестах нет: приём ставит
|
||||
заявку, а вызвать её должен сам тест — так же, как он это делает с
|
||||
приглашениями на звонок и письмами.
|
||||
"""
|
||||
from chatballs.conversations.ai_turn import AI_TURN_REQUESTED
|
||||
from chatballs.events.handlers import dispatch
|
||||
from chatballs.events.models import OutboxEvent, OutboxStatus
|
||||
|
||||
events = list(
|
||||
OutboxEvent.objects.filter(
|
||||
event_type=AI_TURN_REQUESTED, status=OutboxStatus.PENDING
|
||||
).order_by("created_at")
|
||||
)
|
||||
for event in events:
|
||||
dispatch(event)
|
||||
event.status = OutboxStatus.PROCESSED
|
||||
event.save(update_fields=["status"])
|
||||
return len(events)
|
||||
|
||||
|
||||
def ai_answer(text: str):
|
||||
"""Подменить ответ модели в ходе AI (контекст-менеджер)."""
|
||||
from unittest import mock
|
||||
|
||||
from chatballs.ai.provider.base import ChatResult
|
||||
from chatballs.ai.turn import TurnAnswer
|
||||
|
||||
return mock.patch(
|
||||
"chatballs.conversations.ai_turn.run_turn_chat",
|
||||
return_value=TurnAnswer(
|
||||
result=ChatResult(text=text, model="test", prompt_tokens=1, completion_tokens=1)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def ai_failure(message: str = "provider is down"):
|
||||
"""Подменить ход AI отказом провайдера (контекст-менеджер)."""
|
||||
from unittest import mock
|
||||
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.turn import TurnAnswer
|
||||
|
||||
return mock.patch(
|
||||
"chatballs.conversations.ai_turn.run_turn_chat",
|
||||
return_value=TurnAnswer(error=ProviderError(message)),
|
||||
)
|
||||
|
||||
|
||||
class TenantAPIClient(APIClient):
|
||||
"""Test client that turns legacy test literals into the C03 tenant route.
|
||||
|
||||
|
||||
@@ -21,22 +21,27 @@ LOADER_JS = r"""
|
||||
: "channel=" + encodeURIComponent(legacyChannel);
|
||||
var panelUrl = origin + "/chat/?" + entryQuery + "&instanceId=" + encodeURIComponent(instanceId);
|
||||
|
||||
var open = false, frame = null, unread = false, callActive = false;
|
||||
var open = false, frame = null, shell = null, unread = false, callActive = false, expanded = false;
|
||||
var contentTimer = 0;
|
||||
var api = window.ChatballsChat = window.ChatballsChat || {};
|
||||
window.ChatballsChat = api; // legacy alias для уже встроенных хостов
|
||||
|
||||
var style = document.createElement("style");
|
||||
style.textContent = "@keyframes chatballs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes chatballs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.chatballs-chat-message-bump{animation:chatballs-chat-message-bump .42s ease-out}.chatballs-chat-call-shake{animation:chatballs-chat-call-shake 3.2s ease-in-out infinite}.chatballs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(22,119,255,0.45)}.chatballs-chat-launcher:focus-visible{outline:3px solid rgba(22,119,255,0.45);outline-offset:2px}";
|
||||
style.textContent = "@keyframes chatballs-chat-pop{from{opacity:0;transform:scale(.55) rotate(-25deg)}to{opacity:1;transform:none}}.chatballs-chat-launcher svg{animation:chatballs-chat-pop .24s cubic-bezier(.16,1,.3,1)}@keyframes chatballs-chat-blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.12)}}.chatballs-chat-eyes{transform-origin:24px 20px;animation:chatballs-chat-blink 4.2s infinite}@keyframes chatballs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes chatballs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.chatballs-chat-message-bump{animation:chatballs-chat-message-bump .42s ease-out}.chatballs-chat-call-shake{animation:chatballs-chat-call-shake 3.2s ease-in-out infinite}.chatballs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(22,119,255,0.45)}.chatballs-chat-launcher:focus-visible{outline:3px solid rgba(22,119,255,0.45);outline-offset:2px}";
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
|
||||
// Launcher — «пилюля» с иконкой (Tabler brand-hipchat) и подписью. Иконка
|
||||
// вшита как inline SVG: на сайте продукта шрифта Tabler нет.
|
||||
// Launcher — круглая кнопка со знаком агента. Знак вшит как inline SVG:
|
||||
// на сайте, где стоит виджет, наших файлов нет. Пока панель открыта, кнопка
|
||||
// остаётся на месте и показывает шеврон — им же панель и закрывают.
|
||||
var BOT_ICON = '<svg viewBox="0 0 48 48" width="30" height="30" fill="#fff" style="flex:none"><mask id="chatballs-launcher-face" maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48"><rect width="48" height="48" fill="#fff"/><g class="chatballs-chat-eyes"><rect x="15" y="16.4" width="4" height="7.2" rx="2" fill="#000"/><rect x="29" y="16.4" width="4" height="7.2" rx="2" fill="#000"/></g><path d="M17.6 26.4c1.7 2.4 3.9 3.6 6.4 3.6s4.7-1.2 6.4-3.6" fill="none" stroke="#000" stroke-width="2.6" stroke-linecap="round"/></mask><circle cx="24" cy="3.6" r="2.4"/><rect x="22.8" y="4.8" width="2.4" height="5"/><rect x="2.2" y="18" width="2.6" height="8.4" rx="1.3"/><rect x="43.2" y="18" width="2.6" height="8.4" rx="1.3"/><path mask="url(#chatballs-launcher-face)" d="M14 9.2h20a6.8 6.8 0 0 1 6.8 6.8v12.4a6.8 6.8 0 0 1-6.8 6.8H21.6l-6.4 5.2a.9.9 0 0 1-1.5-.7v-4.5h-.3A6.8 6.8 0 0 1 7.2 28.4V16A6.8 6.8 0 0 1 14 9.2Z"/></svg>';
|
||||
var CHEVRON_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="flex:none"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "chatballs-chat-launcher";
|
||||
btn.type = "button";
|
||||
btn.setAttribute("aria-label", "Открыть чат");
|
||||
btn.style.cssText = "position:fixed;right:24px;bottom:24px;height:52px;padding:0 20px;border:none;border-radius:999px;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.35);cursor:pointer;z-index:2147483000;display:flex;align-items:center;gap:9px;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;font-size:15px;font-weight:600;line-height:1;letter-spacing:0;transition:transform .18s ease,box-shadow .18s ease;";
|
||||
btn.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex:none"><path d="M17.802 17.292s.077-.055.2-.149c1.843-1.425 3-3.49 3-5.789c0-4.286-4.03-7.764-9-7.764c-4.97 0-9 3.478-9 7.764c0 4.288 4.03 7.646 9 7.646c.424 0 1.12-.028 2.088-.084c1.262.82 3.104 1.493 4.716 1.493c.499 0 .734-.41.414-.828c-.486-.596-1.156-1.551-1.416-2.29z"/><path d="M7.5 13.5c2.5 2.5 6.5 2.5 9 0"/></svg><span style="white-space:nowrap">Чат</span>';
|
||||
btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:56px;height:56px;padding:0;border:none;border-radius:50%;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.35);cursor:pointer;z-index:2147483002;display:flex;align-items:center;justify-content:center;color:#fff;transition:transform .18s ease,box-shadow .18s ease,opacity .14s ease;";
|
||||
btn.innerHTML = BOT_ICON;
|
||||
|
||||
var dot = document.createElement("span");
|
||||
dot.style.cssText = "position:absolute;top:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;";
|
||||
@@ -78,20 +83,24 @@ LOADER_JS = r"""
|
||||
|
||||
function ensureFrame() {
|
||||
if (frame) return;
|
||||
shell = document.createElement("div");
|
||||
shell.style.cssText = "position:fixed;right:24px;bottom:92px;width:" + PANEL_WIDTH.normal + ";height:" + PANEL_HEIGHT.normal + ";z-index:2147483001;display:none;transition:width .32s cubic-bezier(.4,0,.2,1),height .32s cubic-bezier(.4,0,.2,1);";
|
||||
frame = document.createElement("iframe");
|
||||
frame.src = panelUrl;
|
||||
frame.title = "Чат";
|
||||
frame.style.cssText = "position:fixed;right:24px;bottom:24px;width:min(440px,calc(100vw - 32px));height:min(720px,calc(100vh - 48px));border:none;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,0.18);z-index:2147483000;display:none;background:transparent;";
|
||||
frame.style.cssText = "width:100%;height:100%;border:none;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,0.18);background:transparent;display:block;transform-origin:0 0;";
|
||||
shell.appendChild(frame);
|
||||
// Панель может открыться раньше, чем загрузится iframe — тогда сообщение
|
||||
// «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
|
||||
// повторяем его на load.
|
||||
frame.addEventListener("load", function () { if (open) notifyOpened(); });
|
||||
document.body.appendChild(frame);
|
||||
document.body.appendChild(shell);
|
||||
window.addEventListener("message", function (e) {
|
||||
if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
|
||||
var d = e.data || {};
|
||||
if (d.instanceId && d.instanceId !== instanceId) return;
|
||||
if (d.type === "chatballs-chat-close") setOpen(false);
|
||||
if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded));
|
||||
if (d.type === "chatballs-chat-unread") {
|
||||
unread = Boolean(d.unread);
|
||||
updateDot();
|
||||
@@ -107,6 +116,157 @@ LOADER_JS = r"""
|
||||
});
|
||||
}
|
||||
|
||||
// Развёрнутая панель почти вдвое шире и немного выше; переход анимируется
|
||||
// самим iframe (transition в его стилях).
|
||||
var PANEL_WIDTH = { normal: "min(440px,calc(100vw - 32px))", expanded: "min(820px,calc(100vw - 32px))" };
|
||||
var PANEL_HEIGHT = { normal: "min(720px,calc(100vh - 116px))", expanded: "min(820px,calc(100vh - 116px))" };
|
||||
|
||||
function setExpanded(next) {
|
||||
expanded = next;
|
||||
if (!shell) return;
|
||||
shell.style.width = next ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
|
||||
shell.style.height = next ? PANEL_HEIGHT.expanded : PANEL_HEIGHT.normal;
|
||||
}
|
||||
|
||||
// Эффект джина: окно втягивается в кнопку, вытягивая горловину, — как в
|
||||
// доке macOS. Аффинными трансформациями такое не получить: там изгиб, а не
|
||||
// масштаб. Поэтому силуэт рисуется контуром SVG и перестраивается на каждом
|
||||
// кадре. Сам iframe в анимации не участвует — менять его размеры покадрово
|
||||
// нельзя, внутри переворачивалась бы вёрстка.
|
||||
var GENIE_OPEN_MS = 300;
|
||||
var GENIE_CLOSE_MS = 240;
|
||||
var SVG_NS = "http://www.w3.org/2000/svg";
|
||||
var genieSvg = null, geniePath = null, genieRaf = 0;
|
||||
|
||||
function ensureGenie() {
|
||||
if (genieSvg) return;
|
||||
genieSvg = document.createElementNS(SVG_NS, "svg");
|
||||
genieSvg.style.cssText = "position:fixed;left:0;top:0;width:100%;height:100%;z-index:2147482999;pointer-events:none;display:none;overflow:visible;";
|
||||
geniePath = document.createElementNS(SVG_NS, "path");
|
||||
geniePath.style.filter = "drop-shadow(0 12px 40px rgba(0,0,0,0.18))";
|
||||
genieSvg.appendChild(geniePath);
|
||||
document.body.appendChild(genieSvg);
|
||||
}
|
||||
|
||||
function smooth(x) {
|
||||
if (x <= 0) return 0;
|
||||
if (x >= 1) return 1;
|
||||
return x * x * (3 - 2 * x);
|
||||
}
|
||||
|
||||
function mix(a, b, k) { return a + (b - a) * k; }
|
||||
|
||||
function shapeAt(p, panel, icon) {
|
||||
// Низ втягивается раньше верха — из-за этого и появляется горловина.
|
||||
var pb = smooth(Math.min(1, p * 1.7));
|
||||
var pt = smooth(Math.max(0, (p - 0.3) / 0.7));
|
||||
|
||||
var topY = mix(panel.top, icon.top, pt);
|
||||
var topHalf = mix(panel.half, icon.half, pt);
|
||||
var topCx = mix(panel.cx, icon.cx, pt);
|
||||
var topR = Math.min(mix(panel.radius, icon.half, pt), topHalf);
|
||||
|
||||
var bottomY = mix(panel.bottom, icon.bottom, pb);
|
||||
var bottomHalf = mix(panel.half, icon.half, pb);
|
||||
var bottomCx = mix(panel.cx, icon.cx, pb);
|
||||
var bottomR = Math.min(mix(panel.radius, icon.half, pb), bottomHalf);
|
||||
|
||||
return {
|
||||
topY: topY, topL: topCx - topHalf, topRt: topCx + topHalf, topR: topR,
|
||||
bottomY: bottomY, bottomL: bottomCx - bottomHalf, bottomRt: bottomCx + bottomHalf, bottomR: bottomR,
|
||||
// Плечо кривой: чем длиннее горловина, тем мягче изгиб.
|
||||
k: Math.max(0, (bottomY - topY) * 0.45),
|
||||
fill: pt
|
||||
};
|
||||
}
|
||||
|
||||
function pathOf(g, dx, dy) {
|
||||
var tl = g.topL - dx, tr = g.topRt - dx, ty = g.topY - dy;
|
||||
var bl = g.bottomL - dx, br = g.bottomRt - dx, by = g.bottomY - dy;
|
||||
return "M" + (tl + g.topR) + "," + ty +
|
||||
"H" + (tr - g.topR) +
|
||||
"Q" + tr + "," + ty + " " + tr + "," + (ty + g.topR) +
|
||||
"C" + tr + "," + (ty + g.k) + " " + br + "," + (by - g.k) + " " + br + "," + (by - g.bottomR) +
|
||||
"Q" + br + "," + by + " " + (br - g.bottomR) + "," + by +
|
||||
"H" + (bl + g.bottomR) +
|
||||
"Q" + bl + "," + by + " " + bl + "," + (by - g.bottomR) +
|
||||
"C" + bl + "," + (by - g.k) + " " + tl + "," + (ty + g.k) + " " + tl + "," + (ty + g.topR) +
|
||||
"Q" + tl + "," + ty + " " + (tl + g.topR) + "," + ty + "Z";
|
||||
}
|
||||
|
||||
function paintGenie(p, panel, icon) {
|
||||
var g = shapeAt(p, panel, icon);
|
||||
geniePath.setAttribute("d", pathOf(g, 0, 0));
|
||||
geniePath.setAttribute(
|
||||
"fill",
|
||||
"rgb(" + Math.round(mix(255, 22, g.fill)) + "," +
|
||||
Math.round(mix(255, 119, g.fill)) + "," +
|
||||
Math.round(mix(255, 255, g.fill)) + ")"
|
||||
);
|
||||
// Контур режет контейнер. Его собственная геометрия не меняется, поэтому
|
||||
// координаты контура честно переводятся в его систему простым сдвигом.
|
||||
var box = shell.getBoundingClientRect();
|
||||
shell.style.clipPath = "path('" + pathOf(g, box.left, box.top) + "')";
|
||||
|
||||
// Окно внутри повторяет форму: ширина идёт за горловиной, высота — за
|
||||
// длиной силуэта, наклон — за уходом центра к кнопке. Содержимое из-за
|
||||
// этого сжимается и заваливается вместе с окном, а не стоит на месте.
|
||||
var width = Math.max(1, box.width);
|
||||
var height = Math.max(1, box.height);
|
||||
var sx = Math.max(0.001, (g.topRt - g.topL) / width);
|
||||
var sy = Math.max(0.001, (g.bottomY - g.topY) / height);
|
||||
var lean = ((g.bottomL + g.bottomRt) / 2 - (g.topL + g.topRt) / 2) /
|
||||
Math.max(1, g.bottomY - g.topY);
|
||||
var skew = Math.atan((sy / sx) * lean) * 180 / Math.PI;
|
||||
frame.style.transform = "translate(" + (g.topL - box.left) + "px," + (g.topY - box.top) + "px)" +
|
||||
" scale(" + sx + "," + sy + ") skewX(" + skew + "deg)";
|
||||
frame.style.borderRadius = Math.round(mix(24, icon.half, g.fill)) + "px";
|
||||
}
|
||||
|
||||
function restPanel() {
|
||||
shell.style.clipPath = "none";
|
||||
frame.style.transform = "none";
|
||||
frame.style.borderRadius = "24px";
|
||||
}
|
||||
|
||||
function playGenie(from, to, duration, panel, icon, done) {
|
||||
ensureGenie();
|
||||
genieSvg.style.display = "block";
|
||||
var started = 0;
|
||||
window.cancelAnimationFrame(genieRaf);
|
||||
function step(now) {
|
||||
if (!started) started = now;
|
||||
var linear = Math.min(1, (now - started) / duration);
|
||||
// Мягкий вход и выход: резкий старт читается как рывок.
|
||||
var eased = linear < 0.5 ? 2 * linear * linear : 1 - Math.pow(-2 * linear + 2, 2) / 2;
|
||||
paintGenie(mix(from, to, eased), panel, icon);
|
||||
if (linear < 1) genieRaf = window.requestAnimationFrame(step);
|
||||
else done();
|
||||
}
|
||||
genieRaf = window.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function panelRect() {
|
||||
var box = shell.getBoundingClientRect();
|
||||
return {
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
cx: box.left + box.width / 2,
|
||||
half: box.width / 2,
|
||||
radius: 24
|
||||
};
|
||||
}
|
||||
|
||||
function iconRect() {
|
||||
var box = btn.getBoundingClientRect();
|
||||
return {
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
cx: box.left + box.width / 2,
|
||||
half: box.width / 2
|
||||
};
|
||||
}
|
||||
|
||||
function notifyOpened() {
|
||||
try { frame.contentWindow.postMessage({ type: "chatballs-chat-opened" }, origin); } catch (_) {}
|
||||
}
|
||||
@@ -114,8 +274,35 @@ LOADER_JS = r"""
|
||||
function setOpen(next) {
|
||||
ensureFrame();
|
||||
open = next;
|
||||
frame.style.display = open ? "block" : "none";
|
||||
btn.style.display = open ? "none" : "flex";
|
||||
window.clearTimeout(contentTimer);
|
||||
btn.style.opacity = "0";
|
||||
btn.style.pointerEvents = "none";
|
||||
if (open) {
|
||||
// Контейнер раскладывается до расчёта: силуэт строится по настоящим
|
||||
// границам панели, а они известны только разложенной.
|
||||
shell.style.display = "block";
|
||||
paintGenie(1, panelRect(), iconRect());
|
||||
playGenie(1, 0, GENIE_OPEN_MS, panelRect(), iconRect(), function () {
|
||||
if (!open) return;
|
||||
genieSvg.style.display = "none";
|
||||
restPanel();
|
||||
btn.style.opacity = "1";
|
||||
btn.style.pointerEvents = "";
|
||||
});
|
||||
} else {
|
||||
var box = panelRect();
|
||||
playGenie(0, 1, GENIE_CLOSE_MS, box, iconRect(), function () {
|
||||
if (open) return;
|
||||
genieSvg.style.display = "none";
|
||||
shell.style.display = "none";
|
||||
restPanel();
|
||||
btn.style.opacity = "1";
|
||||
btn.style.pointerEvents = "";
|
||||
});
|
||||
}
|
||||
btn.innerHTML = open ? CHEVRON_ICON : BOT_ICON;
|
||||
btn.setAttribute("aria-label", open ? "Свернуть чат" : "Открыть чат");
|
||||
btn.appendChild(dot);
|
||||
if (open) {
|
||||
unread = false;
|
||||
updateDot();
|
||||
@@ -124,7 +311,7 @@ LOADER_JS = r"""
|
||||
} else updateDot();
|
||||
}
|
||||
|
||||
btn.addEventListener("click", function () { setOpen(true); });
|
||||
btn.addEventListener("click", function () { setOpen(!open); });
|
||||
function mount() {
|
||||
document.body.appendChild(btn);
|
||||
ensureFrame();
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.conf import settings
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations.ai_turn import conversation_is_thinking
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
@@ -346,12 +347,15 @@ def messages_payload(session: WebSession, since: int) -> dict:
|
||||
"messages": [],
|
||||
"call": None,
|
||||
"reset": reset,
|
||||
"thinking": False,
|
||||
}
|
||||
items = conversation.messages.filter(id__gt=since).order_by("created_at")
|
||||
return {
|
||||
"reset": reset,
|
||||
"state": _STATE.get(conversation.control_mode, "ai"),
|
||||
"lifecycle": conversation.lifecycle,
|
||||
# Ответ уже считается: виджет показывает клиенту, что агент печатает.
|
||||
"thinking": conversation_is_thinking(conversation.id),
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
|
||||
@@ -197,15 +197,24 @@ CHATBALLS_OPENROUTER_BASE_URL = os.environ.get("CHATBALLS_OPENROUTER_BASE_URL",
|
||||
CHATBALLS_AI_REQUEST_TIMEOUT = float(os.environ.get("CHATBALLS_AI_REQUEST_TIMEOUT", "30"))
|
||||
CHATBALLS_AI_MAX_RETRIES = int(os.environ.get("CHATBALLS_AI_MAX_RETRIES", "2"))
|
||||
CHATBALLS_AI_EMBEDDING_MODEL = os.environ.get("CHATBALLS_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small")
|
||||
# Ответ живому человеку ждут иначе, чем индексацию знаний: клиент в чате не
|
||||
# станет ждать полторы минуты, пока провайдер доберёт три попытки по тридцать
|
||||
# секунд. Ход диалога ограничен своим сроком (chatballs.ai.turn).
|
||||
CHATBALLS_AI_TURN_TIMEOUT = float(os.environ.get("CHATBALLS_AI_TURN_TIMEOUT", "20"))
|
||||
# Сколько ход имеет смысл: сообщение, пролежавшее в очереди дольше, отвечать
|
||||
# уже поздно — клиент ушёл или его взял оператор, и диалог передаётся человеку.
|
||||
CHATBALLS_AI_TURN_DEADLINE_SECONDS = int(
|
||||
os.environ.get("CHATBALLS_AI_TURN_DEADLINE_SECONDS", "120")
|
||||
)
|
||||
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
|
||||
|
||||
# Managed-провайдер CustoAI удалён (ADR-CHATBALLS-0042 §3): AI — только через
|
||||
# интеграцию организации (BYOK).
|
||||
|
||||
# Long-poll hold-time мессенджеров (сек). Держим малым: единый воркер выполняет
|
||||
# и inbound-поллинг, и outbox-диспатч в одном потоке — при большом hold-time
|
||||
# getUpdates/updates блокирует цикл и outbox (приглашения звонков, уведомления,
|
||||
# ответы AI) уходит с задержкой в размер long-poll на каждое подключение.
|
||||
# Long-poll hold-time мессенджеров (сек). Держим малым: опрос идёт по
|
||||
# подключениям последовательно в одном процессе, и hold-time каждого из них
|
||||
# складывается в задержку приёма у остальных. Ответы AI от этого больше не
|
||||
# зависят — их считает отдельная роль воркера (run_worker --role).
|
||||
CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS = int(os.environ.get("CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS", "2"))
|
||||
|
||||
# Срок жизни анонимной сессии виджета: отсчёт от последней активности, а не от
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
|
||||
|
||||
import { api, ApiError } from "../../api/client";
|
||||
import { ChannelGlyph } from "../../shared/badges";
|
||||
import { SelectField } from "../../shared/form-controls";
|
||||
import { FormField, SelectField } from "../../shared/form-controls";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
|
||||
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
patchAgent,
|
||||
setAgentAiActive,
|
||||
unbindAgentConnection,
|
||||
HISTORY_LIMIT_MAX,
|
||||
type AgentCard,
|
||||
type AgentConnection,
|
||||
type AgentPatch,
|
||||
@@ -534,6 +535,17 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
|
||||
}) {
|
||||
const missingProvider = card.providerIntegrationId === null;
|
||||
const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? "";
|
||||
const transcriptionName = providers.find((item) => item.id === card.transcriptionIntegrationId)?.name ?? "";
|
||||
// Поля моделей редактируются свободно и уходят на сервер по потере фокуса:
|
||||
// сохранять каждую букву — это запрос на символ.
|
||||
const [modelDraft, setModelDraft] = useState(card.model);
|
||||
const [transcriptionDraft, setTranscriptionDraft] = useState(card.transcriptionModel);
|
||||
useEffect(() => { setModelDraft(card.model); }, [card.model]);
|
||||
useEffect(() => { setTranscriptionDraft(card.transcriptionModel); }, [card.transcriptionModel]);
|
||||
const [historyDraft, setHistoryDraft] = useState(String(card.historyLimit));
|
||||
useEffect(() => { setHistoryDraft(String(card.historyLimit)); }, [card.historyLimit]);
|
||||
const historyValue = Number(historyDraft);
|
||||
const historyValid = /^\d+$/.test(historyDraft.trim()) && historyValue >= 1 && historyValue <= HISTORY_LIMIT_MAX;
|
||||
|
||||
return (
|
||||
<section className="agent-card is-side">
|
||||
@@ -550,13 +562,50 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
|
||||
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
|
||||
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
|
||||
/>
|
||||
<label className="agent-field is-model">
|
||||
<span>{t("common.model")}</span>
|
||||
<span className="agent-field-control">
|
||||
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? t("ai.pick_provider") : card.model}</span>
|
||||
<Icon name="search" size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
</label>
|
||||
{/* Ключ провайдера один на организацию, а агентов на нём несколько:
|
||||
модель принадлежит агенту. Пустое поле — «как в интеграции», и
|
||||
подсказкой в нём стоит её модель. */}
|
||||
<FormField
|
||||
disabled={busy || !canManage}
|
||||
label={t("common.model")}
|
||||
mono
|
||||
placeholder={missingProvider ? t("ai.pick_provider") : card.providerModel || t("ai.model_of_integration")}
|
||||
value={modelDraft}
|
||||
onChange={setModelDraft}
|
||||
onBlur={() => { if (modelDraft !== card.model) void apply({ model: modelDraft }); }}
|
||||
/>
|
||||
{/* Сколько последних сообщений диалога модель получает вместе с новым.
|
||||
Больше — агент помнит длинный разговор, но ответ дороже, а у
|
||||
локальной модели с малым окном хвост обрежется на её стороне. */}
|
||||
<FormField
|
||||
disabled={busy || !canManage}
|
||||
error={historyValid ? undefined : t("ai.history_limit_invalid", { max: HISTORY_LIMIT_MAX })}
|
||||
label={t("ai.history_limit")}
|
||||
type="number"
|
||||
value={historyDraft}
|
||||
onChange={setHistoryDraft}
|
||||
onBlur={() => { if (historyValid && historyValue !== card.historyLimit) void apply({ historyLimit: historyValue }); }}
|
||||
/>
|
||||
{/* Речь в текст умеет не всякая модель, которой агент отвечает: у части
|
||||
провайдеров аудио-эндпоинта нет вовсе. Поэтому выбор отдельный. */}
|
||||
<SelectField
|
||||
disabled={busy}
|
||||
label={t("ai.transcription_provider")}
|
||||
readOnly={!canManage}
|
||||
readOnlyText={transcriptionName || t("ai.same_as_answers")}
|
||||
value={card.transcriptionIntegrationId ? String(card.transcriptionIntegrationId) : ""}
|
||||
onChange={(next) => void apply({ transcriptionIntegrationId: next ? Number(next) : null })}
|
||||
options={[["", t("ai.same_as_answers")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
|
||||
/>
|
||||
<FormField
|
||||
disabled={busy || !canManage}
|
||||
label={t("ai.transcription_model")}
|
||||
mono
|
||||
placeholder={card.transcriptionProviderModel || t("ai.model_of_integration")}
|
||||
value={transcriptionDraft}
|
||||
onChange={setTranscriptionDraft}
|
||||
onBlur={() => { if (transcriptionDraft !== card.transcriptionModel) void apply({ transcriptionModel: transcriptionDraft }); }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -35,10 +35,19 @@ export type AgentCard = {
|
||||
groupColor: string;
|
||||
aiStatus: AgentAiStatus;
|
||||
model: string;
|
||||
/** Модель ответов; пусто — как в интеграции. */
|
||||
transcriptionModel: string;
|
||||
/** Модели, заданные в интеграциях: подсказка в пустом поле. */
|
||||
providerModel: string;
|
||||
transcriptionProviderModel: string;
|
||||
providerIntegrationId: number | null;
|
||||
/** Чем расшифровывать голосовые; null — тем же провайдером, что отвечает. */
|
||||
transcriptionIntegrationId: number | null;
|
||||
modelParams: Record<string, unknown>;
|
||||
// Режим языка ответов: MIRROR, ORGANIZATION или код языка.
|
||||
answerLanguage: string;
|
||||
/** Сколько последних сообщений диалога агент видит вместе с новым. */
|
||||
historyLimit: number;
|
||||
persona: string;
|
||||
tone: string;
|
||||
instructions: string;
|
||||
@@ -52,12 +61,19 @@ export type AgentCard = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/** Границы окна истории — те же, что проверяет сервер (ai.models). */
|
||||
export const HISTORY_LIMIT_MAX = 200;
|
||||
|
||||
export type AgentPatch = Partial<{
|
||||
name: string;
|
||||
groupId: number | null;
|
||||
isActive: boolean;
|
||||
providerIntegrationId: number | null;
|
||||
transcriptionIntegrationId: number | null;
|
||||
model: string;
|
||||
transcriptionModel: string;
|
||||
answerLanguage: string;
|
||||
historyLimit: number;
|
||||
persona: string;
|
||||
tone: string;
|
||||
instructions: string;
|
||||
@@ -155,9 +171,11 @@ export function agentTile(card: Pick<AgentCard, "id" | "isActive">): { color: st
|
||||
return { color, background: `color-mix(in srgb, ${color} 12%, var(--surface-card))` };
|
||||
}
|
||||
|
||||
/** Модель в списке: пока провайдер не выбран, показывать нечего (кадр G1). */
|
||||
/** Модель в списке: своя модель агента, иначе та, что стоит в интеграции.
|
||||
* Пока провайдер не выбран, показывать нечего (кадр G1). */
|
||||
export function agentModelLabel(card: AgentCard): string {
|
||||
return card.providerIntegrationId ? card.model : "—";
|
||||
if (!card.providerIntegrationId) return "—";
|
||||
return card.model || card.providerModel || "—";
|
||||
}
|
||||
|
||||
export function agentTint(provider: string): { color: string; bg: string; full: string } {
|
||||
|
||||
@@ -46,6 +46,7 @@ import type { ConversationListItem, ListSort, ListTab } from "./types";
|
||||
import { useConversationCall } from "./useConversationCall";
|
||||
import { useConversationEvents } from "./useConversationEvents";
|
||||
import { useDebounced } from "../../shared/useDebounced";
|
||||
import { useOpenedConversationRead } from "../notifications/useOpenedConversationRead";
|
||||
import { useConversationHistory } from "./useConversationHistory";
|
||||
import { useConversationList } from "./useConversationList";
|
||||
import { useDialogKeyboardNav } from "./useDialogKeyboardNav";
|
||||
@@ -148,6 +149,8 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
|
||||
setCtxOpen(false);
|
||||
}, [selectedId]);
|
||||
|
||||
useOpenedConversationRead(selectedId);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId == null) return;
|
||||
setDetail(null);
|
||||
|
||||
@@ -34,7 +34,7 @@ export const isImageAttachment = (message: Pick<ApiMessage, "attachmentContentTy
|
||||
export type HistoryItem = {
|
||||
id: number;
|
||||
channelName: string;
|
||||
provider: "EMAIL" | "MAX" | "TELEGRAM" | "WEB" | null;
|
||||
provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB" | null;
|
||||
lifecycle: "OPEN" | "CLOSED" | "SPAM";
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
@@ -69,7 +69,7 @@ export type ApiConversation = {
|
||||
id: number;
|
||||
channel: { id: number; code: string; name: string };
|
||||
// voiceMessages/audioCalls/videoCalls — что разрешено в точке входа («Настройки → Голосовые и звонки»).
|
||||
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
|
||||
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
|
||||
// Контакт — единственный источник identity диалога.
|
||||
// phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме.
|
||||
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string } | null;
|
||||
@@ -145,6 +145,7 @@ const PROVIDER_CHANNEL: Record<string, ChannelKey> = {
|
||||
EMAIL: "EMAIL",
|
||||
MAX: "MAX",
|
||||
TELEGRAM: "TG",
|
||||
VK: "VK",
|
||||
WEB: "WEB",
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export type ListTab = "all" | "mine" | "queue" | "onMe";
|
||||
// Порядок инбокса. Считает его сервер: список приходит окном, и сортировать в
|
||||
// браузере было бы нечего.
|
||||
export type ListSort = "activity" | "waiting";
|
||||
export type ChannelKey = "EMAIL" | "MAX" | "TG" | "WEB";
|
||||
export type ChannelKey = "EMAIL" | "MAX" | "TG" | "VK" | "WEB";
|
||||
|
||||
// Элемент списка диалогов (бывш. SalesDialog). Полностью generic.
|
||||
export type ConversationListItem = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Modal } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import { helpArticleUrl } from "../../shared/help";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { FormField, SelectField } from "../../shared/form-controls";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
@@ -124,6 +125,12 @@ export function IntegrationForm({ initial, kind, onClose, onSaved }: { initial:
|
||||
) : (
|
||||
<SelectField label={t("settings.type")} value={provider} onChange={(value) => setProvider(value as IntegrationProvider)} options={options} />
|
||||
)}
|
||||
{meta.helpSlug && (
|
||||
<a className="link is-muted has-icon integration-form-help" href={helpArticleUrl(meta.helpSlug)} target="_blank" rel="noopener noreferrer">
|
||||
{t("settings.how_to_connect")}
|
||||
<Icon name="external" size={13} strokeWidth={2.2} />
|
||||
</a>
|
||||
)}
|
||||
<FormField label={t("common.title")} value={name} onChange={setName} placeholder={isEmail ? t("settings.e_g_support_mailbox") : t("settings.e_g_openrouter_primary")} />
|
||||
{isEmail && <EmailFields value={emailConfig} onChange={setEmailConfig} />}
|
||||
{meta.testable && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { api } from "../../api/client";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export type IntegrationProvider = "OPENROUTER" | "CUSTOM" | "DEMO" | "MAX" | "TELEGRAM" | "WEB" | "EMAIL";
|
||||
export type IntegrationProvider = "OPENROUTER" | "CUSTOM" | "DEMO" | "MAX" | "TELEGRAM" | "VK" | "WEB" | "EMAIL";
|
||||
export type IntegrationKind = "LLM_PROVIDER" | "MESSENGER";
|
||||
export type IntegrationStatus = "UNCHECKED" | "OK" | "ERROR";
|
||||
export type WebChatWidgetSummary = {
|
||||
@@ -67,6 +67,8 @@ type ProviderMeta = {
|
||||
// (у Web-виджета секрета нет, но backend проверяет привязку к каналу).
|
||||
testable: boolean;
|
||||
checkable: boolean;
|
||||
// Статья Центра помощи про этот тип подключения (shared/help).
|
||||
helpSlug?: string;
|
||||
};
|
||||
|
||||
export const PROVIDERS: Record<IntegrationProvider, ProviderMeta> = {
|
||||
@@ -76,11 +78,14 @@ export const PROVIDERS: Record<IntegrationProvider, ProviderMeta> = {
|
||||
CUSTOM: { label: "Custom (OpenAI-compatible)", kind: "LLM_PROVIDER", secretLabel: t("settings.api_key"), defaultBaseUrl: "", hasModel: true, testable: true, checkable: true },
|
||||
// Демо-провайдер — живой AI без ключей и сети для знакомства с системой: отвечает по знаниям агента.
|
||||
DEMO: { label: t("settings.demo_provider_no_key"), kind: "LLM_PROVIDER", secretLabel: "", defaultBaseUrl: "", hasModel: false, testable: false, checkable: true },
|
||||
MAX: { label: "MAX", kind: "MESSENGER", secretLabel: t("settings.bot_token"), defaultBaseUrl: "https://platform-api.max.ru", hasModel: false, testable: true, checkable: true },
|
||||
TELEGRAM: { label: "Telegram", kind: "MESSENGER", secretLabel: t("settings.bot_token"), defaultBaseUrl: "https://api.telegram.org", hasModel: false, testable: true, checkable: true },
|
||||
WEB: { label: t("common.web_widget"), kind: "MESSENGER", secretLabel: "", defaultBaseUrl: "", hasModel: false, testable: false, checkable: true },
|
||||
MAX: { label: "MAX", kind: "MESSENGER", secretLabel: t("settings.bot_token"), defaultBaseUrl: "https://platform-api.max.ru", hasModel: false, testable: true, checkable: true, helpSlug: "max" },
|
||||
TELEGRAM: { label: "Telegram", kind: "MESSENGER", secretLabel: t("settings.bot_token"), defaultBaseUrl: "https://api.telegram.org", hasModel: false, testable: true, checkable: true, helpSlug: "telegram" },
|
||||
// ВКонтакте — сообщество: секрет это ключ доступа сообщества, идентификатор
|
||||
// сообщества подставляет проверка подключения (ADR-CHATBALLS-0020).
|
||||
VK: { label: "ВКонтакте", kind: "MESSENGER", secretLabel: t("settings.vk_community_key"), defaultBaseUrl: "https://api.vk.com/method", hasModel: false, testable: true, checkable: true, helpSlug: "vkontakte" },
|
||||
WEB: { label: t("common.web_widget"), kind: "MESSENGER", secretLabel: "", defaultBaseUrl: "", hasModel: false, testable: false, checkable: true, helpSlug: "veb-vidzhet" },
|
||||
// Email — подключение-ящик IMAP/SMTP (ADR-CHATBALLS-0035); секрет — пароль приложения.
|
||||
EMAIL: { label: "Email (IMAP/SMTP)", kind: "MESSENGER", secretLabel: t("common.password"), defaultBaseUrl: "", hasModel: false, testable: true, checkable: true },
|
||||
EMAIL: { label: "Email (IMAP/SMTP)", kind: "MESSENGER", secretLabel: t("common.password"), defaultBaseUrl: "", hasModel: false, testable: true, checkable: true, helpSlug: "pochtovyj-yashchik" },
|
||||
};
|
||||
|
||||
export const STATUS_META: Record<IntegrationStatus, { label: string; bg: string; color: string }> = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dropdown } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Icon, MaxLogo, TelegramLogo } from "../../shared/icons";
|
||||
import { Icon, MaxLogo, TelegramLogo, VkLogo } from "../../shared/icons";
|
||||
import { Button, ToneBadge } from "../../shared/ui-controls";
|
||||
import { shortDateTime } from "../../shared/utils";
|
||||
import { PROVIDERS, STATUS_META, type Integration, type IntegrationProvider } from "./model";
|
||||
@@ -12,6 +12,7 @@ import { t } from "../../i18n";
|
||||
const TILE_CLASS: Partial<Record<IntegrationProvider, string>> = {
|
||||
TELEGRAM: "integration-tile--telegram",
|
||||
MAX: "integration-tile--max",
|
||||
VK: "integration-tile--vk",
|
||||
WEB: "integration-tile--web",
|
||||
EMAIL: "integration-tile--email",
|
||||
};
|
||||
@@ -31,6 +32,7 @@ export function ConnectionIcon({ provider }: { provider: IntegrationProvider })
|
||||
<span className={`product-icon integration-tile ${tile}`}>
|
||||
{provider === "TELEGRAM" && <TelegramLogo size={20} />}
|
||||
{provider === "MAX" && <MaxLogo size={20} />}
|
||||
{provider === "VK" && <VkLogo size={20} />}
|
||||
{provider === "WEB" && <Icon name="message" size={20} strokeWidth={1.9} />}
|
||||
{provider === "EMAIL" && <Icon name="mail" size={20} strokeWidth={1.9} />}
|
||||
</span>
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
background: color-mix(in srgb, #5b5bd6 14%, var(--surface-card));
|
||||
}
|
||||
|
||||
.integration-tile--vk {
|
||||
color: #0077ff;
|
||||
background: color-mix(in srgb, #0077ff 14%, var(--surface-card));
|
||||
}
|
||||
|
||||
.integration-tile--web {
|
||||
color: var(--primary-text);
|
||||
background: var(--primary-bg);
|
||||
@@ -133,6 +138,14 @@
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* Ссылка на статью Центра помощи про выбранный тип подключения: стоит под
|
||||
полем «Тип» подписью к нему, поэтому подтянута к нему вплотную. */
|
||||
.integration-form-help {
|
||||
align-self: flex-start;
|
||||
margin-top: -10px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* Секции Email-формы: IMAP / SMTP (SPEC-CHATBALLS-0025 §3.2). */
|
||||
.integration-email-section {
|
||||
display: flex;
|
||||
|
||||
@@ -24,6 +24,10 @@ export const LEVEL_META: Record<NotificationLevel, { color: string; icon: "bell"
|
||||
export const fetchNotifications = () => api<{ items: AppNotification[]; unreadCount: number }>("/api/v1/notifications/");
|
||||
export const markRead = (ids: number[]) => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ ids }) });
|
||||
export const markAllRead = () => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ all: true }) });
|
||||
// Открытый диалог — тоже прочтение: гасит все уведомления о нём, а не только
|
||||
// те, что попали в шторку.
|
||||
export const markConversationRead = (conversationId: number) =>
|
||||
api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ conversationId }) });
|
||||
|
||||
export type NotificationTransport = "BROWSER" | "MESSENGER";
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { markConversationRead } from "./model";
|
||||
|
||||
/** Открытый диалог гасит свои уведомления.
|
||||
*
|
||||
* Раньше оклик «клиент ждёт» оставался непрочитанным, даже когда сотрудник уже
|
||||
* отвечал в этой переписке: прочтением считалось только нажатие на строку в
|
||||
* шторке. Счётчик в шапке жил своей жизнью и переставал что-либо значить.
|
||||
*
|
||||
* Список уведомлений после этого перезапрашивает шапка — по событию, которое
|
||||
* шлёт сервер тому же сотруднику: вкладок у него может быть несколько.
|
||||
*/
|
||||
export function useOpenedConversationRead(conversationId: number | null): void {
|
||||
useEffect(() => {
|
||||
if (conversationId == null) return;
|
||||
void markConversationRead(conversationId).catch(() => undefined);
|
||||
}, [conversationId]);
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import { t } from "../../../i18n";
|
||||
|
||||
export type ClientDetailTab = "overview" | "dialogs" | "ids" | "audit";
|
||||
|
||||
const PROVIDER_TO_CHANNEL: Record<string, ClientChannelCode> = { EMAIL: "EMAIL", MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" };
|
||||
const PROVIDER_LABEL: Record<string, string> = { EMAIL: "Email", MAX: "MAX", TELEGRAM: "Telegram", WEB: t("common.web_widget") };
|
||||
const PROVIDER_TO_CHANNEL: Record<string, ClientChannelCode> = { EMAIL: "EMAIL", MAX: "MAX", TELEGRAM: "TG", VK: "VK", WEB: "WEB" };
|
||||
const PROVIDER_LABEL: Record<string, string> = { EMAIL: "Email", MAX: "MAX", TELEGRAM: "Telegram", VK: "ВКонтакте", WEB: t("common.web_widget") };
|
||||
|
||||
// Подпись и цвет режима диалога — те же, что в чате и в списке контактов.
|
||||
const MODE_META: Record<ClientDialogMode, { label: string; color: string; bg: string; dot: string }> = {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { fmt, t } from "../../../i18n";
|
||||
|
||||
export { channelMap };
|
||||
|
||||
export type ClientChannelCode = "EMAIL" | "MAX" | "TG" | "WEB";
|
||||
export type ClientChannelCode = "EMAIL" | "MAX" | "TG" | "VK" | "WEB";
|
||||
export type ClientSortKey = "last" | "open";
|
||||
export type ClientDropdown = "agents" | "channels";
|
||||
export type ClientAgentRef = { id: number; code: string; name: string };
|
||||
@@ -49,6 +49,7 @@ export const channelOptions: Array<{ code: ClientChannelCode; name: string; colo
|
||||
{ code: "EMAIL", name: "Email", color: channelMap.EMAIL.color },
|
||||
{ code: "MAX", name: "MAX", color: channelMap.MAX.color },
|
||||
{ code: "TG", name: "Telegram", color: channelMap.TG.color },
|
||||
{ code: "VK", name: "ВКонтакте", color: channelMap.VK.color },
|
||||
{ code: "WEB", name: t("common.web_widget"), color: channelMap.WEB.color },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { ChannelGlyph } from "../../../../shared/badges";
|
||||
import { Icon } from "../../../../shared/icons";
|
||||
import { CopyButton } from "../../../../shared/ui-controls";
|
||||
import { providerMeta } from "../../../../shared/providers";
|
||||
@@ -73,10 +74,12 @@ export function ClientContext({
|
||||
}
|
||||
}
|
||||
|
||||
const fields: Array<{ key: string; icon: Parameters<typeof Icon>[0]["name"]; text: string; copy?: string; muted?: boolean }> = [];
|
||||
// Логин показывается маркой своего канала: общий значок отправки годился,
|
||||
// пока каналов с логином было два, и врал уже на третьем.
|
||||
const fields: Array<{ key: string; icon: Parameters<typeof Icon>[0]["name"]; glyph?: ReactNode; text: string; copy?: string; muted?: boolean }> = [];
|
||||
if (phone) fields.push({ key: "phone", icon: "phone", text: phone, copy: phone });
|
||||
if (dialog.channel === "EMAIL" && email) fields.push({ key: "email", icon: "mail", text: email, copy: email });
|
||||
if (username) fields.push({ key: "username", icon: "send", text: `@${username} · ${channel.label}`, copy: `@${username}` });
|
||||
if (username) fields.push({ key: "username", icon: "send", glyph: <ChannelGlyph provider={dialog.channel} size={15} />, text: `@${username} · ${channel.label}`, copy: `@${username}` });
|
||||
if (isGuest) fields.push({ key: "guest", icon: "message", text: t("sales.anonymous_session", { channel: channel.label, connection: detail?.connection?.name ?? t("sales.widget") }), muted: true });
|
||||
if (fields.length === 0 && detail?.connection) fields.push({ key: "connection", icon: "plug", text: `${channel.label} · ${detail.connection.name}`, muted: true });
|
||||
// Компания и город — из карточки контакта (решение 5), без «копировать».
|
||||
@@ -109,7 +112,7 @@ export function ClientContext({
|
||||
<div className="ctx-contact-fields">
|
||||
{fields.map((field) => (
|
||||
<div className={`ctx-contact-field ${field.muted ? "is-muted" : ""}`} key={field.key}>
|
||||
<span><Icon name={field.icon} size={15} /></span>
|
||||
<span>{field.glyph ?? <Icon name={field.icon} size={15} />}</span>
|
||||
<span>{field.text}</span>
|
||||
{field.copy && <CopyButton value={field.copy} />}
|
||||
</div>
|
||||
|
||||
@@ -199,6 +199,11 @@
|
||||
|
||||
.sales-dialog-avatar-wrap {
|
||||
position: relative;
|
||||
/* Обёртка обязана быть ростом с аватар. Строка — flex, и по умолчанию её
|
||||
ячейки растягиваются на всю высоту: у строки с метками обёртка вырастала
|
||||
вдвое, а точка режима, привязанная к её низу, уезжала под аватар. */
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -384,6 +384,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.manage": "Manage",
|
||||
"ai.material_import": "Material import",
|
||||
"ai.move": "Move",
|
||||
"ai.model_of_integration": "Same as in the integration",
|
||||
"ai.move_category": "Move to a category",
|
||||
"ai.name_group_all_takes_start": "A name and a group are all it takes to start. Instructions, knowledge and connections are set later on the card.",
|
||||
"ai.name_unique_within_its_parent": "The name is unique within its parent.",
|
||||
@@ -436,6 +437,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.replies_2": "IN REPLIES",
|
||||
"ai.rules": "Rules",
|
||||
"ai.save_reindex": "Save and reindex",
|
||||
"ai.same_as_answers": "Same as answers",
|
||||
"ai.saving": "Saving…",
|
||||
"ai.search_by_title": "Search by title",
|
||||
"ai.search_by_title_description": "Search by title and description",
|
||||
@@ -490,6 +492,10 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.up_25_mb": "· up to 25 MB",
|
||||
"ai.update": "update",
|
||||
"ai.updated": "· Updated:",
|
||||
"ai.transcription_model": "Transcription model",
|
||||
"ai.history_limit": "Conversation context, messages",
|
||||
"ai.history_limit_invalid": "A whole number from 1 to {max}",
|
||||
"ai.transcription_provider": "Voice transcription",
|
||||
"ai.updated_2": "UPDATED",
|
||||
"ai.updated_by_at": "updated by {name} · {date}",
|
||||
"ai.updated_on": "updated {date}",
|
||||
@@ -1477,6 +1483,8 @@ export const en: Record<MessageKey, Message> = {
|
||||
"settings.back_section_list": "Back to the section list",
|
||||
"settings.bot": "Bot",
|
||||
"settings.bot_token": "Bot token",
|
||||
"settings.vk_community_key": "Community access key",
|
||||
"settings.how_to_connect": "How to connect",
|
||||
"settings.bots_email_web_widget_entry": "Bots, email and the web widget are the entry points for conversations. Each is bound to the agent that answers first.",
|
||||
"settings.bucket": "Bucket",
|
||||
"settings.check": "Check",
|
||||
@@ -1507,7 +1515,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"settings.demo_provider_no_key": "Demo provider (no key)",
|
||||
"settings.demo_records_count": "{count} records",
|
||||
"settings.disabled": "Disabled",
|
||||
"settings.domain_or_ip": "Domain or IP",
|
||||
"settings.domain_or_ip": "Domain or IP, with a port if needed",
|
||||
"settings.e_g_openrouter_primary": "e.g. OpenRouter · primary",
|
||||
"settings.e_g_support_mailbox": "e.g. Support mailbox",
|
||||
"settings.edit_integration": "Edit the integration",
|
||||
|
||||
@@ -385,6 +385,7 @@ export const ru = {
|
||||
"ai.manage": "Управлять",
|
||||
"ai.material_import": "Импорт материалов",
|
||||
"ai.move": "Переместить",
|
||||
"ai.model_of_integration": "Как в интеграции",
|
||||
"ai.move_category": "Переместить в категорию",
|
||||
"ai.name_group_all_takes_start": "Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.",
|
||||
"ai.name_unique_within_its_parent": "Имя уникально внутри родителя.",
|
||||
@@ -437,6 +438,7 @@ export const ru = {
|
||||
"ai.replies_2": "В ОТВЕТАХ",
|
||||
"ai.rules": "Правила",
|
||||
"ai.save_reindex": "Сохранить и переиндексировать",
|
||||
"ai.same_as_answers": "Как у ответов",
|
||||
"ai.saving": "Сохранение…",
|
||||
"ai.search_by_title": "Поиск по названию",
|
||||
"ai.search_by_title_description": "Поиск по заголовку и описанию",
|
||||
@@ -491,6 +493,10 @@ export const ru = {
|
||||
"ai.up_25_mb": "· до 25 МБ",
|
||||
"ai.update": "обновить",
|
||||
"ai.updated": "· Обновлено:",
|
||||
"ai.transcription_model": "Модель расшифровки",
|
||||
"ai.history_limit": "Контекст диалога, сообщений",
|
||||
"ai.history_limit_invalid": "Целое число от 1 до {max}",
|
||||
"ai.transcription_provider": "Расшифровка голосовых",
|
||||
"ai.updated_2": "ОБНОВЛЕНО",
|
||||
"ai.updated_by_at": "обновил {name} · {date}",
|
||||
"ai.updated_on": "обновлено {date}",
|
||||
@@ -1478,6 +1484,8 @@ export const ru = {
|
||||
"settings.back_section_list": "К списку разделов",
|
||||
"settings.bot": "Бот",
|
||||
"settings.bot_token": "Токен бота",
|
||||
"settings.vk_community_key": "Ключ доступа сообщества",
|
||||
"settings.how_to_connect": "Как подключить",
|
||||
"settings.bots_email_web_widget_entry": "Боты, почта и Web-виджет — точки входа диалогов. Каждая привязана к агенту, который отвечает первым.",
|
||||
"settings.bucket": "Бакет",
|
||||
"settings.check": "Проверить",
|
||||
@@ -1508,7 +1516,7 @@ export const ru = {
|
||||
"settings.demo_provider_no_key": "Демо-провайдер (без ключа)",
|
||||
"settings.demo_records_count": "{count} записей",
|
||||
"settings.disabled": "Отключено",
|
||||
"settings.domain_or_ip": "Домен или IP",
|
||||
"settings.domain_or_ip": "Домен или IP, при необходимости с портом",
|
||||
"settings.e_g_openrouter_primary": "например, OpenRouter · основной",
|
||||
"settings.e_g_support_mailbox": "например, Почта поддержки",
|
||||
"settings.edit_integration": "Изменить интеграцию",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Icon, MaxLogo, TelegramLogo } from "./icons";
|
||||
import { Icon, MaxLogo, TelegramLogo, VkLogo } from "./icons";
|
||||
import { providerKey } from "./providers";
|
||||
|
||||
/** Иконка канала без подложки (дизайн-базлайн v2, решение 4): Telegram/MAX —
|
||||
* фирменные глифы, Email/Web — линейные. */
|
||||
/** Иконка канала без подложки (дизайн-базлайн v2, решение 4): Telegram, MAX и
|
||||
* ВКонтакте — фирменные глифы, Email/Web — линейные. */
|
||||
export function ChannelGlyph({ provider, size = 13 }: { provider: string; size?: number }) {
|
||||
const key = providerKey(provider);
|
||||
if (key === "TG") return <TelegramLogo size={size} />;
|
||||
if (key === "MAX") return <MaxLogo size={size} />;
|
||||
if (key === "VK") return <VkLogo size={size} />;
|
||||
if (key === "EMAIL") return <Icon name="mail" size={size} />;
|
||||
return <Icon name="message" size={size} />;
|
||||
}
|
||||
@@ -9,8 +9,11 @@ type FormFieldProps = {
|
||||
label: string;
|
||||
mono?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
/** Значение уходит на сервер по потере фокуса: запрос на каждую букву — это
|
||||
* запрос на каждую букву. */
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
type?: "password" | "text";
|
||||
type?: "number" | "password" | "text";
|
||||
value: string;
|
||||
wide?: boolean;
|
||||
};
|
||||
@@ -21,6 +24,7 @@ export function FormField({
|
||||
label,
|
||||
mono = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
placeholder = "",
|
||||
type = "text",
|
||||
value,
|
||||
@@ -39,7 +43,7 @@ export function FormField({
|
||||
return (
|
||||
<label className={className}>
|
||||
<span>{label}</span>
|
||||
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} />
|
||||
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} onBlur={onBlur} />
|
||||
{error && <small className="form-field-error" role="alert">{error}</small>}
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* Ссылки на Центр помощи продукта.
|
||||
|
||||
Центр помощи один на продукт и живёт на домене разработчика: это
|
||||
документация Chatballs, а не портал организации, поэтому адрес здесь
|
||||
постоянный и от установки не зависит. Порталы самой организации
|
||||
настраиваются в разделе «Порталы» и к этим ссылкам отношения не имеют. */
|
||||
|
||||
const HELP_CENTER_URL = "https://chatballs.com.edevs.tech";
|
||||
|
||||
export function helpArticleUrl(slug: string): string {
|
||||
return `${HELP_CENTER_URL}/articles/${slug}`;
|
||||
}
|
||||
@@ -57,6 +57,14 @@ export function MaxLogo({ size = 24 }: { size?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function VkLogo({ size = 24 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor">
|
||||
<path d="m9.489.004.729-.003h3.564l.73.003.914.01.433.007.418.011.403.014.388.016.374.021.36.025.345.03.333.033c1.74.196 2.933.616 3.833 1.516.9.9 1.32 2.092 1.516 3.833l.034.333.029.346.025.36.02.373.025.588.012.41.013.644.009.915.004.98-.001 3.313-.003.73-.01.914-.007.433-.011.418-.014.403-.016.388-.021.374-.025.36-.03.345-.033.333c-.196 1.74-.616 2.933-1.516 3.833-.9.9-2.092 1.32-3.833 1.516l-.333.034-.346.029-.36.025-.373.02-.588.025-.41.012-.644.013-.915.009-.98.004-3.313-.001-.73-.003-.914-.01-.433-.007-.418-.011-.403-.014-.388-.016-.374-.021-.36-.025-.345-.03-.333-.033c-1.74-.196-2.933-.616-3.833-1.516-.9-.9-1.32-2.092-1.516-3.833l-.034-.333-.029-.346-.025-.36-.02-.373-.025-.588-.012-.41-.013-.644-.009-.915-.004-.98.001-3.313.003-.73.01-.914.007-.433.011-.418.014-.403.016-.388.021-.374.025-.36.03-.345.033-.333c.196-1.74.616-2.933 1.516-3.833.9-.9 2.092-1.32 3.833-1.516l.333-.034.346-.029.36-.025.373-.02.588-.025.41-.012.644-.013.915-.009ZM6.79 7.3H4.05c.13 6.24 3.25 9.99 8.72 9.99h.31v-3.57c2.01.2 3.53 1.67 4.14 3.57h2.84c-.78-2.84-2.83-4.41-4.11-5.01 1.28-.74 3.08-2.54 3.51-4.98h-2.58c-.56 1.98-2.22 3.78-3.8 3.95V7.3H10.5v6.92c-1.6-.4-3.62-2.34-3.71-6.92Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "bellOff" | "chevron" | "chevronLeft" | "chevronRight" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "alert" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "close" | "folder" | "grip" | "reply" | "sort" | "collapseLeft" | "mic" | "text" | "pin" | "smile" | "globe" | "doc" | "sparkles" | "database" | "sun" | "sunny" | "moon" | "monitor" | "laptop" | "smartphone" | "arrowDown" | "link" | "paint" | "widget" | "danger" | "image" | "file" | "bold" | "italic" | "code" | "numlist" | "quote" | "table" | "attach" | "undo" | "upload" | "import" | "thumbUp" | "thumbDown" | "book" | "move"; size?: number; strokeWidth?: number }) {
|
||||
const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth, strokeLinecap: "round", strokeLinejoin: "round" } as const;
|
||||
const paths: Record<typeof name, ReactNode> = {
|
||||
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user