mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f57b1ee54a | ||
|
|
64266dcc9c | ||
|
|
cce87a4973 | ||
|
|
556246865e | ||
|
|
0aff0c564b | ||
|
|
0d307c7d06 | ||
|
|
4e56b10e6a | ||
|
|
bcb66fadbc | ||
|
|
2ffad1e787 | ||
|
|
c69cd1b436 | ||
|
|
1f46ed62bd | ||
|
|
83b1119d6b | ||
|
|
c8b8246b35 | ||
|
|
b95a4edf6e | ||
|
|
19448eb05a | ||
|
|
a321109a8f | ||
|
|
e1cda4dd8b | ||
|
|
fed079e02d | ||
|
|
89c44db2bc | ||
|
|
ca23cd285f | ||
|
|
d34aea8a8a | ||
|
|
eb98b9b781 | ||
|
|
31985fc35c | ||
|
|
1ea0faf1c6 | ||
|
|
a41b23a5e2 | ||
|
|
089dd6ddc2 | ||
|
|
ae46508d22 | ||
|
|
bd3b9309bf | ||
|
|
e35133b159 | ||
|
|
e9b64b6afc | ||
|
|
65f7de6bc3 | ||
|
|
290b4ccea9 | ||
|
|
ca1b892e11 | ||
|
|
e5d3e98ce3 | ||
|
|
129e4a40ec | ||
|
|
9b54c544af | ||
|
|
e8155db4f9 | ||
|
|
d9bc540bc3 | ||
|
|
1de6239eb4 | ||
|
|
73ed0ae6cd | ||
|
|
8d334de3f3 | ||
|
|
733b44f186 | ||
|
|
5dbc062b1e | ||
|
|
ee8367e205 | ||
|
|
839a1fe815 | ||
|
|
c62cb903ab | ||
|
|
0bd76042d2 | ||
|
|
c286ba7903 | ||
|
|
234a3e43da | ||
|
|
17b0c9387f | ||
|
|
27091c0234 | ||
|
|
3c01486aa3 | ||
|
|
15ff9bf2c3 | ||
|
|
5d8362e8f6 | ||
|
|
eeed0ed51a | ||
|
|
42a468ec97 | ||
|
|
44718142b3 | ||
|
|
4cba376c0e | ||
|
|
a4c6b960a8 | ||
|
|
0098e147ac | ||
|
|
4ab26a1bcd | ||
|
|
09fb0177b1 | ||
|
|
b76871caf0 | ||
|
|
8580b480e7 | ||
|
|
eff6d7ac0c | ||
|
|
1cff9e2183 | ||
|
|
d8420e4379 | ||
|
|
21b8100598 | ||
|
|
1da84c9734 | ||
|
|
82805f7b1a | ||
|
|
eca0e52667 | ||
|
|
54c35e281a | ||
|
|
5396dda8ad | ||
|
|
29164db094 | ||
|
|
c5a53ccec5 | ||
|
|
049217505a | ||
|
|
132c684630 | ||
|
|
bfe90e9abd | ||
|
|
5b49904d6a | ||
|
|
cd50cce5a8 | ||
|
|
a4ca7e9373 | ||
|
|
145c44a17d | ||
|
|
ac824e192c | ||
|
|
3e790af6e5 | ||
|
|
1390ccc426 | ||
|
|
2e52bdaea0 |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -23,9 +23,9 @@ jobs:
|
||||
name: ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -46,9 +46,9 @@ jobs:
|
||||
name: deployment CLI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -39,12 +39,13 @@ env:
|
||||
GATEWAY_BASE_IMAGE: caddy:2.8.4
|
||||
REDIS_IMAGE: redis:7-alpine
|
||||
COTURN_IMAGE: coturn/coturn:4.6
|
||||
UPDATER_BASE_IMAGE: docker:27-cli
|
||||
|
||||
jobs:
|
||||
images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Версия релиза
|
||||
id: version
|
||||
@@ -57,9 +58,9 @@ jobs:
|
||||
echo "value=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -78,13 +79,16 @@ jobs:
|
||||
echo "gateway=${GATEWAY_BASE_IMAGE}@$(digest_of "$GATEWAY_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "redis=${REDIS_IMAGE}@$(digest_of "$REDIS_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "coturn=${COTURN_IMAGE}@$(digest_of "$COTURN_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "updater=${UPDATER_BASE_IMAGE}@$(digest_of "$UPDATER_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Backend
|
||||
id: backend
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/apps/backend/Dockerfile.production
|
||||
build-args: |
|
||||
CHATBALLS_VERSION=${{ steps.version.outputs.value }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/backend:${{ steps.version.outputs.value }}
|
||||
@@ -94,7 +98,7 @@ jobs:
|
||||
|
||||
- name: Frontend
|
||||
id: frontend
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/frontend.Dockerfile
|
||||
@@ -109,7 +113,7 @@ jobs:
|
||||
# а не монтируются с хоста. Ради этого установка и стала одним файлом.
|
||||
- name: Gateway
|
||||
id: gateway
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/gateway.Dockerfile
|
||||
@@ -124,7 +128,7 @@ jobs:
|
||||
|
||||
- name: Postgres
|
||||
id: postgres
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/postgres.Dockerfile
|
||||
@@ -137,6 +141,23 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Сервис обновления по кнопке (ADR-CHATBALLS-0049): docker CLI с compose
|
||||
# и два сценария внутри; единственный, кому монтируется docker.sock.
|
||||
- name: Updater
|
||||
id: updater
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/updater.Dockerfile
|
||||
build-args: |
|
||||
CHATBALLS_UPDATER_BASE_IMAGE=${{ steps.bases.outputs.updater }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:${{ steps.version.outputs.value }}
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Релизный compose.yaml и release.env
|
||||
id: artifacts
|
||||
run: |
|
||||
@@ -148,6 +169,7 @@ jobs:
|
||||
frontend="$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
|
||||
gateway="$prefix/gateway:$version@${{ steps.gateway.outputs.digest }}"
|
||||
postgres="$prefix/postgres:$version@${{ steps.postgres.outputs.digest }}"
|
||||
updater="$prefix/updater:$version@${{ steps.updater.outputs.digest }}"
|
||||
redis="${{ steps.bases.outputs.redis }}"
|
||||
coturn="${{ steps.bases.outputs.coturn }}"
|
||||
|
||||
@@ -160,7 +182,8 @@ jobs:
|
||||
--pin "CHATBALLS_GATEWAY_IMAGE=$gateway" \
|
||||
--pin "CHATBALLS_POSTGRES_IMAGE=$postgres" \
|
||||
--pin "CHATBALLS_REDIS_IMAGE=$redis" \
|
||||
--pin "CHATBALLS_COTURN_IMAGE=$coturn"
|
||||
--pin "CHATBALLS_COTURN_IMAGE=$coturn" \
|
||||
--pin "CHATBALLS_UPDATER_IMAGE=$updater"
|
||||
|
||||
{
|
||||
echo "# release.env — digest-пины релиза $version для \`chatballs deploy\`."
|
||||
@@ -172,6 +195,7 @@ jobs:
|
||||
echo "CHATBALLS_POSTGRES_IMAGE=$postgres"
|
||||
echo "CHATBALLS_REDIS_IMAGE=$redis"
|
||||
echo "CHATBALLS_COTURN_IMAGE=$coturn"
|
||||
echo "CHATBALLS_UPDATER_IMAGE=$updater"
|
||||
} > dist/release.env
|
||||
|
||||
# Файл, который скачает человек, обязан быть валидным сам по себе —
|
||||
@@ -180,7 +204,7 @@ jobs:
|
||||
|
||||
cat dist/compose.yaml
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-compose
|
||||
path: |
|
||||
@@ -189,7 +213,7 @@ jobs:
|
||||
|
||||
- name: Приложить к релизу
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
dist/compose.yaml
|
||||
|
||||
@@ -32,3 +32,6 @@ Chatballs.zip
|
||||
|
||||
# Служебный каталог агента: локальные настройки запуска, а не часть продукта.
|
||||
.claude/
|
||||
|
||||
# Дизайн-хендоффы: рабочие материалы, а не часть продукта.
|
||||
design/
|
||||
@@ -1,5 +1,9 @@
|
||||
Если в окружении не доступен инструмент, например - Python, PHP, попробуй docker, если в проекте есть его файлы.
|
||||
|
||||
Тесты: гонять только те, что относятся к изменённому в текущем шаге. Полный
|
||||
прогон — только по моему явному указанию, никогда по своей инициативе. Полный
|
||||
набор идёт ~20 минут, и гонять его после каждой правки запрещено.
|
||||
|
||||
Без моего явного указания не меняй ничего!
|
||||
Если я задал вопросы, это не значит что ты можешь менять файлы!
|
||||
Любые правки только после моего явного указания, например - "делай".
|
||||
@@ -16,7 +20,7 @@ Production deployment / миграция:
|
||||
- Перед любым изменяющим действием в production сначала представить владельцу точный план миграции и получить его явное согласование. Разрешение на диагностику или общая просьба «исправить» не являются разрешением самостоятельно выбирать архитектуру миграции.
|
||||
|
||||
UI / дизайн:
|
||||
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утвержденной документации или design-system.
|
||||
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утверждённом дизайн-макете (`design/baseline/<фича>/*.dc.html`). Макет — источник истины, README рядом с ним лишь пересказ.
|
||||
- Если UI-этап еще не наступил, UI не считается реализованным и не должен маскироваться под готовый продуктовый интерфейс.
|
||||
- Для построения UI использовать существующие компоненты и их стили, если они уже реализованы; если подходящего компонента нет, создавать переиспользуемый компонент в рамках существующей системы.
|
||||
- Не упрощать UI, анимации, иконки, состояния или поведение по своему усмотрению. Любое отклонение от baseline требует явного согласования до правок.
|
||||
@@ -56,7 +60,11 @@ Engineering rules / обязательные практики:
|
||||
- Демо-набор ставится на языке организации: манифесты лежат в `demo_seed/data/<язык>/`, наборы ключей в них совпадают. Бинарные вложения (аватары, голосовые) общие, текстовые документы — свои на каждый язык.
|
||||
|
||||
Definition of Done:
|
||||
- Запускать только тесты, относящиеся к изменениям текущей итерации. Полный прогон всех тестов выполнять только по явному указанию владельца.
|
||||
- Запускать только тесты, относящиеся к изменениям текущей итерации: изменил
|
||||
presence — гоняешь тесты присутствия, изменил тексты — гоняешь каталог i18n.
|
||||
Полный прогон всех тестов выполнять ТОЛЬКО по явному указанию владельца.
|
||||
Использовать `--reuse-db`; `--create-db` — лишняя минута на переигрывание
|
||||
миграций, она нужна только когда схема действительно поменялась.
|
||||
- Измененные файлы должны быть проверены на NO GOD violations.
|
||||
- Новый компонент не должен владеть несвязанными ответственностями.
|
||||
- Не должно быть придуманных текстов, иконок, layout-решений или состояний вне design/docs.
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +61,9 @@
|
||||
import surfaces
|
||||
}
|
||||
|
||||
# Дополнительные сайты из постоянного тома сохраняются при обновлении шлюза.
|
||||
import /config/sites/*.caddy
|
||||
|
||||
# Любой хост, которому ask-эндпоинт разрешил сертификат.
|
||||
https:// {
|
||||
encode gzip zstd
|
||||
|
||||
@@ -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)
|
||||
@@ -32,7 +46,7 @@
|
||||
- [Step 2. First-run wizard](#step-2-first-run-wizard)
|
||||
- [Step 3. Configure in the UI](#step-3-configure-in-the-ui)
|
||||
- [Website widget](#website-widget)
|
||||
- [Calls relay (optional)](#calls-relay-optional)
|
||||
- [Calls](#calls)
|
||||
- [External file storage (optional)](#external-file-storage-optional)
|
||||
- [Updating](#updating)
|
||||
- [Features](#features)
|
||||
@@ -43,9 +57,9 @@
|
||||
|
||||
## 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 and set your own budget.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,7 +72,7 @@ The platform installs on your own server with a single command. Customer data st
|
||||
| **Server** | Linux, x86_64 |
|
||||
| **Software** | Docker with the Docker Compose plugin |
|
||||
| **Ports** | 80 and 443 open |
|
||||
| **Calls relay (optional)** | A dedicated public IP, port 3478 and UDP range 49160–49999 |
|
||||
| **Ports for calls** | 3478 (UDP and TCP) and the UDP range 49160–49999 |
|
||||
|
||||
A domain is not needed to start. The installation opens by the server's IP address; the domain is set later in the settings.
|
||||
|
||||
@@ -100,8 +114,8 @@ 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. |
|
||||
| **Agents** | Create an AI agent: who it is, how it speaks, what rules it follows. Choose the model and a daily budget. Attach articles from the knowledge base. |
|
||||
| **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. |
|
||||
|
||||
The home screen shows a launch checklist: create an agent, connect an entry point, invite employees.
|
||||
@@ -116,15 +130,16 @@ After creating a web widget, add one tag to your site:
|
||||
|
||||
The chat opens in an isolated window on top of the site.
|
||||
|
||||
### Calls relay (optional)
|
||||
### Calls
|
||||
|
||||
Audio and video calls run directly between browsers. If customers or employees sit behind strict NAT or a corporate firewall, enable the TURN relay:
|
||||
Calls work right after the installation. Between browsers the conversation goes directly; when one side sits behind strict NAT or on a VPN it goes through the relay, which starts together with the stack on the same address. Nothing to configure: the relay addresses appear in **Settings → TURN for calls** on their own, derived from the installation address, and are only changed if you run your own server.
|
||||
|
||||
```bash
|
||||
COMPOSE_PROFILES=calls CHATBALLS_CALL_TURN_REALM=<domain> CHATBALLS_TURN_EXTERNAL_IP=<public IP> CHATBALLS_TURN_LISTENING_IP=<IP for TURN> docker compose up -d --wait
|
||||
```
|
||||
Open on the firewall:
|
||||
|
||||
The relay listens on a dedicated IP so that port 443 does not conflict with the web gateway. The certificate for TURN over TLS is placed in the directory set by `CHATBALLS_TURN_CERTS_DIR`. TURN addresses are then entered in **Settings → Communication**.
|
||||
- 3478/udp and 3478/tcp — the relay itself;
|
||||
- 49160–49999/udp — the conversation ports (two per call).
|
||||
|
||||
Networks that allow nothing but port 443 will not reach the relay on 3478. They need TURN over TLS on 443, which means a separate public address (443 on the main one belongs to the web gateway) or an external TURN service — its addresses go into the same settings.
|
||||
|
||||
### External file storage (optional)
|
||||
|
||||
@@ -132,7 +147,9 @@ By default files are stored in a Docker volume. In **Settings → Storage** the
|
||||
|
||||
### Updating
|
||||
|
||||
Download the new release's `compose.yaml` over the old one and restart:
|
||||
When a new release is out, the installation administrator sees a banner in the interface and updates with one button; the same lives in **Settings → Platform → Updates**. The installation updates itself on the server: it downloads the release `compose.yaml`, pulls the images and restarts the services, with about a minute of downtime.
|
||||
|
||||
Manually, from the server console: download the new release's `compose.yaml` over the old one and restart:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
@@ -154,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
|
||||
|
||||
@@ -170,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
|
||||
|
||||
@@ -190,7 +207,7 @@ Waiting conversations and new messages reach the employee in Telegram or MAX. Li
|
||||
|
||||
### Your own server and your own AI model
|
||||
|
||||
Installs with one command, data stays with you. Connect any AI model provider with your own key: OpenRouter, an OpenAI-compatible service, a local model. A daily budget per agent in dollars, token and cost accounting for every call.
|
||||
Installs with one command, data stays with you. Connect any AI model provider with your own key: OpenRouter, an OpenAI-compatible service, a local model.
|
||||
|
||||
### Customer data protection
|
||||
|
||||
@@ -242,8 +259,7 @@ Check in order:
|
||||
1. The agent status is **Active**, not **Draft**.
|
||||
2. The agent has an AI model provider selected. Without it no answer is possible.
|
||||
3. The provider in **Integrations** has the **Connected** status. Run the check to refresh it.
|
||||
4. The agent's daily budget is not exhausted. Blocked calls are visible in the AI usage log.
|
||||
5. The conversation is not switched to **Operator** or **Paused** mode.
|
||||
4. The conversation is not switched to **Operator** or **Paused** mode.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -253,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:
|
||||
|
||||
@@ -269,7 +285,7 @@ docker compose logs worker
|
||||
<details>
|
||||
<summary><strong>Calls do not connect</strong></summary>
|
||||
|
||||
Between browsers a call goes directly. If one side is behind strict NAT, a relay is needed: enable the `calls` profile and enter the TURN addresses in **Settings**. Check that port 3478 and the UDP range 49160–49999 are open on the firewall. Make sure the relay listens on a separate IP and does not overlap with the web gateway on port 443.
|
||||
Between browsers a call goes directly; behind strict NAT and on a VPN it goes through the relay. Check that the `coturn` container runs (`docker compose ps coturn`) and that 3478/udp, 3478/tcp and the 49160–49999/udp range are open on the firewall. The addresses in **Settings → TURN for calls** must not be empty: they are derived from the installation address, so that address has to be set first.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -311,13 +327,13 @@ All services should be `healthy` or `running`. Application readiness is availabl
|
||||
<details>
|
||||
<summary><strong>Do not delete the secrets volume</strong></summary>
|
||||
|
||||
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. Include this volume in backups together with the database and files.
|
||||
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. The `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes hold the database role passwords: without them the stack cannot connect to its own database. Include all three volumes in backups together with the database and files.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Backup</strong></summary>
|
||||
|
||||
Copy the `chatballs-postgres`, `chatballs-media` and `chatballs-secrets` volumes. A database dump can be taken as well:
|
||||
Copy the `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes. A database dump can be taken as well:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
|
||||
|
||||
+38
-22
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Что это](#что-это)
|
||||
@@ -32,7 +46,7 @@
|
||||
- [Шаг 2. Мастер первого запуска](#шаг-2-мастер-первого-запуска)
|
||||
- [Шаг 3. Настройка в интерфейсе](#шаг-3-настройка-в-интерфейсе)
|
||||
- [Виджет на сайте](#виджет-на-сайте)
|
||||
- [Звонки через relay (опционально)](#звонки-через-relay-опционально)
|
||||
- [Звонки](#звонки)
|
||||
- [Внешнее хранилище файлов (опционально)](#внешнее-хранилище-файлов-опционально)
|
||||
- [Обновление](#обновление)
|
||||
- [Функции](#функции)
|
||||
@@ -43,9 +57,9 @@
|
||||
|
||||
## Что это
|
||||
|
||||
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
|
||||
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, ВКонтакте, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
|
||||
|
||||
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу и сами задаёте бюджет.
|
||||
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,7 +72,7 @@ Chatballs берёт на себя первую линию общения с к
|
||||
| **Сервер** | Linux, x86_64 |
|
||||
| **ПО** | Docker и плагин Docker Compose |
|
||||
| **Порты** | 80 и 443 открыты |
|
||||
| **Relay для звонков (опционально)** | Выделенный публичный IP, порт 3478 и диапазон UDP 49160–49999 |
|
||||
| **Порты для звонков** | 3478 (UDP и TCP) и диапазон UDP 49160–49999 |
|
||||
|
||||
Домен на старте не нужен. Установка открывается по IP-адресу сервера, домен задаётся позже в настройках.
|
||||
|
||||
@@ -100,8 +114,8 @@ docker compose up -d --wait
|
||||
| Раздел | Что сделать |
|
||||
|---|---|
|
||||
| **Платформа** | Укажите домен установки. Шлюз сам выпустит сертификат Let's Encrypt и переведёт работу на HTTPS. Здесь же задаётся исходящая почта по SMTP: она нужна для приглашений сотрудников и восстановления паролей. |
|
||||
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
|
||||
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель и дневной бюджет. Прикрепите статьи из базы знаний. |
|
||||
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, сообщество ВКонтакте, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
|
||||
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель. Прикрепите статьи из базы знаний. |
|
||||
| **Сотрудники** | Пригласите команду по почте, распределите роли и группы. |
|
||||
|
||||
На главном экране есть чек-лист запуска: создать агента, подключить точку входа, пригласить сотрудников.
|
||||
@@ -116,15 +130,16 @@ docker compose up -d --wait
|
||||
|
||||
Чат откроется в изолированном окне поверх сайта.
|
||||
|
||||
### Звонки через relay (опционально)
|
||||
### Звонки
|
||||
|
||||
Аудио- и видеозвонки работают напрямую между браузерами. Если клиенты или сотрудники сидят за строгим NAT или корпоративным файрволом, включите TURN-relay:
|
||||
Звонки работают сразу после установки. Между браузерами разговор идёт напрямую, а если одна из сторон за строгим NAT или в VPN — через relay, который поднимается вместе со стеком на том же адресе. Настраивать нечего: адреса relay появляются в **Настройки → TURN для звонков** сами, от адреса установки, и меняются только если вы ставите свой сервер.
|
||||
|
||||
```bash
|
||||
COMPOSE_PROFILES=calls CHATBALLS_CALL_TURN_REALM=<домен> CHATBALLS_TURN_EXTERNAL_IP=<публичный IP> CHATBALLS_TURN_LISTENING_IP=<IP для TURN> docker compose up -d --wait
|
||||
```
|
||||
На файрволе нужно открыть:
|
||||
|
||||
Relay слушает выделенный IP, чтобы порт 443 не конфликтовал с веб-шлюзом. Сертификат для TURN-over-TLS кладётся в каталог, заданный переменной `CHATBALLS_TURN_CERTS_DIR`. Адреса TURN затем указываются в **Настройки → Коммуникации**.
|
||||
- 3478/udp и 3478/tcp — сам relay;
|
||||
- 49160–49999/udp — порты разговоров (по два на звонок).
|
||||
|
||||
Сети, где наружу разрешён только порт 443, relay на 3478 не пройдут. Для них нужен TURN-over-TLS на 443, а это отдельный публичный адрес (443 на основном занят веб-шлюзом) либо внешний TURN-сервис — его адреса вписываются в те же настройки.
|
||||
|
||||
### Внешнее хранилище файлов (опционально)
|
||||
|
||||
@@ -132,7 +147,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
|
||||
|
||||
### Обновление
|
||||
|
||||
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
|
||||
|
||||
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
@@ -154,7 +171,7 @@ docker compose pull && docker compose up -d --wait
|
||||
|
||||
### Все каналы в одном окне
|
||||
|
||||
Telegram, MAX, электронная почта и чат на сайте попадают в единый список диалогов. Сотрудник видит, откуда пришёл клиент, и отвечает в том же канале.
|
||||
Telegram, MAX, ВКонтакте, электронная почта и чат на сайте попадают в единый список диалогов. Сотрудник видит, откуда пришёл клиент, и отвечает в том же канале.
|
||||
|
||||
### База знаний с семантическим поиском
|
||||
|
||||
@@ -170,7 +187,7 @@ Telegram, MAX, электронная почта и чат на сайте по
|
||||
|
||||
### Аудио- и видеозвонки из чата
|
||||
|
||||
Клиент и сотрудник созваниваются прямо из диалога без сторонних сервисов. Работает в веб-виджете, Telegram и MAX. Для сложных сетей есть relay.
|
||||
Клиент и сотрудник созваниваются прямо из диалога без сторонних сервисов. Работает в веб-виджете, Telegram, MAX и ВКонтакте. Для сложных сетей есть relay.
|
||||
|
||||
### Рабочее место оператора
|
||||
|
||||
@@ -190,7 +207,7 @@ Telegram, MAX, электронная почта и чат на сайте по
|
||||
|
||||
### Свой сервер и своя ИИ-модель
|
||||
|
||||
Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель. Дневной бюджет на агента в долларах, учёт токенов и стоимости по каждому вызову.
|
||||
Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель.
|
||||
|
||||
### Защита данных клиентов
|
||||
|
||||
@@ -242,8 +259,7 @@ docker compose logs gateway
|
||||
1. Агент в статусе **Активен**, а не **Черновик**.
|
||||
2. У агента выбран провайдер ИИ-моделей. Без него ответ невозможен.
|
||||
3. Провайдер в **Интеграциях** имеет статус **Подключено**. Нажмите проверку, чтобы обновить статус.
|
||||
4. Не исчерпан дневной бюджет агента. Заблокированные вызовы видны в учёте ИИ.
|
||||
5. Диалог не переведён в режим **Оператор** или **Пауза**.
|
||||
4. Диалог не переведён в режим **Оператор** или **Пауза**.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -253,7 +269,7 @@ docker compose logs gateway
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Сообщения из Telegram или MAX не приходят</strong></summary>
|
||||
<summary><strong>Сообщения из Telegram, MAX или ВКонтакте не приходят</strong></summary>
|
||||
|
||||
Фоновый воркер опрашивает ботов. Проверьте, что он запущен:
|
||||
|
||||
@@ -269,7 +285,7 @@ docker compose logs worker
|
||||
<details>
|
||||
<summary><strong>Звонки не соединяются</strong></summary>
|
||||
|
||||
Между браузерами звонок идёт напрямую. Если одна из сторон за строгим NAT, нужен relay: включите профиль `calls` и укажите адреса TURN в **Настройках**. Проверьте, что на файрволе открыты порт 3478 и диапазон UDP 49160–49999. Убедитесь, что relay слушает отдельный IP и не пересекается с веб-шлюзом по порту 443.
|
||||
Между браузерами звонок идёт напрямую, за строгим NAT и в VPN — через relay. Проверьте, что контейнер `coturn` работает (`docker compose ps coturn`), а на файрволе открыты 3478/udp, 3478/tcp и диапазон 49160–49999/udp. В **Настройки → TURN для звонков** адреса должны быть непустыми: они строятся от адреса установки, поэтому сначала должен быть задан сам адрес.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -311,13 +327,13 @@ docker compose ps
|
||||
<details>
|
||||
<summary><strong>Не удаляйте том с секретами</strong></summary>
|
||||
|
||||
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. Включайте этот том в резервные копии вместе с базой и файлами.
|
||||
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. В томах `chatballs-secrets-platform` и `chatballs-secrets-schema` лежат пароли ролей базы: без них стек не подключится к собственной базе. Включайте все три тома в резервные копии вместе с базой и файлами.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Резервная копия</strong></summary>
|
||||
|
||||
Копируйте тома `chatballs-postgres`, `chatballs-media` и `chatballs-secrets`. Для базы можно снять дамп:
|
||||
Копируйте тома `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` и `chatballs-secrets-schema`. Для базы можно снять дамп:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
|
||||
|
||||
@@ -6,6 +6,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Версия релиза попадает в образ при сборке: по ней приложение решает,
|
||||
# есть ли обновление. Сборка из исходников остаётся «dev».
|
||||
ARG CHATBALLS_VERSION=dev
|
||||
ENV CHATBALLS_VERSION=${CHATBALLS_VERSION}
|
||||
|
||||
RUN addgroup --system hub && adduser --system --ingroup hub hub
|
||||
|
||||
COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
|
||||
|
||||
@@ -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,11 +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,
|
||||
"limits": agent.limits,
|
||||
"answerLanguage": agent.answer_language,
|
||||
"historyLimit": agent.history_limit,
|
||||
"persona": agent.persona,
|
||||
"tone": agent.tone,
|
||||
"instructions": agent.instructions,
|
||||
@@ -231,12 +248,15 @@ def update_agent_card(
|
||||
|
||||
ai_fields = {
|
||||
"providerIntegrationId",
|
||||
"transcriptionIntegrationId",
|
||||
"model",
|
||||
"transcriptionModel",
|
||||
"modelParams",
|
||||
"limits",
|
||||
"persona",
|
||||
"tone",
|
||||
"instructions",
|
||||
"answerLanguage",
|
||||
"historyLimit",
|
||||
"knowledgeIds",
|
||||
}
|
||||
if ai_fields & set(body):
|
||||
@@ -249,11 +269,8 @@ def update_agent_card(
|
||||
):
|
||||
raise ValidationError({"knowledgeIds": t("api.list_of_ids_required")})
|
||||
model_params = body.get("modelParams", agent.model_params)
|
||||
limits = body.get("limits", agent.limits)
|
||||
if not isinstance(model_params, dict):
|
||||
raise ValidationError({"modelParams": t("api.object_required")})
|
||||
if not isinstance(limits, dict):
|
||||
raise ValidationError({"limits": t("api.object_required")})
|
||||
provider_integration_id = body.get(
|
||||
"providerIntegrationId", agent.provider_integration_id
|
||||
)
|
||||
@@ -261,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,
|
||||
@@ -268,15 +294,22 @@ 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,
|
||||
limits=limits,
|
||||
persona=str(body.get("persona", agent.persona)),
|
||||
tone=str(body.get("tone", agent.tone)),
|
||||
instructions=str(body.get("instructions", agent.instructions)),
|
||||
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,
|
||||
),
|
||||
)
|
||||
@@ -298,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:
|
||||
|
||||
@@ -4,10 +4,10 @@ from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.ai.models import KnowledgeAttachment
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import attachment_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
class AttachmentDownloadView(APIView):
|
||||
@@ -19,10 +19,9 @@ class AttachmentDownloadView(APIView):
|
||||
route = attachment_route(str(public_id))
|
||||
if route is None:
|
||||
raise Http404
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist as error:
|
||||
raise Http404 from error
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise Http404
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
attachment = KnowledgeAttachment.objects.filter(
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
"""Обращения к 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
|
||||
|
||||
from chatballs.ai import limits, pricing
|
||||
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
|
||||
from chatballs.ai.pii import redact
|
||||
from chatballs.ai.provider import routing
|
||||
@@ -16,33 +31,143 @@ 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 у всех сразу.
|
||||
@dataclass(slots=True)
|
||||
class _BreakerSlot:
|
||||
revision: int
|
||||
breaker: CircuitBreaker
|
||||
|
||||
|
||||
def _record_blocked(*, channel, purpose: str, model: str, error: Exception) -> None:
|
||||
_breakers: dict[tuple[int, int], _BreakerSlot] = {}
|
||||
|
||||
|
||||
def _breaker(key: tuple[int, int], revision: int) -> CircuitBreaker:
|
||||
slot = _breakers.get(key)
|
||||
if slot is None or slot.revision != revision:
|
||||
slot = _BreakerSlot(revision=revision, breaker=CircuitBreaker())
|
||||
_breakers[key] = slot
|
||||
return slot.breaker
|
||||
|
||||
|
||||
def reset_breakers() -> None:
|
||||
"""Для тестов: забыть накопленные сбои провайдеров."""
|
||||
|
||||
_breakers.clear()
|
||||
|
||||
|
||||
def _breaker_identity(channel) -> tuple[tuple[int, int], int]:
|
||||
"""Ключ предохранителя. Без канала провайдер может быть только тестовым —
|
||||
считать сбои там не по чему, и общий ключ (0, 0) никому не мешает."""
|
||||
|
||||
if channel is None:
|
||||
return (0, 0), 0
|
||||
integration_id, revision = routing.integration_runtime_identity(channel)
|
||||
return (channel.organization_id, integration_id), revision
|
||||
|
||||
|
||||
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]
|
||||
breaker_revision: int
|
||||
params: dict | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmbeddingJob:
|
||||
"""То же для эмбеддингов: вектор считается тем же провайдером организации."""
|
||||
|
||||
provider: LLMProvider
|
||||
model: str
|
||||
texts: list[str]
|
||||
breaker_key: tuple[int, int]
|
||||
breaker_revision: int
|
||||
|
||||
|
||||
def _effective_model(channel, requested_model: str | None) -> str:
|
||||
# BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции
|
||||
# организации с fallback на модель агента. Без интеграции модель остаётся
|
||||
# агентской: тестовый провайдер работает, прод упадёт в get_provider штатно.
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
fallback = str(getattr(agent, "model", "") or "")
|
||||
if requested_model:
|
||||
return requested_model
|
||||
try:
|
||||
return routing.resolve_model(channel, fallback_model=fallback)
|
||||
except routing.IntegrationNotConfigured:
|
||||
return fallback
|
||||
|
||||
|
||||
def prepare_chat(
|
||||
*,
|
||||
channel,
|
||||
messages: list[ChatMessage],
|
||||
model: str | None = None,
|
||||
params: dict | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> ChatJob:
|
||||
"""Шаг в транзакции: провайдер, модель и очищенный от ПДн текст запроса."""
|
||||
|
||||
breaker_key, breaker_revision = _breaker_identity(channel)
|
||||
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,
|
||||
breaker_revision=breaker_revision,
|
||||
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, job.breaker_revision),
|
||||
)
|
||||
|
||||
|
||||
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=model,
|
||||
status=LlmInvocationStatus.BLOCKED,
|
||||
error=str(error),
|
||||
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 _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]:
|
||||
# BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции
|
||||
# организации с fallback на модель агента. Без интеграции модель остаётся
|
||||
# агентской: тестовый провайдер работает, прод упадёт в get_provider штатно.
|
||||
agent = channel.ai_agent
|
||||
try:
|
||||
effective_model = routing.resolve_model(channel, fallback_model=agent.model)
|
||||
except routing.IntegrationNotConfigured:
|
||||
effective_model = agent.model
|
||||
return get_provider(channel=channel), requested_model or effective_model
|
||||
|
||||
|
||||
def invoke_chat(
|
||||
*,
|
||||
channel,
|
||||
@@ -52,57 +177,84 @@ def invoke_chat(
|
||||
params: dict | None = None,
|
||||
used_fragment_ids: list | None = None,
|
||||
) -> ChatResult:
|
||||
fallback_model = model or channel.ai_agent.model
|
||||
try:
|
||||
provider, model = _prepare_invocation(channel=channel, requested_model=model)
|
||||
limits.assert_within_limits(channel, channel.ai_agent)
|
||||
except limits.LimitExceeded as error:
|
||||
_record_blocked(
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
model=fallback_model,
|
||||
error=error,
|
||||
)
|
||||
raise
|
||||
"""Три шага подряд, в транзакции вызывающего: там, где ждать не жалко."""
|
||||
|
||||
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:
|
||||
"""Шаг в транзакции: провайдер эмбеддингов организации."""
|
||||
|
||||
breaker_key, breaker_revision = _breaker_identity(channel)
|
||||
return EmbeddingJob(
|
||||
provider=get_provider(channel=channel, timeout=timeout),
|
||||
model=model,
|
||||
texts=texts,
|
||||
breaker_key=breaker_key,
|
||||
breaker_revision=breaker_revision,
|
||||
)
|
||||
|
||||
|
||||
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, job.breaker_revision),
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
cost_micros=result.cost_micros
|
||||
or pricing.cost_micros(result.model, result.prompt_tokens, result.completion_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(
|
||||
@@ -113,22 +265,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,
|
||||
cost_micros=pricing.cost_micros(model, tokens, 0),
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
purpose=purpose,
|
||||
results=results,
|
||||
latency_ms=_elapsed_ms(started),
|
||||
)
|
||||
return results
|
||||
@@ -66,14 +66,11 @@ def _category_for_path(*, context: TenantContext, raw_path: object) -> Knowledge
|
||||
for raw_name in raw_path:
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
raise ValidationError({"categoryPath": t("ai.category_names_strings")})
|
||||
try:
|
||||
category = KnowledgeCategory.objects.get(
|
||||
organization_id=context.organization_id,
|
||||
parent_id=parent_id,
|
||||
name=raw_name.strip(),
|
||||
)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"categoryPath": t("ai.category_path_not_found")}) from error
|
||||
category, _ = KnowledgeCategory.objects.get_or_create(
|
||||
organization_id=context.organization_id,
|
||||
parent_id=parent_id,
|
||||
name=raw_name.strip(),
|
||||
)
|
||||
parent_id = category.id
|
||||
assert category is not None
|
||||
return category
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
|
||||
|
||||
|
||||
class LimitExceeded(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _day_start():
|
||||
now = timezone.localtime()
|
||||
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def daily_cost_micros(channel=None) -> int:
|
||||
queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)
|
||||
if channel is not None:
|
||||
queryset = queryset.filter(channel=channel)
|
||||
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0
|
||||
|
||||
|
||||
def assert_within_limits(channel, agent) -> None:
|
||||
global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS
|
||||
if global_limit and daily_cost_micros() >= global_limit:
|
||||
raise LimitExceeded("Global daily AI cost limit reached")
|
||||
# Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается
|
||||
# в micro-USD. 1 цент = 10 000 micro-USD.
|
||||
channel_limit = (agent.limits or {}).get("dailyCostUsd")
|
||||
if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:
|
||||
raise LimitExceeded("Channel daily AI cost limit reached")
|
||||
@@ -0,0 +1,18 @@
|
||||
# Дневной бюджет агента снят вместе с полем `limits`: расход считался по
|
||||
# прайс-таблице из двух моделей, а для всех остальных оставался нулевым — лимит
|
||||
# не срабатывал никогда. Единственный оставшийся предохранитель — общий лимит
|
||||
# установки из переменной окружения (CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS).
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("ai", "0017_agent_answer_language"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="aiagent",
|
||||
name="limits",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Учёт стоимости вызовов удалён вместе с лимитами. Считать было нечем: цена
|
||||
# бралась из ответа провайдера, а его присылает только OpenRouter; на остальных
|
||||
# оставалась прайс-таблица из двух моделей и ноль для всех прочих. Ни одна
|
||||
# цифра расхода в продукте не показывалась.
|
||||
#
|
||||
# Статус BLOCKED уходит вместе с лимитами — блокировать вызовы больше нечему.
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def drop_blocked_rows(apps, schema_editor):
|
||||
"""Строк со снятым статусом в журнале остаться не должно."""
|
||||
|
||||
apps.get_model("ai", "LlmInvocation").objects.filter(status="BLOCKED").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("ai", "0018_remove_agent_limits"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(drop_blocked_rows, migrations.RunPython.noop),
|
||||
migrations.RemoveField(model_name="llminvocation", name="cost_micros"),
|
||||
migrations.RemoveField(model_name="llminvocation", name="currency"),
|
||||
migrations.AlterField(
|
||||
model_name="llminvocation",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[("SUCCESS", "Успех"), ("ERROR", "Ошибка")],
|
||||
default="SUCCESS",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
# Язык ответов клиенту. По умолчанию агент отвечает на языке, на котором
|
||||
# к нему обратились: сигнал точный, лежит прямо в сообщении и не требует
|
||||
@@ -417,10 +444,6 @@ class AIAgent(TenantRelationModel):
|
||||
|
||||
allowed_tools = models.JSONField(default=list, blank=True)
|
||||
|
||||
# Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD).
|
||||
|
||||
limits = models.JSONField(default=dict, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -463,8 +486,6 @@ class LlmInvocationStatus(models.TextChoices):
|
||||
|
||||
ERROR = "ERROR", "Ошибка"
|
||||
|
||||
BLOCKED = "BLOCKED", "Заблокировано лимитом"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -489,10 +510,6 @@ class LlmInvocation(TenantRelationModel):
|
||||
|
||||
total_tokens = models.PositiveIntegerField(default=0)
|
||||
|
||||
cost_micros = models.PositiveBigIntegerField(default=0)
|
||||
|
||||
currency = models.CharField(max_length=3, default="USD")
|
||||
|
||||
latency_ms = models.PositiveIntegerField(default=0)
|
||||
|
||||
status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from django.conf import settings
|
||||
|
||||
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
|
||||
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
|
||||
# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.
|
||||
DEFAULT_PRICING = {
|
||||
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
|
||||
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
|
||||
}
|
||||
|
||||
|
||||
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
|
||||
table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}
|
||||
price = table.get(model)
|
||||
if not price:
|
||||
return 0
|
||||
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
|
||||
@@ -18,9 +18,6 @@ class ChatResult:
|
||||
model: str
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
# Фактическая стоимость, сообщённая провайдером (micro-USD). 0 — провайдер не
|
||||
# вернул цену, тогда считаем по прайс-таблице (ai/pricing.py).
|
||||
cost_micros: int = 0
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
@@ -38,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)
|
||||
@@ -12,7 +12,7 @@ speak the same Chat Completions shape:
|
||||
|
||||
- POST /chat/completions with {model, messages, ...}; response has
|
||||
|
||||
choices[0].message.content and usage (optionally usage.cost in USD).
|
||||
choices[0].message.content and usage (prompt/completion tokens).
|
||||
|
||||
- POST /embeddings with {model, input}; response has data[].embedding and usage.
|
||||
|
||||
@@ -24,7 +24,7 @@ This module owns the HTTP transport and response parsing so the three adapters
|
||||
|
||||
do not duplicate it. Adapters stay responsible for their own product semantics
|
||||
|
||||
(name, cost handling, catalog). Stdlib only — no third-party HTTP client.
|
||||
(name, catalog). Stdlib only — no third-party HTTP client.
|
||||
|
||||
"""
|
||||
|
||||
@@ -37,7 +37,13 @@ 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 +78,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.
|
||||
@@ -116,21 +136,9 @@ def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_ur
|
||||
|
||||
def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,
|
||||
|
||||
timeout: float, proxy_url: str = "", params: dict | None = None,
|
||||
timeout: float, proxy_url: str = "", params: dict | None = None) -> ChatResult:
|
||||
|
||||
include_cost: bool = False) -> ChatResult:
|
||||
|
||||
"""POST /chat/completions and parse the OpenAI-shaped response.
|
||||
|
||||
|
||||
|
||||
`include_cost=True` requests the OpenRouter-style usage.include flag and reads
|
||||
|
||||
usage.cost (USD, converted to micros). Providers that do not report cost
|
||||
|
||||
(Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.
|
||||
|
||||
"""
|
||||
"""POST /chat/completions and parse the OpenAI-shaped response."""
|
||||
|
||||
payload: dict = {
|
||||
|
||||
@@ -142,10 +150,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
|
||||
|
||||
}
|
||||
|
||||
if include_cost:
|
||||
|
||||
payload["usage"] = {"include": True}
|
||||
|
||||
data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
|
||||
|
||||
payload=payload, timeout=timeout, proxy_url=proxy_url)
|
||||
@@ -160,8 +164,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
|
||||
|
||||
usage = data.get("usage") or {}
|
||||
|
||||
cost = usage.get("cost")
|
||||
|
||||
return ChatResult(
|
||||
|
||||
text=text,
|
||||
@@ -172,8 +174,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
|
||||
|
||||
completion_tokens=int(usage.get("completion_tokens", 0)),
|
||||
|
||||
cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,10 +22,9 @@ class OpenRouterProvider(LLMProvider):
|
||||
self.proxy_url = proxy_url or ""
|
||||
|
||||
def chat(self, *, messages: list[ChatMessage], model: str, params: dict | None = None) -> ChatResult:
|
||||
# usage.include=true — OpenRouter возвращает фактическую стоимость в usage.cost (USD).
|
||||
return openai_http.chat_completions(
|
||||
base_url=self.base_url, api_key=self.api_key, messages=messages, model=model,
|
||||
timeout=self.timeout, proxy_url=self.proxy_url, params=params, include_cost=True,
|
||||
timeout=self.timeout, proxy_url=self.proxy_url, params=params,
|
||||
)
|
||||
|
||||
def embed(self, *, texts: list[str], model: str) -> list[EmbeddingResult]:
|
||||
@@ -37,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
|
||||
|
||||
@@ -45,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",
|
||||
@@ -66,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):
|
||||
@@ -38,17 +38,21 @@ def call_with_resilience(
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
backoff: float = 0.5,
|
||||
):
|
||||
if breaker is not None:
|
||||
breaker.before()
|
||||
attempt = 0
|
||||
while True:
|
||||
if breaker is not None:
|
||||
breaker.before()
|
||||
try:
|
||||
result = func()
|
||||
except ProviderRejected:
|
||||
# Провайдер отказал по существу запроса: повторять нечего, и
|
||||
# предохранитель тут ни при чём — сам провайдер жив и отвечает.
|
||||
raise
|
||||
except ProviderError:
|
||||
if breaker is not None:
|
||||
breaker.on_failure()
|
||||
attempt += 1
|
||||
if attempt > retries:
|
||||
if breaker is not None:
|
||||
breaker.on_failure()
|
||||
raise
|
||||
sleep(backoff * attempt)
|
||||
continue
|
||||
|
||||
@@ -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,39 @@ 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 integration_runtime_identity(channel) -> tuple[int, int]:
|
||||
"""Идентификатор и версия runtime-настроек выбранного провайдера."""
|
||||
|
||||
try:
|
||||
integration = _channel_integration(channel)
|
||||
except IntegrationNotConfigured:
|
||||
return 0, 0
|
||||
return integration.id, integration.runtime_revision
|
||||
|
||||
|
||||
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 +165,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,13 +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
|
||||
limits: dict
|
||||
persona: str
|
||||
tone: str
|
||||
instructions: str
|
||||
answer_language: str
|
||||
history_limit: int
|
||||
knowledge_ids: list[int] | None # None -> выбор знаний не меняется
|
||||
|
||||
|
||||
@@ -39,20 +47,6 @@ class AgentCreateInput:
|
||||
knowledge_ids: list[int]
|
||||
|
||||
|
||||
# Единственный поддерживаемый лимит агента — дневной бюджет в целых центах USD
|
||||
# (dailyCostUsd). Прочие исторические ключи (dailyCostMicros, dailyBudgetRub,
|
||||
# dailyDialogs, maxMessagesPerDialog) бэкендом не используются и отбрасываются.
|
||||
def _normalize_limits(raw: dict | None) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
value = raw.get("dailyCostUsd")
|
||||
try:
|
||||
cents = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return {"dailyCostUsd": cents} if cents > 0 else {}
|
||||
|
||||
|
||||
def knowledge_for_agent_ids(
|
||||
*, context: TenantContext, channel: Channel, knowledge_ids: list[int]
|
||||
) -> list[Knowledge]:
|
||||
@@ -105,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,
|
||||
@@ -139,30 +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.limits = _normalize_limits(data.limits)
|
||||
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",
|
||||
"limits",
|
||||
"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)
|
||||
|
||||
@@ -65,7 +65,7 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
knowledge = Knowledge.objects.get(title="Acme support")
|
||||
self.assertEqual(knowledge.category_id, self.app.id)
|
||||
|
||||
def test_unknown_category_path_fails_per_document(self) -> None:
|
||||
def test_missing_category_is_created_under_existing_parent(self) -> None:
|
||||
response = self._import(
|
||||
[
|
||||
{
|
||||
@@ -78,15 +78,52 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["created"], 1)
|
||||
self.assertEqual(len(payload["failed"]), 1)
|
||||
self.assertFalse(Knowledge.objects.filter(title="Unknown path").exists())
|
||||
self.assertFalse(
|
||||
KnowledgeCategory.objects.filter(
|
||||
organization=self.organization,
|
||||
name="Missing",
|
||||
).exists()
|
||||
self.assertEqual(payload["created"], 2)
|
||||
self.assertEqual(payload["failed"], [])
|
||||
category = KnowledgeCategory.objects.get(
|
||||
organization=self.organization, parent=self.products, name="Missing"
|
||||
)
|
||||
self.assertEqual(Knowledge.objects.get(title="Unknown path").category_id, category.id)
|
||||
|
||||
def test_new_tree_is_shared_and_repeat_import_does_not_duplicate_categories(self) -> None:
|
||||
documents = [
|
||||
{"title": title, "content": "Text", "categoryPath": [" New root ", "Child", "Leaf"]}
|
||||
for title in ["First", "Second"]
|
||||
]
|
||||
before = KnowledgeCategory.objects.count()
|
||||
self.assertEqual(self._import(documents).json()["created"], 2)
|
||||
self.assertEqual(self._import(documents).json()["unchanged"], 2)
|
||||
self.assertEqual(KnowledgeCategory.objects.count(), before + 3)
|
||||
first = Knowledge.objects.get(title="First")
|
||||
second = Knowledge.objects.get(title="Second")
|
||||
self.assertEqual(first.category_id, second.category_id)
|
||||
self.assertEqual(first.category.parent.parent.name, "New root")
|
||||
|
||||
def test_failed_document_rolls_back_new_categories_and_other_documents_import(self) -> None:
|
||||
response = self._import([
|
||||
{"title": "Invalid", "content": "Text", "description": [],
|
||||
"categoryPath": ["Rollback root", "Child"]},
|
||||
{"title": "Valid sibling", "content": "Text", "categoryPath": ["Kept root"]},
|
||||
])
|
||||
self.assertEqual(response.json()["created"], 1)
|
||||
self.assertEqual(len(response.json()["failed"]), 1)
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Rollback root").exists())
|
||||
self.assertFalse(Knowledge.objects.filter(title="Invalid").exists())
|
||||
|
||||
def test_invalid_path_rolls_back_preceding_levels(self) -> None:
|
||||
for path in [["Invalid root", " "], ["Invalid root", 42], []]:
|
||||
with self.subTest(path=path):
|
||||
response = self._import([{"title": "Invalid path", "content": "Text", "categoryPath": path}])
|
||||
self.assertEqual(len(response.json()["failed"]), 1)
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Invalid root").exists())
|
||||
|
||||
def test_existing_document_moves_to_new_category(self) -> None:
|
||||
self._import([{"title": "Moving", "content": "Text"}])
|
||||
response = self._import([
|
||||
{"title": "Moving", "content": "Text", "categoryPath": ["New destination"]}
|
||||
])
|
||||
self.assertEqual(response.json()["updated"], 1)
|
||||
self.assertEqual(Knowledge.objects.get(title="Moving").category.name, "New destination")
|
||||
|
||||
def test_omitted_metadata_preserves_category_and_agent_links(self) -> None:
|
||||
self._import(
|
||||
@@ -157,6 +194,7 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
|
||||
{
|
||||
"title": self.support_only.title,
|
||||
"content": "Attempted overwrite",
|
||||
"categoryPath": ["Forbidden category"],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -167,3 +205,4 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.support_only.refresh_from_db()
|
||||
self.assertEqual(self.support_only.content, "")
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Forbidden category").exists())
|
||||
@@ -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,67 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from chatballs.ai.invocation import _breaker, reset_breakers
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.provider.resilience import (
|
||||
CircuitBreaker,
|
||||
CircuitBreakerOpen,
|
||||
call_with_resilience,
|
||||
)
|
||||
|
||||
|
||||
class ProviderResilienceTests(SimpleTestCase):
|
||||
def test_retries_count_as_one_logical_failure(self) -> None:
|
||||
breaker = CircuitBreaker(failure_threshold=2, reset_timeout=999)
|
||||
calls = {"n": 0}
|
||||
|
||||
def always_fail():
|
||||
calls["n"] += 1
|
||||
raise ProviderError("down")
|
||||
|
||||
for _ in range(2):
|
||||
with self.assertRaises(ProviderError):
|
||||
call_with_resilience(
|
||||
always_fail,
|
||||
retries=2,
|
||||
breaker=breaker,
|
||||
sleep=lambda _s: None,
|
||||
)
|
||||
self.assertEqual(calls["n"], 6)
|
||||
with self.assertRaises(CircuitBreakerOpen):
|
||||
call_with_resilience(always_fail, retries=2, breaker=breaker)
|
||||
self.assertEqual(calls["n"], 6)
|
||||
|
||||
def test_circuit_breaker_allows_probe_after_cooldown(self) -> None:
|
||||
now = [0.0]
|
||||
breaker = CircuitBreaker(
|
||||
failure_threshold=1,
|
||||
reset_timeout=30,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
|
||||
def fail():
|
||||
raise ProviderError("down")
|
||||
|
||||
with self.assertRaises(ProviderError):
|
||||
call_with_resilience(fail, retries=0, breaker=breaker)
|
||||
now[0] = 29
|
||||
with self.assertRaises(CircuitBreakerOpen):
|
||||
call_with_resilience(lambda: "ok", retries=0, breaker=breaker)
|
||||
now[0] = 30
|
||||
self.assertEqual(
|
||||
call_with_resilience(lambda: "ok", retries=0, breaker=breaker),
|
||||
"ok",
|
||||
)
|
||||
|
||||
def test_runtime_revision_replaces_open_breaker(self) -> None:
|
||||
reset_breakers()
|
||||
self.addCleanup(reset_breakers)
|
||||
first = _breaker((1, 2), revision=1)
|
||||
for _ in range(first.failure_threshold):
|
||||
first.on_failure()
|
||||
with self.assertRaises(CircuitBreakerOpen):
|
||||
first.before()
|
||||
|
||||
second = _breaker((1, 2), revision=2)
|
||||
self.assertIsNot(second, first)
|
||||
second.before()
|
||||
@@ -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())
|
||||
@@ -343,15 +343,6 @@ class ChatInvocationTests(TestCase):
|
||||
invocation = LlmInvocation.objects.get(channel=self.channel, operation="chat")
|
||||
self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS)
|
||||
self.assertGreater(invocation.total_tokens, 0)
|
||||
# Технический учёт стоимости (ADR-CHATBALLS-0042 §2): считается по прайсу модели.
|
||||
from chatballs.ai import pricing
|
||||
|
||||
self.assertEqual(
|
||||
invocation.cost_micros,
|
||||
pricing.cost_micros(
|
||||
invocation.model, invocation.prompt_tokens, invocation.completion_tokens
|
||||
),
|
||||
)
|
||||
|
||||
def test_pii_is_redacted_before_reaching_provider(self) -> None:
|
||||
from unittest import mock
|
||||
@@ -378,24 +369,6 @@ class ChatInvocationTests(TestCase):
|
||||
|
||||
self.assertNotIn("a@b.com", captured["messages"][0].content)
|
||||
|
||||
def test_limit_blocks_and_records(self) -> None:
|
||||
from chatballs.ai import limits as ai_limits
|
||||
from chatballs.ai.invocation import invoke_chat
|
||||
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
|
||||
from chatballs.ai.provider.base import ChatMessage
|
||||
|
||||
self.agent.limits = {"dailyCostUsd": 1}
|
||||
self.agent.save(update_fields=["limits"])
|
||||
# 1 цент = 10 000 micro-USD; лимит превышен расходом в 10_001 micros.
|
||||
LlmInvocation.objects.create(
|
||||
channel=self.channel, purpose="seed", operation="chat", model="x", cost_micros=10_001,
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
)
|
||||
|
||||
with self.assertRaises(ai_limits.LimitExceeded):
|
||||
invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat")
|
||||
self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists())
|
||||
|
||||
def test_invocation_records_used_fragment_ids(self) -> None:
|
||||
from chatballs.ai.invocation import invoke_chat
|
||||
from chatballs.ai.models import LlmInvocation
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -27,10 +27,11 @@ from chatballs.calls.tokens import (
|
||||
verify_call_access_token,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import call_invite_route, call_session_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -45,10 +46,9 @@ def resolve_invite(*, token: str) -> ResolvedInvite:
|
||||
route = call_invite_route(token_hash)
|
||||
if route is None:
|
||||
raise CallTokenError(message)
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist:
|
||||
raise CallTokenError(message) from None
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise CallTokenError(message)
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
invite = (
|
||||
@@ -92,10 +92,9 @@ def _authorize_call_access(
|
||||
route = call_session_route(str(claims.call_session_id))
|
||||
if route is None:
|
||||
raise CallTokenError(t("calls.token_invalid"))
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist:
|
||||
raise CallTokenError(t("calls.token_invalid")) from None
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise CallTokenError(t("calls.token_invalid"))
|
||||
resource_context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(resource_context):
|
||||
try:
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.conf import settings
|
||||
|
||||
from chatballs.calls.models import CallSession
|
||||
from chatballs.calls.turn import turn_credentials
|
||||
from chatballs.identity.instance_settings import turn_config
|
||||
from chatballs.identity.instance_settings import default_stun_urls, turn_config
|
||||
|
||||
|
||||
def _iso(value):
|
||||
@@ -67,8 +67,9 @@ def ice_servers_payload() -> list[dict]:
|
||||
# fallback с краткоживущими credentials. Генерируется на каждый запрос токена,
|
||||
# поэтому клиент всегда получает не истёкшие TURN credentials.
|
||||
servers: list[dict] = []
|
||||
if settings.CHATBALLS_CALL_STUN_URLS:
|
||||
servers.append({"urls": list(settings.CHATBALLS_CALL_STUN_URLS)})
|
||||
stun_urls = list(settings.CHATBALLS_CALL_STUN_URLS) or default_stun_urls()
|
||||
if stun_urls:
|
||||
servers.append({"urls": stun_urls})
|
||||
turn_urls, ttl = turn_config()
|
||||
if turn_urls and settings.CHATBALLS_CALL_TURN_SECRET:
|
||||
username, credential = turn_credentials(ttl_seconds=ttl)
|
||||
|
||||
@@ -13,7 +13,7 @@ from chatballs.calls.errors import (
|
||||
CallInvalidTransition,
|
||||
CallTokenError,
|
||||
)
|
||||
from chatballs.calls.lifecycle import transition_call
|
||||
from chatballs.calls.lifecycle import finish_call, transition_call
|
||||
from chatballs.calls.metrics import record_call_metric
|
||||
from chatballs.calls.models import (
|
||||
TERMINAL_CALL_STATUSES,
|
||||
@@ -231,6 +231,15 @@ def issue_staff_access_token(*, context: TenantContext, call_session: CallSessio
|
||||
|
||||
|
||||
def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSession:
|
||||
"""Оператор закончил звонок — из любой фазы, в которой тот ещё жив.
|
||||
|
||||
Кнопка у оператора одна и означает «прекратить»: до ответа клиента это
|
||||
отмена, после — завершение. Раньше здесь был только переход в CANCELLED, и
|
||||
он разрешён лишь из REQUESTED/RINGING: клиент принял звонок, соединение не
|
||||
установилось (частый случай за NAT), оператор жмёт «завершить» — и получает
|
||||
409, а звонок остаётся висеть. Фазу выбирает `finish_call`, тот же код, что
|
||||
и у клиента.
|
||||
"""
|
||||
user = context.actor_user
|
||||
if user is None or context.membership is None:
|
||||
raise CallAccessDenied(t("calls.operator_context_required"))
|
||||
@@ -238,12 +247,9 @@ def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSes
|
||||
if not call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists():
|
||||
raise CallConflict(t("calls.not_a_participant"))
|
||||
try:
|
||||
# Повторная отмена идемпотентна: transition_call вернёт звонок без изменений.
|
||||
return transition_call(
|
||||
call_session_id=call_session.id,
|
||||
target_status=CallStatus.CANCELLED,
|
||||
ended_by=CallEndedBy.STAFF,
|
||||
)
|
||||
# Повторный вызов идемпотентен: у завершённого звонка finish_call
|
||||
# возвращает его как есть.
|
||||
return finish_call(call_session_id=call_session.id, side=ParticipantSide.STAFF)
|
||||
except CallInvalidTransition as error:
|
||||
raise CallConflict(t("calls.cannot_cancel")) from error
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.calls.event_handlers import CallInviteDeliveryError, handle_call_invite_send
|
||||
from chatballs.calls.lifecycle import transition_call
|
||||
from chatballs.calls.models import (
|
||||
CallEndedBy,
|
||||
CallInvite,
|
||||
@@ -61,6 +62,22 @@ class CancelCallApiTests(CallTestCase):
|
||||
1,
|
||||
)
|
||||
|
||||
def test_operator_ends_call_the_customer_already_accepted(self) -> None:
|
||||
# Клиент принял звонок, соединение не установилось (частый случай за
|
||||
# NAT). Кнопка оператора одна и обязана закончить звонок, а не упереться
|
||||
# в запрет перехода.
|
||||
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
|
||||
open_call_for_identity(identity=self.identity)
|
||||
transition_call(call_session_id=created.call_session.id, target_status=CallStatus.ACCEPTED)
|
||||
|
||||
response = self.client.post(f"/api/v1/calls/{created.call_session.id}/cancel/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
created.call_session.refresh_from_db()
|
||||
self.assertEqual(created.call_session.status, CallStatus.FAILED)
|
||||
self.assertEqual(created.call_session.failure_code, "ABORTED_BEFORE_CONNECT")
|
||||
self.assertEqual(created.call_session.ended_by, CallEndedBy.STAFF)
|
||||
|
||||
def test_non_participant_cannot_cancel(self) -> None:
|
||||
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
|
||||
self.client.force_authenticate(user=self.operator)
|
||||
|
||||
@@ -3,10 +3,11 @@ import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
|
||||
from chatballs.calls.serializers import ice_servers_payload
|
||||
from chatballs.calls.turn import turn_credentials
|
||||
from chatballs.identity.instance_settings import InstanceSettings, turn_config
|
||||
|
||||
SECRET = "coturn-shared-secret"
|
||||
TURN_URLS = ["turn:example.com:3478?transport=udp", "turn:example.com:3478?transport=tcp"]
|
||||
@@ -71,3 +72,56 @@ class IceServersPayloadTests(SimpleTestCase):
|
||||
def test_turn_urls_without_secret_are_not_exposed(self) -> None:
|
||||
# Без секрета выдать рабочие credentials нельзя — TURN не отдаётся вовсе.
|
||||
self.assertEqual(ice_servers_payload(), [])
|
||||
|
||||
|
||||
@override_settings(
|
||||
CHATBALLS_CALL_STUN_URLS=[],
|
||||
CHATBALLS_CALL_TURN_URLS=[],
|
||||
CHATBALLS_CALL_TURN_SECRET=SECRET,
|
||||
CHATBALLS_CALL_TURN_TTL_SECONDS=3600,
|
||||
)
|
||||
class TurnDefaultsTests(TestCase):
|
||||
"""Relay коробки: адреса берутся от адреса установки, без единой настройки."""
|
||||
|
||||
def _set_host(self, host: str) -> None:
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = host
|
||||
row.save()
|
||||
|
||||
def test_addresses_come_from_the_installation_address(self) -> None:
|
||||
self._set_host("crm.example.com")
|
||||
urls, _ttl = turn_config()
|
||||
self.assertEqual(
|
||||
urls,
|
||||
[
|
||||
"turn:crm.example.com:3478?transport=udp",
|
||||
"turn:crm.example.com:3478?transport=tcp",
|
||||
],
|
||||
)
|
||||
|
||||
def test_owner_addresses_win_over_the_defaults(self) -> None:
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = "crm.example.com"
|
||||
row.turn_urls = "turns:turn.example.net:5349?transport=tcp"
|
||||
row.save()
|
||||
urls, _ttl = turn_config()
|
||||
self.assertEqual(urls, ["turns:turn.example.net:5349?transport=tcp"])
|
||||
|
||||
def test_ice_payload_offers_stun_and_turn_of_the_box(self) -> None:
|
||||
self._set_host("crm.example.com")
|
||||
servers = ice_servers_payload()
|
||||
self.assertEqual(servers[0], {"urls": ["stun:crm.example.com:3478"]})
|
||||
self.assertEqual(
|
||||
servers[1]["urls"],
|
||||
[
|
||||
"turn:crm.example.com:3478?transport=udp",
|
||||
"turn:crm.example.com:3478?transport=tcp",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
servers[1]["credential"], _expected_credential(servers[1]["username"])
|
||||
)
|
||||
|
||||
def test_without_the_installation_address_there_is_nothing_to_offer(self) -> None:
|
||||
self._set_host("")
|
||||
self.assertEqual(ice_servers_payload(), [])
|
||||
@@ -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
|
||||
@@ -9,4 +9,7 @@ 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)
|
||||
signals, # noqa: F401
|
||||
)
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.utils import timezone
|
||||
from rest_framework.request import Request
|
||||
@@ -22,6 +24,7 @@ from chatballs.conversations.models import (
|
||||
LifecycleState,
|
||||
ReplyTemplate,
|
||||
)
|
||||
from chatballs.conversations.queue_models import policy_for
|
||||
from chatballs.conversations.selectors import apply_conversation_visibility
|
||||
from chatballs.conversations.serializers import conversation_payload
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
@@ -30,12 +33,33 @@ from chatballs.identity.avatars import user_avatar_url
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.policy import can_administer_access
|
||||
from chatballs.presence import ONLINE_WITHIN_SECONDS, last_seen
|
||||
|
||||
|
||||
def _label_payload(label: ConversationLabel) -> dict[str, object]:
|
||||
return {"id": label.id, "name": label.name, "color": label.color}
|
||||
|
||||
|
||||
# Переменные шаблонов ответов: подставляет их интерфейс оператора при вставке
|
||||
# шаблона (internal-ui, conversations/templateVariables.ts — тот же список).
|
||||
TEMPLATE_VARIABLES = frozenset({"client_name", "operator_name", "company"})
|
||||
_TEMPLATE_TOKEN = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
||||
|
||||
|
||||
def _template_text_error(text: str) -> Response | None:
|
||||
if not text:
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
unknown = sorted(
|
||||
{match.group(0) for match in _TEMPLATE_TOKEN.finditer(text) if match.group(1) not in TEMPLATE_VARIABLES}
|
||||
)
|
||||
if unknown:
|
||||
return Response(
|
||||
{"detail": t("conversations.template_unknown_variables", names=", ".join(unknown))},
|
||||
status=400,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _template_payload(template: ReplyTemplate) -> dict[str, object]:
|
||||
return {
|
||||
"id": template.id,
|
||||
@@ -258,10 +282,21 @@ class ConversationCountersView(ConversationViewBase):
|
||||
}
|
||||
for assignee in assignees:
|
||||
assignee["avatarUrl"] = user_avatar_url(avatars.get(assignee["id"]), request.tenant_context.organization.public_id)
|
||||
# Два разных ожидания (макет Q3): диалог ничей — взять может любой;
|
||||
# диалог назначен лично на меня и ждёт, пока я его возьму. Смешивать их
|
||||
# в одном счётчике значит прятать своё среди чужого.
|
||||
waiting_qs = open_qs.filter(control_mode=ControlMode.PAUSED)
|
||||
return Response(
|
||||
{
|
||||
"all": open_qs.count(),
|
||||
"waiting": open_qs.filter(control_mode=ControlMode.PAUSED).count(),
|
||||
"waiting": waiting_qs.count(),
|
||||
"queue": waiting_qs.filter(assigned_operator__isnull=True).count(),
|
||||
"waitingOnMe": waiting_qs.filter(assigned_operator_id=request.user.id).count(),
|
||||
# Срок личной очереди: по нему клиент считает, через сколько
|
||||
# диалог вернётся всем.
|
||||
"assignmentTimeoutMinutes": policy_for(
|
||||
request.tenant_context.organization
|
||||
).assignment_timeout_minutes,
|
||||
"mine": base.filter(assigned_operator_id=request.user.id).count(),
|
||||
"ungrouped": ungrouped,
|
||||
"groups": groups,
|
||||
@@ -301,6 +336,22 @@ class ConversationDirectoryView(APIView):
|
||||
# Ответственного можно назначить и вне выдачи — по поиску, поэтому
|
||||
# оставшихся не прячем молча, а сообщаем признаком hasMore.
|
||||
rows = list(members[: DIRECTORY_LIMIT + 1])
|
||||
shown = rows[:DIRECTORY_LIMIT]
|
||||
# Присутствие и загрузка — второй и третий признак при выборе
|
||||
# ответственного (макет «Очередь и уведомления», кадр Q5). Назначить
|
||||
# отсутствующего можно: признак приблизительный и ничего не запрещает.
|
||||
user_ids = [member.user_id for member in shown]
|
||||
seen = last_seen(organization_id, user_ids)
|
||||
now = timezone.now()
|
||||
load = dict(
|
||||
Conversation.objects.filter(
|
||||
organization_id=organization_id,
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
assigned_operator_id__in=user_ids,
|
||||
)
|
||||
.values_list("assigned_operator_id")
|
||||
.annotate(total=Count("id"))
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"groups": [{"id": group.id, "name": group.name, "color": group.color} for group in groups],
|
||||
@@ -309,8 +360,15 @@ class ConversationDirectoryView(APIView):
|
||||
"id": member.user_id,
|
||||
"name": member.user.full_name or member.user.email,
|
||||
"avatarUrl": user_avatar_url(member.user, request.tenant_context.organization.public_id),
|
||||
"role": member.role,
|
||||
"online": member.user_id in seen
|
||||
and (now - seen[member.user_id]).total_seconds() <= ONLINE_WITHIN_SECONDS,
|
||||
"lastSeenAt": (
|
||||
seen[member.user_id].isoformat() if member.user_id in seen else None
|
||||
),
|
||||
"openDialogs": load.get(member.user_id, 0),
|
||||
}
|
||||
for member in rows[:DIRECTORY_LIMIT]
|
||||
for member in shown
|
||||
],
|
||||
"hasMoreEmployees": len(rows) > DIRECTORY_LIMIT,
|
||||
}
|
||||
@@ -407,8 +465,8 @@ class ReplyTemplateListView(APIView):
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not title or len(title) > 120:
|
||||
return Response({"detail": t("conversations.template_name_length")}, status=400)
|
||||
if not text:
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
if error := _template_text_error(text):
|
||||
return error
|
||||
if ReplyTemplate.objects.filter(
|
||||
organization_id=request.tenant_context.organization_id, title__iexact=title
|
||||
).exists():
|
||||
@@ -439,11 +497,15 @@ class ReplyTemplateDetailView(APIView):
|
||||
title = str(request.data.get("title", "")).strip()
|
||||
if not title or len(title) > 120:
|
||||
return Response({"detail": t("conversations.template_name_length")}, status=400)
|
||||
if ReplyTemplate.objects.filter(
|
||||
organization_id=template.organization_id, title__iexact=title
|
||||
).exclude(id=template.id).exists():
|
||||
return Response({"detail": t("conversations.template_name_taken")}, status=409)
|
||||
template.title = title
|
||||
if "text" in request.data:
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not text:
|
||||
return Response({"detail": t("conversations.template_text_required")}, status=400)
|
||||
if error := _template_text_error(text):
|
||||
return error
|
||||
template.text = text
|
||||
template.save()
|
||||
return Response({"template": _template_payload(template)})
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.db.models import (
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from chatballs.conversations.contact_avatars import contact_avatar_url_in
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
@@ -39,6 +40,7 @@ from chatballs.identity.models import AuditEvent
|
||||
PROVIDER_CODE = {
|
||||
"MAX": "MAX",
|
||||
"TELEGRAM": "TG",
|
||||
"VK": "VK",
|
||||
"WEB": "WEB",
|
||||
"EMAIL": "EMAIL",
|
||||
}
|
||||
@@ -161,7 +163,7 @@ def client_row(contact: Contact) -> dict:
|
||||
# на другом языке это перестало бы работать.
|
||||
"isGuest": not contact.name,
|
||||
"phone": contact.phone,
|
||||
"avatarUrl": contact.avatar_url,
|
||||
"avatarUrl": contact_avatar_url_in(contact, contact.organization_id),
|
||||
"email": next(
|
||||
(
|
||||
identity.external_user_id
|
||||
@@ -301,7 +303,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
# на другом языке это перестало бы работать.
|
||||
"isGuest": not contact.name,
|
||||
"phone": contact.phone,
|
||||
"avatarUrl": contact.avatar_url,
|
||||
"avatarUrl": contact_avatar_url_in(contact, contact.organization_id),
|
||||
# Поля карточки из чата (описание, компания, город).
|
||||
"description": contact.description,
|
||||
"company": contact.company,
|
||||
@@ -370,7 +372,7 @@ def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None:
|
||||
"cid": f"CUS-{other.id}",
|
||||
"name": other.name or t("conversations.guest"),
|
||||
"isGuest": not other.name,
|
||||
"avatarUrl": other.avatar_url,
|
||||
"avatarUrl": contact_avatar_url_in(other, other.organization_id),
|
||||
"dialogs": other.conversations.count(),
|
||||
"sources": sorted({identity.connection.provider for identity in identities}),
|
||||
"phone": other.phone,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""WebSocket оповещений о диалогах (см. chatballs.conversations.realtime).
|
||||
"""WebSocket оповещений рабочего места (см. chatballs.conversations.realtime и
|
||||
chatballs.notifications.realtime).
|
||||
|
||||
Сокет один на сессию: по нему идут и события диалогов, и события уведомлений.
|
||||
Второй сокет ради второго источника означал бы второе переподключение, вторую
|
||||
аутентификацию и вторую точку отказа на ровном месте.
|
||||
|
||||
Правила:
|
||||
- аутентификация — сессией того же SPA (AuthMiddlewareStack), отдельного токена
|
||||
@@ -22,8 +27,11 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.conversations.realtime import conversation_group, inbox_group
|
||||
from chatballs.conversations.selectors import conversation_is_visible
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.notifications.realtime import user_group
|
||||
from chatballs.presence import touch
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.lookup import organization_by_public_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,6 +43,8 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer):
|
||||
self.organization_id: int | None = None
|
||||
self.membership_id: int | None = None
|
||||
self.watched: str | None = None
|
||||
self.personal: str | None = None
|
||||
self.user_id: int | None = None
|
||||
user = self.scope.get("user")
|
||||
if user is None or not user.is_authenticated:
|
||||
await self.close(code=NOT_A_MEMBER_CLOSE)
|
||||
@@ -45,20 +55,42 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer):
|
||||
await self.close(code=NOT_A_MEMBER_CLOSE)
|
||||
return
|
||||
self.organization_id, self.membership_id = resolved
|
||||
self.user_id = user.id
|
||||
# Открытый сокет и есть присутствие: ничего специально «включать» для
|
||||
# этого сотрудник не должен (chatballs.presence).
|
||||
await self._touch_presence()
|
||||
await self.channel_layer.group_add(inbox_group(self.organization_id), self.channel_name)
|
||||
# Уведомления адресованы человеку, а не организации: у каждого своя группа.
|
||||
self.personal = user_group(user.id)
|
||||
await self.channel_layer.group_add(self.personal, self.channel_name)
|
||||
await self.accept()
|
||||
|
||||
async def disconnect(self, code: int) -> None:
|
||||
if self.organization_id is not None and self.user_id is not None:
|
||||
# Не «его нет», а «здесь он был в последний раз».
|
||||
await database_sync_to_async(touch)(self.organization_id, self.user_id)
|
||||
if self.organization_id is not None:
|
||||
await self.channel_layer.group_discard(
|
||||
inbox_group(self.organization_id), self.channel_name
|
||||
)
|
||||
if self.personal is not None:
|
||||
await self.channel_layer.group_discard(self.personal, self.channel_name)
|
||||
if self.watched is not None:
|
||||
await self.channel_layer.group_discard(self.watched, self.channel_name)
|
||||
|
||||
async def receive_json(self, content: dict, **kwargs) -> None:
|
||||
"""Клиент сообщает, какой диалог открыт: событий по нему он и ждёт."""
|
||||
if content.get("type") != "watch" or self.organization_id is None:
|
||||
"""Клиент сообщает, какой диалог открыт: событий по нему он и ждёт.
|
||||
|
||||
Он же раз в минуту присылает heartbeat — по нему продлевается отметка
|
||||
присутствия. Без неё ключ истекает сам, и оборванное соединение
|
||||
перестаёт считаться живым без отдельного уборщика.
|
||||
"""
|
||||
if self.organization_id is None:
|
||||
return
|
||||
if content.get("type") == "ping":
|
||||
await self._touch_presence()
|
||||
return
|
||||
if content.get("type") != "watch":
|
||||
return
|
||||
conversation_id = content.get("conversationId")
|
||||
if self.watched is not None:
|
||||
@@ -79,11 +111,18 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer):
|
||||
async def fanout(self, event: dict) -> None:
|
||||
await self.send_json(event["payload"])
|
||||
|
||||
async def _touch_presence(self) -> None:
|
||||
if self.organization_id is None or self.user_id is None:
|
||||
return
|
||||
await database_sync_to_async(touch)(self.organization_id, self.user_id)
|
||||
|
||||
@database_sync_to_async
|
||||
def _membership(self, user_id: int, raw_public_id: str) -> tuple[int, int] | None:
|
||||
try:
|
||||
organization = Organization.objects.get(public_id=uuid.UUID(str(raw_public_id)))
|
||||
except (ValueError, Organization.DoesNotExist):
|
||||
organization = organization_by_public_id(uuid.UUID(str(raw_public_id)))
|
||||
except ValueError:
|
||||
return None
|
||||
if organization is None:
|
||||
return None
|
||||
with tenant_atomic(organization.pk):
|
||||
membership = (
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Фото контакта: скачиваем у провайдера и отдаём со своего адреса.
|
||||
|
||||
Рабочее место живёт под `Content-Security-Policy: img-src 'self'`, поэтому
|
||||
ссылка на CDN мессенджера до экрана не доезжает — оператор видит инициалы
|
||||
вместо лица. Значит, фото должно лежать у нас и отдаваться тенантным
|
||||
эндпоинтом, как фото сотрудника (`identity.avatars`).
|
||||
|
||||
Источник фото запоминается в `Contact.avatar_source`: у MAX это адрес из
|
||||
профиля отправителя, у Telegram — идентификатор файла фотографии. Пока
|
||||
источник тот же, повторно ничего не качается. Строка `tg:none` означает «у
|
||||
человека в Telegram фото нет»: без неё каждое его сообщение стоило бы лишнего
|
||||
запроса к API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from chatballs.conversations.models import Contact
|
||||
from chatballs.identity.avatars import image_type, organization_public_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||
# Проверено и фото нет: помним, чтобы не спрашивать провайдера снова.
|
||||
NO_AVATAR = "none"
|
||||
|
||||
|
||||
def contact_avatar_url_in(contact: Contact | None, organization_id: int) -> str | None:
|
||||
"""Ссылка на фото контакта для рабочего места; None — фото нет."""
|
||||
if contact is None:
|
||||
return None
|
||||
if contact.avatar:
|
||||
version = hashlib.sha1(contact.avatar.name.encode("utf-8")).hexdigest()[:8]
|
||||
public_id = organization_public_id(organization_id)
|
||||
return f"/api/v1/organizations/{public_id}/conversations/clients/{contact.id}/avatar/?v={version}"
|
||||
# Демо-набор и старые записи держат ссылку на наш же адрес — она рабочая.
|
||||
return contact.avatar_url or None
|
||||
|
||||
|
||||
def store_contact_avatar(contact: Contact, *, content: bytes, source: str) -> bool:
|
||||
"""Сохранить скачанное фото. False — это не картинка или она слишком велика."""
|
||||
if not content or len(content) > MAX_AVATAR_BYTES:
|
||||
return False
|
||||
detected = image_type(content)
|
||||
if detected is None:
|
||||
return False
|
||||
content_type, suffix = detected
|
||||
if contact.avatar:
|
||||
contact.avatar.delete(save=False)
|
||||
contact.avatar.save(f"avatar{suffix}", ContentFile(content), save=False)
|
||||
contact.avatar_content_type = content_type
|
||||
contact.avatar_source = source[:512]
|
||||
contact.save(update_fields=["avatar", "avatar_content_type", "avatar_source"])
|
||||
return True
|
||||
|
||||
|
||||
def _checked_marker(source: str) -> str:
|
||||
"""«Фото по этому источнику спрашивали, его нет» — чтобы не спрашивать снова."""
|
||||
return f"{NO_AVATAR}:{source}"[:512]
|
||||
|
||||
|
||||
def refresh_contact_avatar(integration, inbound, contact: Contact) -> None:
|
||||
"""Подтянуть фото отправителя, если провайдер его отдаёт и оно новое.
|
||||
|
||||
Сбой скачивания не мешает сообщению: фото — украшение карточки, а не её
|
||||
содержание.
|
||||
"""
|
||||
from chatballs.conversations import transports
|
||||
|
||||
try:
|
||||
source = transports.avatar_source(integration, inbound)
|
||||
if not source or contact.avatar_source in (source, _checked_marker(source)):
|
||||
return
|
||||
fetched = transports.download_avatar(integration, inbound)
|
||||
if fetched is None:
|
||||
contact.avatar_source = _checked_marker(source)
|
||||
contact.save(update_fields=["avatar_source"])
|
||||
return
|
||||
content, source_key = fetched
|
||||
if not store_contact_avatar(contact, content=content, source=source_key):
|
||||
logger.info("Contact %s avatar from %s is not an image", contact.id, source_key)
|
||||
except Exception as error: # noqa: BLE001 - провайдер/сеть, деградация мягкая
|
||||
logger.info("Contact %s avatar download failed: %s", contact.id, error)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Что происходит, когда диалог ждёт слишком долго.
|
||||
|
||||
Раньше не происходило ничего. Про ждущий диалог операторов окликали ровно один
|
||||
раз, и дальше `dedup_key` на сутки гарантировал тишину: если в тот момент никто
|
||||
не смотрел на экран, диалог мог провисеть до автозакрытия, и узнать об этом было
|
||||
неоткуда.
|
||||
|
||||
Уровень выбирается по времени ожидания, а не по счётчику попыток: состояние
|
||||
хранить не нужно, потому что повтор гасит тот же `dedup_key` — свой у каждого
|
||||
уровня. Свип идеемпотентен и может выполняться сколь угодно часто.
|
||||
|
||||
Назначенный диалог не эскалируется: он не в общей очереди, а в личной, и у неё
|
||||
свой срок — не взял, значит возвращаем всем.
|
||||
|
||||
Присутствие сокращает ожидание, но не заменяет его. Если в группе диалога сейчас
|
||||
никого нет за рабочим местом, ждать второго порога бессмысленно: напоминать
|
||||
некому, и круг расширяется сразу. Обратного правила нет — присутствие никого не
|
||||
задерживает и ничего не запрещает, потому что ошибается в обе стороны.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations.models import (
|
||||
ControlMode,
|
||||
Conversation,
|
||||
LifecycleState,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.conversations.queue_models import QueueEscalationPolicy, policy_for
|
||||
from chatballs.conversations.services import operator_label
|
||||
from chatballs.i18n import t
|
||||
from chatballs.notifications.models import (
|
||||
NotificationAudience,
|
||||
NotificationLevel,
|
||||
NotificationType,
|
||||
)
|
||||
from chatballs.notifications.recipients import audience_user_ids
|
||||
from chatballs.notifications.services import notify, notify_management
|
||||
from chatballs.presence import online_user_ids
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _contact_name(conversation: Conversation) -> str:
|
||||
return getattr(conversation.contact, "name", "") or t("conversations.guest")
|
||||
|
||||
|
||||
def _waiting_conversations(context: TenantContext):
|
||||
return Conversation.objects.filter(
|
||||
organization=context.organization,
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
control_mode=ControlMode.PAUSED,
|
||||
waiting_since__isnull=False,
|
||||
).select_related("contact", "assigned_operator", "group")
|
||||
|
||||
|
||||
def sweep_waiting_conversations(context: TenantContext) -> int:
|
||||
"""Оклики по ждущим диалогам организации. Возвращает число новых уведомлений."""
|
||||
policy = policy_for(context.organization)
|
||||
now = timezone.now()
|
||||
fired = 0
|
||||
for conversation in _waiting_conversations(context):
|
||||
if conversation.assigned_operator_id:
|
||||
fired += _expire_stale_assignment(context, conversation, policy, now)
|
||||
else:
|
||||
fired += _escalate(context, conversation, policy, now)
|
||||
return fired
|
||||
|
||||
|
||||
def _nobody_is_watching(context: TenantContext, conversation: Conversation) -> bool:
|
||||
"""В группе диалога никого нет за рабочим местом."""
|
||||
watchers = audience_user_ids(
|
||||
organization_id=context.organization_id,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
group_id=conversation.group_id,
|
||||
)
|
||||
return not online_user_ids(context.organization_id, watchers)
|
||||
|
||||
|
||||
def _escalate(
|
||||
context: TenantContext,
|
||||
conversation: Conversation,
|
||||
policy: QueueEscalationPolicy,
|
||||
now: datetime,
|
||||
) -> int:
|
||||
waited = now - conversation.waiting_since
|
||||
contact = _contact_name(conversation)
|
||||
common = {
|
||||
"context": context,
|
||||
"type": NotificationType.DIALOG_WAITING_LONG,
|
||||
"text_params": {"contact": contact},
|
||||
"target_id": conversation.id,
|
||||
"source_type": "Conversation",
|
||||
"source_id": conversation.id,
|
||||
}
|
||||
fired = 0
|
||||
if waited >= timedelta(minutes=policy.remind_after_minutes):
|
||||
# Тот же круг, что и в первый раз: смена на месте, просто не заметила.
|
||||
fired += notify(
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
level=NotificationLevel.WARNING,
|
||||
title=f"Диалог всё ещё ждёт · {contact}",
|
||||
title_key="notifications.still_waiting",
|
||||
body_key="notifications.still_waiting_body",
|
||||
dedup_key=f"waiting:{conversation.id}:remind",
|
||||
**common,
|
||||
) is not None
|
||||
widen_after = policy.widen_after_minutes
|
||||
if waited >= timedelta(minutes=policy.remind_after_minutes) and _nobody_is_watching(
|
||||
context, conversation
|
||||
):
|
||||
# Некому заметить напоминание — второй порог ждать незачем.
|
||||
widen_after = min(widen_after, policy.remind_after_minutes)
|
||||
if waited >= timedelta(minutes=widen_after):
|
||||
# Круг шире группы: в своей группе ответить некому.
|
||||
fired += notify(
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=None,
|
||||
level=NotificationLevel.WARNING,
|
||||
title=f"Диалог всё ещё ждёт · {contact}",
|
||||
title_key="notifications.still_waiting",
|
||||
body_key="notifications.still_waiting_body",
|
||||
dedup_key=f"waiting:{conversation.id}:widen",
|
||||
**common,
|
||||
) is not None
|
||||
if waited >= timedelta(minutes=policy.escalate_after_minutes):
|
||||
# Это уже не про сменщика, а про то, что смены нет.
|
||||
fired += notify_management(
|
||||
level=NotificationLevel.CRITICAL,
|
||||
title=f"Диалог никто не берёт · {contact}",
|
||||
title_key="notifications.waiting_unattended",
|
||||
body_key="notifications.waiting_unattended_body",
|
||||
dedup_key=f"waiting:{conversation.id}:management",
|
||||
**common,
|
||||
)
|
||||
return fired
|
||||
|
||||
|
||||
def _expire_stale_assignment(
|
||||
context: TenantContext,
|
||||
conversation: Conversation,
|
||||
policy: QueueEscalationPolicy,
|
||||
now: datetime,
|
||||
) -> int:
|
||||
if conversation.assigned_at is None:
|
||||
return 0
|
||||
if now - conversation.assigned_at < timedelta(minutes=policy.assignment_timeout_minutes):
|
||||
return 0
|
||||
label = operator_label(conversation.assigned_operator)
|
||||
conversation.assigned_operator = None
|
||||
conversation.assigned_at = None
|
||||
conversation.save(update_fields=["assigned_operator", "assigned_at"])
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.ASSIGNMENT_EXPIRED,
|
||||
system_params={"operator": label},
|
||||
text=f"{label} не взял диалог — он вернулся в очередь",
|
||||
)
|
||||
logger.info("Assignment on conversation %s expired", conversation.id)
|
||||
contact = _contact_name(conversation)
|
||||
return notify(
|
||||
context=context,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
level=NotificationLevel.WARNING,
|
||||
title=f"Диалог снова ничей · {contact}",
|
||||
title_key="notifications.assignment_expired",
|
||||
text_params={"contact": contact, "operator": label},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
dedup_key=f"unassigned:{conversation.id}:{conversation.waiting_since.isoformat()}",
|
||||
) is not None
|
||||
@@ -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,23 +1,24 @@
|
||||
"""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.limits import LimitExceeded
|
||||
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,
|
||||
Contact,
|
||||
@@ -28,26 +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 — оно уже приходило.
|
||||
@@ -73,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:
|
||||
@@ -177,13 +73,27 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
context = TenantContext.for_resource(channel.organization)
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
ai_available = bool(agent and agent.is_active)
|
||||
if not ai_available:
|
||||
# Частая причина «диалог сразу ждёт оператора»: у канала подключения нет
|
||||
# агента или он не активен. В журнале это должно быть видно одной
|
||||
# строкой, иначе настройку ищут перебором.
|
||||
logger.info(
|
||||
"Channel %s has no active AI agent (agent=%s) — conversation goes to the operator queue",
|
||||
channel.id,
|
||||
getattr(agent, "status", None),
|
||||
)
|
||||
source = f"{integration.provider.lower()}:{integration.id}"
|
||||
if _already_processed(context, source, inbound.external_id, inbound.text):
|
||||
return
|
||||
|
||||
# Явный шаринг контакта: сообщение без текста, но с телефоном.
|
||||
is_contact_share = bool(inbound.phone)
|
||||
is_voice = bool(inbound.voice_file_id or inbound.voice_url or inbound.voice_content)
|
||||
is_voice = bool(
|
||||
inbound.voice_file_id
|
||||
or inbound.voice_url
|
||||
or inbound.voice_content
|
||||
or inbound.voice_unavailable
|
||||
)
|
||||
files = tuple(inbound.files or ())
|
||||
# Файлы без текста: сообщение-контейнер не создаём, каждый файл — своя реплика.
|
||||
files_only = bool(files) and not inbound.text and not is_contact_share and not is_voice
|
||||
@@ -224,11 +134,12 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
if identity.phone_verified_at is None:
|
||||
identity.phone_verified_at = timezone.now()
|
||||
identity.save(update_fields=["phone_verified_at"])
|
||||
# Аватар обновляем при каждом заходе: провайдер может сменить фото,
|
||||
# а контакт ещё не шарил телефон (is_contact_share=False).
|
||||
# Адрес фото у провайдера храним как было, но показываем оператору не
|
||||
# его: страница под CSP `img-src 'self'` чужую картинку не покажет.
|
||||
if inbound.avatar_url and contact.avatar_url != inbound.avatar_url:
|
||||
contact.avatar_url = inbound.avatar_url
|
||||
contact.save(update_fields=["avatar_url"])
|
||||
refresh_contact_avatar(integration, inbound, contact)
|
||||
|
||||
conversation = (
|
||||
Conversation.objects.filter(channel=channel, contact=contact, lifecycle=LifecycleState.OPEN)
|
||||
@@ -250,6 +161,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
external_chat_id=inbound.chat_id,
|
||||
control_mode=ControlMode.AI if ai_available else ControlMode.PAUSED,
|
||||
expected_responder=ExpectedResponder.AI if ai_available else ExpectedResponder.OPERATOR,
|
||||
waiting_since=None if ai_available else timezone.now(),
|
||||
previous_conversation=previous,
|
||||
)
|
||||
elif inbound.chat_id and not conversation.external_chat_id:
|
||||
@@ -283,9 +195,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
conversation.last_activity_at = timezone.now()
|
||||
update_fields = ["external_chat_id", "last_activity_at"]
|
||||
if conversation.control_mode == ControlMode.AI and not ai_available:
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
update_fields.extend(["control_mode", "expected_responder"])
|
||||
enter_queue(conversation)
|
||||
update_fields.extend(QUEUE_FIELDS)
|
||||
if inbound.thread_meta:
|
||||
# Email: Message-ID последнего входящего — для ответа в тред;
|
||||
# тема диалога фиксируется по первому письму (ADR-CHATBALLS-0035).
|
||||
@@ -299,10 +210,18 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
conversation.save(update_fields=update_fields)
|
||||
|
||||
if is_new:
|
||||
# Диалог, которым занялся агент, — это «новый диалог» и больше ничего.
|
||||
# Диалог, отвечать в котором некому, — уже просьба о человеке: событие
|
||||
# одно, а смысл для смены разный, и подписки на них тоже разные.
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
type=(
|
||||
NotificationType.OPERATOR_REQUESTED
|
||||
if is_waiting(conversation)
|
||||
else NotificationType.NEW_DIALOG
|
||||
),
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
title=f"Новый диалог · {channel.name}",
|
||||
body=f"{contact.name or 'Гость'} · {integration.provider}: {message_text[:80]}",
|
||||
title_key="notifications.new_dialog",
|
||||
@@ -325,6 +244,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_NEW_MESSAGE,
|
||||
audience=NotificationAudience.USER if operator else NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
recipient_user=operator,
|
||||
title=f"Новое сообщение · {contact.name or 'Гость'}",
|
||||
body=message_text[:120],
|
||||
@@ -346,28 +266,24 @@ 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:
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
conversation.save(update_fields=["control_mode", "expected_responder"])
|
||||
enter_queue(conversation)
|
||||
conversation.save(update_fields=QUEUE_FIELDS)
|
||||
if is_new:
|
||||
return
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
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="Голосовое без расшифровки" 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,
|
||||
@@ -380,94 +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, LimitExceeded) as error:
|
||||
# Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны
|
||||
# «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и
|
||||
# отвечаем клиенту понятным fallback.
|
||||
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
conversation.last_activity_at = timezone.now()
|
||||
conversation.save(update_fields=["control_mode", "expected_responder", "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.DIALOG_WAITING,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
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.INTEGRATION_ERROR,
|
||||
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:
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
else:
|
||||
conversation.expected_responder = ExpectedResponder.CUSTOMER
|
||||
conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"])
|
||||
|
||||
if handoff:
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.AI_HANDED_OVER,
|
||||
text="AI передал диалог оператору",
|
||||
)
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
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,40 @@
|
||||
"""Время ожидания в очереди — отдельным полем.
|
||||
|
||||
Раньше «дольше всех ждущий» вычислялся по времени последнего сообщения, и
|
||||
очередь работала обратно смыслу: клиент, напомнивший о себе, двигал
|
||||
last_message_at вперёд и падал в конец очереди. Поле ставится один раз при входе
|
||||
в очередь (chatballs.conversations.queue) и снимается при выходе из неё.
|
||||
|
||||
Backfill берёт last_message_at — единственное, что известно про уже ждущие
|
||||
диалоги. Для них порядок не ухудшится: в старой сортировке ключ был тот же.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
BACKFILL = """
|
||||
UPDATE conversations_conversation
|
||||
SET waiting_since = last_message_at
|
||||
WHERE lifecycle = 'OPEN' AND control_mode = 'PAUSED' AND waiting_since IS NULL
|
||||
"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0021_i18n_events'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='conversation',
|
||||
name='waiting_since',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.RunSQL(sql=BACKFILL, reverse_sql=migrations.RunSQL.noop),
|
||||
migrations.AddIndex(
|
||||
model_name='conversation',
|
||||
index=models.Index(
|
||||
fields=['organization', 'waiting_since'], name='conv_waiting_order'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Личная очередь и пороги эскалации.
|
||||
|
||||
assigned_at нужен, чтобы у назначения был срок: waiting_since для этого не
|
||||
годится — назначить могут и через час после того, как диалог встал в очередь.
|
||||
Пороги — строка на организацию с дефолтами: «долго» у круглосуточной
|
||||
поддержки и у приёма по будням означает разное.
|
||||
"""
|
||||
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0022_conversation_waiting_since'),
|
||||
('identity', '0039_remove_organization_currency'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='conversation',
|
||||
name='assigned_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='message',
|
||||
name='system_event',
|
||||
field=models.CharField(blank=True, choices=[('operator_took', 'Оператор перехватил диалог'), ('returned_to_ai', 'Диалог возвращён AI'), ('returned_to_queue', 'Диалог возвращён в очередь'), ('ai_unavailable', 'AI недоступен'), ('ai_handed_over', 'AI передал диалог оператору'), ('assigned_to', 'Диалог назначен сотруднику'), ('assignment_expired', 'Назначение истекло'), ('call_requested', 'Запрошен звонок'), ('call_accepted', 'Клиент принял приглашение'), ('call_declined', 'Клиент отклонил приглашение'), ('call_cancelled', 'Приглашение отменено'), ('call_missed', 'Звонок пропущен'), ('call_expired', 'Приглашение истекло'), ('call_started', 'Звонок начался'), ('call_ended', 'Звонок завершён'), ('call_failed', 'Звонок не состоялся')], default='', max_length=32),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QueueEscalationPolicy',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('remind_after_minutes', models.PositiveIntegerField(default=5)),
|
||||
('widen_after_minutes', models.PositiveIntegerField(default=15)),
|
||||
('escalate_after_minutes', models.PositiveIntegerField(default=30)),
|
||||
('assignment_timeout_minutes', models.PositiveIntegerField(default=10)),
|
||||
('organization', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='queue_policy', to='identity.organization')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'conversations_queueescalationpolicy',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-13 19:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0023_queue_escalation'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='queueescalationpolicy',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='queueescalationpolicy',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-14 21:32
|
||||
|
||||
import chatballs.conversations.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0024_queue_policy_author'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='avatar',
|
||||
field=models.FileField(blank=True, default='', max_length=512, upload_to=chatballs.conversations.models.contact_avatar_upload_path),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='avatar_content_type',
|
||||
field=models.CharField(blank=True, default='', max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='avatar_source',
|
||||
field=models.CharField(blank=True, default='', max_length=512),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -11,6 +11,14 @@ from chatballs.tenancy.models import TenantRelationModel
|
||||
# разделено на независимые оси; перехват оператором — атомарный.
|
||||
|
||||
|
||||
def contact_avatar_upload_path(instance: "Contact", filename: str) -> str:
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
suffix = Path(filename).suffix.lower()[:8] or ".jpg"
|
||||
return f"organizations/{instance.organization.public_id}/contacts/{uuid.uuid4().hex}{suffix}"
|
||||
|
||||
|
||||
class Contact(models.Model):
|
||||
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="contacts")
|
||||
name = models.CharField(max_length=255, blank=True)
|
||||
@@ -22,6 +30,16 @@ class Contact(models.Model):
|
||||
# getUpdates, поэтому для него поле остаётся пустым. Хранится только URL —
|
||||
# само изображение живёт на стороне провайдера.
|
||||
avatar_url = models.URLField(max_length=512, blank=True, default="")
|
||||
# Фото контакта, скачанное у провайдера и лежащее у нас. Внешней ссылкой
|
||||
# обойтись нельзя: страница рабочего места живёт под CSP `img-src 'self'`,
|
||||
# и картинка с чужого домена до экрана не доезжает — оператор видит
|
||||
# инициалы вместо фото. Источник запоминается, чтобы не качать то же самое
|
||||
# на каждое сообщение.
|
||||
avatar = models.FileField(
|
||||
upload_to=contact_avatar_upload_path, max_length=512, blank=True, default=""
|
||||
)
|
||||
avatar_content_type = models.CharField(max_length=64, blank=True, default="")
|
||||
avatar_source = models.CharField(max_length=512, blank=True, default="")
|
||||
# Карточка контакта (дизайн-базлайн v2, решение 5): описание, компания, город —
|
||||
# заполняет оператор.
|
||||
description = models.TextField(blank=True, default="")
|
||||
@@ -166,6 +184,10 @@ class Conversation(models.Model):
|
||||
lifecycle = models.CharField(max_length=16, choices=LifecycleState.choices, default=LifecycleState.OPEN)
|
||||
control_mode = models.CharField(max_length=16, choices=ControlMode.choices, default=ControlMode.AI)
|
||||
expected_responder = models.CharField(max_length=16, choices=ExpectedResponder.choices, default=ExpectedResponder.AI)
|
||||
# С какого момента диалог ждёт человека (chatballs.conversations.queue).
|
||||
# Не «последнее сообщение»: клиент, написавший повторно, ждёт не меньше, а
|
||||
# больше прежнего, и в очереди обязан оставаться выше, а не ниже.
|
||||
waiting_since = models.DateTimeField(null=True, blank=True)
|
||||
# Группа видимости (ADR-CHATBALLS-0043): наследуется от group агента/канала при
|
||||
# создании, переносится вручную. NULL — диалог виден всем сотрудникам.
|
||||
group = models.ForeignKey(
|
||||
@@ -177,6 +199,9 @@ class Conversation(models.Model):
|
||||
)
|
||||
# «Ответственный» (ADR-CHATBALLS-0043): видит диалог независимо от групп.
|
||||
assigned_operator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_conversations")
|
||||
# Когда назначили. С этого момента идёт срок личной очереди: не взял —
|
||||
# диалог возвращается в общую (chatballs.conversations.escalation).
|
||||
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||
# Дизайн-базлайн v2: приоритет, метки и заметка оператора.
|
||||
priority = models.CharField(
|
||||
max_length=8, choices=ConversationPriority.choices, default=ConversationPriority.NONE
|
||||
@@ -219,6 +244,10 @@ class Conversation(models.Model):
|
||||
models.Index(
|
||||
fields=["contact", "-last_activity_at"], name="conv_contact_recent"
|
||||
),
|
||||
# Очередь к оператору: кто ждёт дольше всех и не дождался порога.
|
||||
models.Index(
|
||||
fields=["organization", "waiting_since"], name="conv_waiting_order"
|
||||
),
|
||||
]
|
||||
constraints = [
|
||||
# Диалог всегда принадлежит контакту.
|
||||
@@ -274,6 +303,8 @@ class SystemEvent(models.TextChoices):
|
||||
RETURNED_TO_QUEUE = "returned_to_queue", "Диалог возвращён в очередь"
|
||||
AI_UNAVAILABLE = "ai_unavailable", "AI недоступен"
|
||||
AI_HANDED_OVER = "ai_handed_over", "AI передал диалог оператору"
|
||||
ASSIGNED_TO = "assigned_to", "Диалог назначен сотруднику"
|
||||
ASSIGNMENT_EXPIRED = "assignment_expired", "Назначение истекло"
|
||||
CALL_REQUESTED = "call_requested", "Запрошен звонок"
|
||||
CALL_ACCEPTED = "call_accepted", "Клиент принял приглашение"
|
||||
CALL_DECLINED = "call_declined", "Клиент отклонил приглашение"
|
||||
@@ -293,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).
|
||||
@@ -344,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)
|
||||
@@ -400,3 +451,11 @@ class ReplyTemplate(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"template:{self.organization_id}/{self.title}"
|
||||
|
||||
|
||||
# Django импортирует только models.py: пороги очереди лежат рядом, чтобы не
|
||||
# растить этот файл, и переэкспортируются здесь ради регистрации модели.
|
||||
from chatballs.conversations.queue_models import ( # noqa: E402, F401
|
||||
QueueEscalationPolicy,
|
||||
policy_for,
|
||||
)
|
||||
@@ -22,6 +22,8 @@ def poll_all_messengers(context) -> int:
|
||||
channel__is_active=True,
|
||||
).exclude(secret="")
|
||||
if integration.config.get("purpose") != "notifications"
|
||||
# Демо-подключения из демо-набора: токены ненастоящие, опрашивать нечего.
|
||||
and not integration.config.get("demoSeed")
|
||||
]
|
||||
total = 0
|
||||
for integration in integrations:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Очередь к оператору: единственное место, где диалог в неё входит и выходит.
|
||||
|
||||
Правило «диалог ждёт человека» — это три поля сразу: control_mode = PAUSED,
|
||||
expected_responder = OPERATOR и момент, с которого пошло ожидание. Раньше первые
|
||||
два выставлялись в шести местах подряд (создание диалога без доступного AI,
|
||||
клиент написал в диалог без AI, голосовое без расшифровки, сбой провайдера,
|
||||
хендофф агента, ручной возврат оператором), а третьего не было вовсе: «дольше
|
||||
всех ждущий» считался по времени последнего сообщения.
|
||||
|
||||
Из-за этого очередь вела себя обратно смыслу. Клиент, который писал повторно,
|
||||
двигал last_message_at вперёд и падал в конец очереди: чем настойчивее человек,
|
||||
тем позже до него доходили руки. Поэтому waiting_since ставится один раз — при
|
||||
входе в очередь — и не обновляется, пока диалог из неё не вышел.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations.models import (
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ExpectedResponder,
|
||||
LifecycleState,
|
||||
)
|
||||
|
||||
# Что пишет enter_queue. Вызывающий кладёт это в update_fields своего save():
|
||||
# состояние диалога меняется вместе с остальными полями, одной записью.
|
||||
QUEUE_FIELDS = ("control_mode", "expected_responder", "waiting_since")
|
||||
|
||||
|
||||
def is_waiting(conversation: Conversation) -> bool:
|
||||
"""Диалог стоит в очереди к человеку.
|
||||
|
||||
Закрытый и спам тоже лежат в PAUSED, но никого не ждут — отсюда проверка
|
||||
жизненного цикла.
|
||||
"""
|
||||
return (
|
||||
conversation.lifecycle == LifecycleState.OPEN
|
||||
and conversation.control_mode == ControlMode.PAUSED
|
||||
)
|
||||
|
||||
|
||||
def enter_queue(conversation: Conversation, *, now: datetime | None = None) -> bool:
|
||||
"""Ставит диалог в очередь. True — если он в неё только что попал.
|
||||
|
||||
Возврат нужен вызывающему, чтобы решить, звать ли операторов: повторное
|
||||
сообщение клиента в уже ждущий диалог очередь не меняет и второго оклика не
|
||||
заслуживает.
|
||||
"""
|
||||
entered = not is_waiting(conversation)
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
if entered:
|
||||
conversation.waiting_since = now or timezone.now()
|
||||
return entered
|
||||
|
||||
|
||||
def leave_queue(conversation: Conversation) -> None:
|
||||
"""Диалог больше никого не ждёт.
|
||||
|
||||
Несимметрично enter_queue намеренно: вход в очередь — одно состояние, а
|
||||
выходов несколько (оператор взял, диалог вернули AI, закрыли, пометили
|
||||
спамом), и control_mode у каждого свой. Общее у них только одно — ожидание
|
||||
закончилось, и его начало больше не имеет смысла.
|
||||
"""
|
||||
conversation.waiting_since = None
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Пороги очереди: через сколько напоминать, расширять круг и звать руководство.
|
||||
|
||||
Числа разные у разных организаций — у круглосуточной поддержки хостинга и у
|
||||
клиники с приёмом по будням «долго» означает не одно и то же, — поэтому они
|
||||
настройка, а не константа в коде. Строка одна на организацию и заводится с
|
||||
дефолтами при первом обращении.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class QueueEscalationPolicy(models.Model):
|
||||
organization = models.OneToOneField(
|
||||
"identity.Organization", on_delete=models.CASCADE, related_name="queue_policy"
|
||||
)
|
||||
# Диалог ждёт дольше этого — повторный оклик той же группе.
|
||||
remind_after_minutes = models.PositiveIntegerField(default=5)
|
||||
# Ждёт ещё дольше — круг расширяется за пределы группы диалога.
|
||||
widen_after_minutes = models.PositiveIntegerField(default=15)
|
||||
# Совсем долго — это уже не про сменщика, а про руководство.
|
||||
escalate_after_minutes = models.PositiveIntegerField(default=30)
|
||||
# Назначили ответственного, а он не взял — диалог возвращается в общую
|
||||
# очередь. Без этого назначение работает как способ спрятать диалог: из
|
||||
# общей очереди он ушёл, а отвечать некому.
|
||||
assignment_timeout_minutes = models.PositiveIntegerField(default=10)
|
||||
# Кто и когда менял: в разделе настроек это подпись под формой. Сроки —
|
||||
# правило работы смены, и знать, чьё это решение, важнее, чем кажется.
|
||||
updated_at = models.DateTimeField(null=True, blank=True)
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
|
||||
)
|
||||
|
||||
# Значения по умолчанию — они же «обычные сроки» в кнопке сброса.
|
||||
DEFAULTS = {
|
||||
"remind_after_minutes": 5,
|
||||
"widen_after_minutes": 15,
|
||||
"escalate_after_minutes": 30,
|
||||
"assignment_timeout_minutes": 10,
|
||||
}
|
||||
|
||||
class Meta:
|
||||
db_table = "conversations_queueescalationpolicy"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"queue-policy:{self.organization_id}"
|
||||
|
||||
|
||||
def policy_for(organization) -> QueueEscalationPolicy:
|
||||
policy, _ = QueueEscalationPolicy.objects.get_or_create(organization=organization)
|
||||
return policy
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Сроки очереди: раздел «Когда звать на помощь» (макет Q2).
|
||||
|
||||
До этого пороги правились только в служебной админке — то есть де-факто никем.
|
||||
Читает их тот, кто видит настройки; меняет — тот, кто ими управляет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.conversations.queue_models import QueueEscalationPolicy, policy_for
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.policy import ResourceScope, authorize
|
||||
|
||||
FIELDS = tuple(QueueEscalationPolicy.DEFAULTS)
|
||||
# Сутки: всё, что дольше, — это не «позвать на помощь», а другая задача.
|
||||
MAX_MINUTES = 24 * 60
|
||||
|
||||
|
||||
def _payload(policy: QueueEscalationPolicy) -> dict:
|
||||
author = policy.updated_by
|
||||
return {
|
||||
**{field: getattr(policy, field) for field in FIELDS},
|
||||
"defaults": dict(QueueEscalationPolicy.DEFAULTS),
|
||||
"updatedAt": policy.updated_at.isoformat() if policy.updated_at else None,
|
||||
"updatedBy": (author.full_name or author.email) if author else "",
|
||||
}
|
||||
|
||||
|
||||
class QueuePolicyView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
context = request.tenant_context
|
||||
if not authorize(context.membership, "settings.view", ResourceScope(context.organization_id)):
|
||||
return Response({"detail": t("settings.queue_policy_forbidden")}, status=403)
|
||||
return Response(_payload(policy_for(context.organization)))
|
||||
|
||||
def patch(self, request: Request) -> Response:
|
||||
context = request.tenant_context
|
||||
if not authorize(context.membership, "settings.manage", ResourceScope(context.organization_id)):
|
||||
return Response({"detail": t("settings.queue_policy_forbidden")}, status=403)
|
||||
policy = policy_for(context.organization)
|
||||
changed = []
|
||||
for field in FIELDS:
|
||||
if field not in request.data:
|
||||
continue
|
||||
value = request.data[field]
|
||||
if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= MAX_MINUTES:
|
||||
return Response({"detail": t("settings.queue_minutes_range")}, status=400)
|
||||
if getattr(policy, field) != value:
|
||||
setattr(policy, field, value)
|
||||
changed.append(field)
|
||||
if changed:
|
||||
policy.updated_at = timezone.now()
|
||||
policy.updated_by = context.actor_user
|
||||
policy.save(update_fields=[*changed, "updated_at", "updated_by"])
|
||||
return Response(_payload(policy))
|
||||
@@ -13,12 +13,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from chatballs.realtime import publish
|
||||
|
||||
INBOX_EVENT = "inbox.changed"
|
||||
CONVERSATION_EVENT = "conversation.changed"
|
||||
@@ -32,27 +27,12 @@ def conversation_group(conversation_id: int) -> str:
|
||||
return f"conv.{conversation_id}"
|
||||
|
||||
|
||||
def _publish(group: str, payload: dict[str, object]) -> None:
|
||||
"""Оповещение — вспомогательный путь: его сбой не должен ронять запись.
|
||||
|
||||
Сообщение уже сохранено к моменту отправки; если канал недоступен, клиент
|
||||
узнает об изменении следующим опросом — он остаётся как запасной путь.
|
||||
"""
|
||||
layer = get_channel_layer()
|
||||
if layer is None:
|
||||
return
|
||||
try:
|
||||
async_to_sync(layer.group_send)(group, {"type": "fanout", "payload": payload})
|
||||
except Exception: # noqa: BLE001 — канал не должен ломать сохранение
|
||||
logger.warning("realtime fanout failed for %s", group, exc_info=True)
|
||||
|
||||
|
||||
def notify_inbox_changed(organization_id: int) -> None:
|
||||
_publish(inbox_group(organization_id), {"type": INBOX_EVENT})
|
||||
publish(inbox_group(organization_id), {"type": INBOX_EVENT})
|
||||
|
||||
|
||||
def notify_conversation_changed(conversation_id: int, *, organization_id: int) -> None:
|
||||
_publish(
|
||||
publish(
|
||||
conversation_group(conversation_id),
|
||||
{"type": CONVERSATION_EVENT, "conversationId": conversation_id},
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import FileResponse
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
@@ -21,6 +22,29 @@ class ConversationStatsView(ConversationViewBase):
|
||||
return Response(sales_overview_stats(request.tenant_context, period))
|
||||
|
||||
|
||||
class ContactAvatarView(ConversationViewBase):
|
||||
"""Фото контакта, скачанное у провайдера.
|
||||
|
||||
Отдаётся со своего адреса: страница рабочего места живёт под CSP
|
||||
`img-src 'self'`, и ссылка на CDN мессенджера до экрана не доезжает.
|
||||
Видно тому же, кто видит диалоги, — фото клиента и есть часть карточки.
|
||||
"""
|
||||
|
||||
def get(self, request: Request, contact_id: int):
|
||||
contact = Contact.objects.filter(
|
||||
organization=self._org(request), id=contact_id
|
||||
).first()
|
||||
if contact is None or not contact.avatar:
|
||||
return Response({"detail": t("sales.client_not_found")}, status=404)
|
||||
response = FileResponse(
|
||||
contact.avatar.open("rb"),
|
||||
content_type=contact.avatar_content_type or "application/octet-stream",
|
||||
filename="avatar",
|
||||
)
|
||||
response["Cache-Control"] = "private, max-age=86400"
|
||||
return response
|
||||
|
||||
|
||||
class ClientsView(ConversationViewBase):
|
||||
required_capability = "customers.view"
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.db.models import (
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from chatballs.api.pagination import SortKey
|
||||
from chatballs.conversations.models import (
|
||||
@@ -63,6 +64,11 @@ def order_conversations(
|
||||
Сортировка живёт на сервере вместе с окном: клиент видит не весь набор, и
|
||||
переставлять в браузере ему нечего. `waiting` — «ждущие человека первыми,
|
||||
дольше всех ждущий выше», остальные — по убыванию активности.
|
||||
|
||||
«Дольше всех ждущий» считается от waiting_since — момента, когда диалог
|
||||
встал в очередь. По времени последнего сообщения его считать нельзя: клиент,
|
||||
написавший повторно, двигал бы себя в конец очереди, и чем настойчивее он
|
||||
напоминал о себе, тем позже до него доходила очередь.
|
||||
"""
|
||||
if sort != "waiting":
|
||||
return queryset.order_by("-last_message_at", "-id"), ACTIVITY_KEYS
|
||||
@@ -72,7 +78,9 @@ def order_conversations(
|
||||
When(waits, then=Value(0)), default=Value(1), output_field=IntegerField()
|
||||
),
|
||||
_wait_at=Case(
|
||||
When(waits, then=F("last_message_at")),
|
||||
# Coalesce — страховка для строк, встававших в очередь до появления
|
||||
# waiting_since: без неё они ушли бы в конец очереди вместо начала.
|
||||
When(waits, then=Coalesce(F("waiting_since"), F("last_message_at"))),
|
||||
default=Value(_NOT_WAITING_AT),
|
||||
output_field=DateTimeField(),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.db.models import Count, Max, Q
|
||||
|
||||
from chatballs.conversations.contact_avatars import contact_avatar_url_in
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Conversation,
|
||||
@@ -156,15 +157,28 @@ def _history_item(conversation: Conversation) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _contact_username(conversation: Conversation) -> str:
|
||||
# Username живёт на identity подключения (у контакта их может быть несколько).
|
||||
# Только в detail-режиме — в списках это лишний запрос на каждый диалог.
|
||||
def _connection_identity(conversation: Conversation) -> ConnectionIdentity | None:
|
||||
# Username и подпись гостя живут на identity подключения (у контакта их
|
||||
# может быть несколько). Только в detail-режиме — в списках это лишний
|
||||
# запрос на каждый диалог.
|
||||
if not conversation.connection_id:
|
||||
return ""
|
||||
identity = ConnectionIdentity.objects.filter(
|
||||
return None
|
||||
return ConnectionIdentity.objects.filter(
|
||||
connection_id=conversation.connection_id, contact_id=conversation.contact_id
|
||||
).first()
|
||||
return identity.username if identity else ""
|
||||
|
||||
|
||||
def _contact_is_guest(contact, identity: ConnectionIdentity | None) -> bool:
|
||||
# Гость виджета получает имя «Гость · <код сессии>» на языке организации;
|
||||
# та же подпись записана в display_name его identity. Пока имя не сменили,
|
||||
# настоящего имени у контакта нет — подставлять его в ответ нельзя.
|
||||
if not contact.name:
|
||||
return True
|
||||
return bool(
|
||||
identity
|
||||
and identity.display_name == contact.name
|
||||
and identity.external_user_id[:6] in contact.name
|
||||
)
|
||||
|
||||
|
||||
def _contact_email(conversation: Conversation) -> str:
|
||||
@@ -205,6 +219,7 @@ def conversation_payload(
|
||||
# pending_counts_for); поштучный расчёт остаётся для одиночных ответов.
|
||||
last = None if detailed else (last_message or _last_message(conversation))
|
||||
channel = conversation.channel
|
||||
identity = _connection_identity(conversation) if detailed and conversation.contact_id else None
|
||||
payload = {
|
||||
"id": conversation.id,
|
||||
"channel": {
|
||||
@@ -228,12 +243,16 @@ def conversation_payload(
|
||||
"id": conversation.contact_id,
|
||||
"name": conversation.contact.name,
|
||||
"phone": conversation.contact.phone,
|
||||
"avatarUrl": conversation.contact.avatar_url,
|
||||
"avatarUrl": contact_avatar_url_in(
|
||||
conversation.contact, conversation.organization_id
|
||||
),
|
||||
"description": conversation.contact.description,
|
||||
"company": conversation.contact.company,
|
||||
"city": conversation.contact.city,
|
||||
"email": _contact_email(conversation),
|
||||
"username": _contact_username(conversation) if detailed else "",
|
||||
"username": identity.username if identity else "",
|
||||
# Для переменной {{client_name}} шаблонов ответов.
|
||||
**({"isGuest": _contact_is_guest(conversation.contact, identity)} if detailed else {}),
|
||||
}
|
||||
if conversation.contact_id
|
||||
else None
|
||||
@@ -257,6 +276,17 @@ def conversation_payload(
|
||||
"isAssignedToViewer": bool(
|
||||
viewer_id and conversation.assigned_operator_id == viewer_id
|
||||
),
|
||||
# Ожидание (макет «Очередь и уведомления», кадры Q3 и Q4): с какого
|
||||
# момента диалог ждёт человека и с какого — конкретного человека.
|
||||
# Считать «сколько осталось» клиент должен сам: минута на сервере и
|
||||
# минута на экране расходятся, и показывать замерший счётчик хуже, чем
|
||||
# не показывать никакого.
|
||||
"waitingSince": (
|
||||
conversation.waiting_since.isoformat() if conversation.waiting_since else None
|
||||
),
|
||||
"assignedAt": (
|
||||
conversation.assigned_at.isoformat() if conversation.assigned_at else None
|
||||
),
|
||||
"group": (
|
||||
{"id": conversation.group_id, "name": conversation.group.name, "color": conversation.group.color}
|
||||
if conversation.group_id
|
||||
|
||||
@@ -13,9 +13,17 @@ from chatballs.conversations.models import (
|
||||
MessageKind,
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, leave_queue
|
||||
from chatballs.conversations.realtime import notify_conversation_changed
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.notifications.models import (
|
||||
Notification,
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
)
|
||||
from chatballs.notifications.services import notify
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -37,7 +45,7 @@ def _require_open(conversation: Conversation) -> None:
|
||||
|
||||
|
||||
|
||||
def _operator_label(operator) -> str:
|
||||
def operator_label(operator) -> str:
|
||||
|
||||
return getattr(operator, "full_name", "") or operator.email
|
||||
|
||||
@@ -101,7 +109,9 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
|
||||
leave_queue(conversation)
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder", "waiting_since"])
|
||||
|
||||
Message.objects.create(
|
||||
|
||||
@@ -111,9 +121,9 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
|
||||
|
||||
system_event=SystemEvent.OPERATOR_TOOK,
|
||||
|
||||
system_params={"operator": _operator_label(operator)},
|
||||
system_params={"operator": operator_label(operator)},
|
||||
|
||||
text=f"Оператор {_operator_label(operator)} перехватил диалог",
|
||||
text=f"Оператор {operator_label(operator)} перехватил диалог",
|
||||
|
||||
)
|
||||
|
||||
@@ -147,7 +157,9 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.AI
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
|
||||
leave_queue(conversation)
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder", "waiting_since"])
|
||||
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
@@ -176,13 +188,12 @@ def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversa
|
||||
|
||||
_require_open(conversation)
|
||||
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
enter_queue(conversation)
|
||||
|
||||
conversation.assigned_operator = None
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
|
||||
conversation.save(update_fields=[*QUEUE_FIELDS, "assigned_operator"])
|
||||
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
@@ -337,6 +348,8 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.NOBODY
|
||||
|
||||
leave_queue(conversation)
|
||||
|
||||
conversation.save(
|
||||
|
||||
update_fields=[
|
||||
@@ -348,6 +361,7 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
|
||||
"assigned_operator",
|
||||
|
||||
"expected_responder",
|
||||
"waiting_since",
|
||||
|
||||
]
|
||||
|
||||
@@ -383,6 +397,8 @@ def mark_conversation_as_spam(
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.NOBODY
|
||||
|
||||
leave_queue(conversation)
|
||||
|
||||
conversation.save(
|
||||
|
||||
update_fields=[
|
||||
@@ -394,6 +410,7 @@ def mark_conversation_as_spam(
|
||||
"assigned_operator",
|
||||
|
||||
"expected_responder",
|
||||
"waiting_since",
|
||||
|
||||
]
|
||||
|
||||
@@ -401,3 +418,91 @@ def mark_conversation_as_spam(
|
||||
|
||||
return conversation
|
||||
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def assign_operator(*, context: TenantContext, conversation_id: int, assignee) -> Conversation:
|
||||
"""Назначить ответственного за диалог (или снять назначение).
|
||||
|
||||
Назначение — не взятие: человек ещё не ответил и мог даже не увидеть
|
||||
диалог. Но из общей очереди диалог уходит — отвечать в нём, кроме
|
||||
назначенного и руководства, уже никто не может, — поэтому назначенного надо
|
||||
позвать лично и поставить срок. Не успел — диалог возвращается в общую
|
||||
очередь (chatballs.conversations.escalation).
|
||||
|
||||
Раньше эта операция молча писала внешний ключ: назначенный не узнавал,
|
||||
диалог продолжал числиться в общей очереди, а взять его оттуда было уже
|
||||
нельзя.
|
||||
"""
|
||||
conversation = Conversation.objects.select_for_update().get(
|
||||
id=conversation_id, organization=context.organization
|
||||
)
|
||||
_require_open(conversation)
|
||||
conversation.assigned_operator = assignee
|
||||
conversation.assigned_at = timezone.now() if assignee is not None else None
|
||||
conversation.save(update_fields=["assigned_operator", "assigned_at"])
|
||||
if assignee is None:
|
||||
return conversation
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.ASSIGNED_TO,
|
||||
system_params={"operator": operator_label(assignee)},
|
||||
text=f"Диалог назначен на {operator_label(assignee)}",
|
||||
)
|
||||
# Себе назначил — сам и знает.
|
||||
if context.actor_user is not None and assignee.pk == context.actor_user.pk:
|
||||
return conversation
|
||||
contact_name = getattr(conversation.contact, "name", "") or t("conversations.guest")
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_ASSIGNED,
|
||||
audience=NotificationAudience.USER,
|
||||
recipient_user=assignee,
|
||||
title=f"Вам назначен диалог · {contact_name}",
|
||||
title_key="notifications.assigned_to_you",
|
||||
text_params={"contact": contact_name},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
dedup_key=f"assign:{conversation.id}:{assignee.pk}",
|
||||
)
|
||||
return conversation
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_conversation(*, context: TenantContext, conversation: Conversation) -> None:
|
||||
"""Удалить диалог насовсем: переписку, вложения, звонки и оклики о нём.
|
||||
|
||||
Раньше «удалить» означало архив: диалог пропадал из списков, но продолжал
|
||||
жить — в него приходили сообщения из канала, он поднимал уведомления и
|
||||
возвращался клиенту в виджете как ни в чём не бывало. Решение владельца
|
||||
2026-09-14: удалён — значит удалён. Права на это есть только у владельца и
|
||||
администратора (проверяет представление).
|
||||
|
||||
Файлы удаляются явно: `FileField` при удалении строки оставляет их на
|
||||
диске, а «удалён» не должно означать «лежит в media».
|
||||
"""
|
||||
conversation_id = conversation.id
|
||||
organization_id = conversation.organization_id
|
||||
for message in conversation.messages.exclude(audio="", attachment=""):
|
||||
if message.audio:
|
||||
message.audio.delete(save=False)
|
||||
if message.attachment:
|
||||
message.attachment.delete(save=False)
|
||||
# Оклик ведёт в диалог, которого больше нет: и в списке уведомлений, и в
|
||||
# мессенджере сотрудника такая строка — тупик.
|
||||
Notification.objects.filter(
|
||||
organization_id=organization_id,
|
||||
source_type="Conversation",
|
||||
source_id=str(conversation_id),
|
||||
).delete()
|
||||
# Звонки держат диалог внешним ключом PROTECT; история звонка без самого
|
||||
# диалога ничего не значит, поэтому уходит вместе с ним.
|
||||
from chatballs.calls.models import CallSession
|
||||
|
||||
CallSession.objects.filter(conversation_id=conversation_id).delete()
|
||||
conversation.delete()
|
||||
# Открытые рабочие места узнают об этом событием: у того, кто держал диалог
|
||||
# открытым, он должен закрыться, а не висеть мёртвой карточкой.
|
||||
notify_conversation_changed(conversation_id, organization_id=organization_id)
|
||||
@@ -8,11 +8,10 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.db.models import Count, Sum
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import TruncDate, TruncHour
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.models import LlmInvocation
|
||||
from chatballs.channels.selectors import channels_in_organization
|
||||
from chatballs.conversations.models import (
|
||||
ControlMode,
|
||||
@@ -21,6 +20,7 @@ from chatballs.conversations.models import (
|
||||
LifecycleState,
|
||||
Message,
|
||||
)
|
||||
from chatballs.conversations.selectors import apply_conversation_visibility
|
||||
from chatballs.i18n import t
|
||||
|
||||
_ACTIVE_WINDOW = timedelta(minutes=15)
|
||||
@@ -64,28 +64,32 @@ def _chart(
|
||||
return {"values": values, "labels": labels}
|
||||
|
||||
|
||||
def _ai_cost(
|
||||
org_id: int,
|
||||
start: datetime,
|
||||
end: datetime | None = None,
|
||||
) -> int:
|
||||
qs = LlmInvocation.objects.filter(channel__organization_id=org_id, created_at__gte=start)
|
||||
if end is not None:
|
||||
qs = qs.filter(created_at__lt=end)
|
||||
return qs.aggregate(total=Sum("cost_micros"))["total"] or 0
|
||||
|
||||
|
||||
def sales_overview_stats(context, period: str) -> dict:
|
||||
organization_id = context.organization_id
|
||||
now = timezone.now()
|
||||
start, prev_start = _window(period, now)
|
||||
|
||||
open_qs = Conversation.objects.filter(organization_id=organization_id, lifecycle=LifecycleState.OPEN)
|
||||
# Удалённые по-старому (архивные) диалоги в живых числах не участвуют: они
|
||||
# не видны ни в одном списке, а бейдж из-за них не опускался до нуля.
|
||||
open_qs = Conversation.objects.filter(
|
||||
organization_id=organization_id,
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
archived_at__isnull=True,
|
||||
)
|
||||
open_dialogs = open_qs.count()
|
||||
# «Ждут оператора» = очередь: диалоги, которые никто не взял (PAUSED).
|
||||
# Взятые оператором (HUMAN), но ещё без ответа, очередью не считаются —
|
||||
# иначе бейдж «Диалоги» показывает число при полностью разобранном inbox.
|
||||
waiting = open_qs.filter(control_mode=ControlMode.PAUSED).count()
|
||||
#
|
||||
# Это же число висит бейджем на пункте «Чат», поэтому считается по границе
|
||||
# видимости сотрудника: иначе он видит счётчик диалогов, которых не увидит,
|
||||
# и разобрать его не может (ADR-CHATBALLS-0043 §4). Счётчики вкладок списка
|
||||
# (chat_extras_views) считают ровно так же.
|
||||
waiting = (
|
||||
apply_conversation_visibility(open_qs, context)
|
||||
.filter(control_mode=ControlMode.PAUSED)
|
||||
.count()
|
||||
)
|
||||
ops = {
|
||||
"openDialogs": open_dialogs,
|
||||
"activeNow": open_qs.filter(last_activity_at__gte=now - _ACTIVE_WINDOW).count(),
|
||||
@@ -105,8 +109,6 @@ def sales_overview_stats(context, period: str) -> dict:
|
||||
conversation__organization_id=organization_id,
|
||||
created_at__gte=start,
|
||||
).count(),
|
||||
"aiCostMicros": _ai_cost(organization_id, start),
|
||||
"aiCostPrevMicros": _ai_cost(organization_id, prev_start, start),
|
||||
}
|
||||
|
||||
open_by_channel = dict(open_qs.values_list("channel_id").annotate(c=Count("id")))
|
||||
|
||||
@@ -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()
|
||||
@@ -1,10 +1,11 @@
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
@@ -13,6 +14,7 @@ from chatballs.conversations.models import (
|
||||
LifecycleState,
|
||||
ReplyTemplate,
|
||||
)
|
||||
from chatballs.conversations.serializers import _contact_is_guest
|
||||
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
@@ -339,31 +341,94 @@ class ReplyTemplateTests(ChatExtrasTestCase):
|
||||
self.assertEqual(deleted.status_code, 204)
|
||||
self.assertFalse(ReplyTemplate.objects.exists())
|
||||
|
||||
def test_rename_to_taken_title_conflicts(self) -> None:
|
||||
ReplyTemplate.objects.create(
|
||||
organization_id=self.organization.id, title="Приветствие", text="Здравствуйте!"
|
||||
)
|
||||
other = ReplyTemplate.objects.create(
|
||||
organization_id=self.organization.id, title="Прощание", text="До свидания!"
|
||||
)
|
||||
|
||||
class LaunchChecklistTests(TestCase):
|
||||
taken = self.admin_client.patch(
|
||||
f"/api/v1/conversations/templates/{other.id}/",
|
||||
data=json.dumps({"title": "приветствие"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(taken.status_code, 409)
|
||||
other.refresh_from_db()
|
||||
self.assertEqual(other.title, "Прощание")
|
||||
|
||||
# Смена регистра собственного названия — не конфликт.
|
||||
recased = self.admin_client.patch(
|
||||
f"/api/v1/conversations/templates/{other.id}/",
|
||||
data=json.dumps({"title": "ПРОЩАНИЕ"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(recased.status_code, 200)
|
||||
self.assertEqual(recased.json()["template"]["title"], "ПРОЩАНИЕ")
|
||||
|
||||
def test_template_variables_are_checked(self) -> None:
|
||||
created = self.admin_client.post(
|
||||
"/api/v1/conversations/templates/",
|
||||
data=json.dumps({"title": "Приветствие", "text": "Здравствуйте, {{client_name}}! Я {{ operator_name }}, {дата}."}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
|
||||
typo = self.admin_client.post(
|
||||
"/api/v1/conversations/templates/",
|
||||
data=json.dumps({"title": "Опечатка", "text": "Здравствуйте, {{clent_name}}!"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(typo.status_code, 400)
|
||||
self.assertIn("{{clent_name}}", typo.json()["detail"])
|
||||
|
||||
patched = self.admin_client.patch(
|
||||
f"/api/v1/conversations/templates/{created.json()['template']['id']}/",
|
||||
data=json.dumps({"text": "{{order_id}}"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(patched.status_code, 400)
|
||||
|
||||
|
||||
class ContactGuestTests(SimpleTestCase):
|
||||
def test_widget_guest_label_is_not_a_name(self) -> None:
|
||||
identity = ConnectionIdentity(external_user_id="3ffa7b0c9d", display_name="Гость · 3ffa7b")
|
||||
self.assertTrue(_contact_is_guest(Contact(name="Гость · 3ffa7b"), identity))
|
||||
self.assertTrue(_contact_is_guest(Contact(name=""), None))
|
||||
# Гость представился — имя уже настоящее.
|
||||
self.assertFalse(_contact_is_guest(Contact(name="Дмитрий Орлов"), identity))
|
||||
self.assertFalse(_contact_is_guest(Contact(name="Иван"), None))
|
||||
|
||||
|
||||
class OnboardingTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.organization = Organization.objects.create(name="New", slug="launch-org")
|
||||
owner = HumanUser.objects.create_user(
|
||||
self.owner = HumanUser.objects.create_user(
|
||||
email="owner@launch.test", password="Password-123"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
user=owner,
|
||||
self.membership = OrganizationMembership.objects.create(
|
||||
user=self.owner,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.OWNER,
|
||||
position_title="Owner",
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(owner)
|
||||
self.client.force_authenticate(self.owner)
|
||||
|
||||
def test_checklist_marks_steps_by_fact(self) -> None:
|
||||
initial = self.client.get("/api/v1/company/launch-checklist/").json()
|
||||
def test_steps_are_marked_by_fact(self) -> None:
|
||||
initial = self.client.get("/api/v1/company/onboarding/").json()
|
||||
self.assertEqual(
|
||||
initial,
|
||||
initial["steps"],
|
||||
{
|
||||
"agentCreated": False,
|
||||
"providerConnected": False,
|
||||
"agentActive": False,
|
||||
"knowledgeFilled": False,
|
||||
"connectionBound": False,
|
||||
"widgetPublished": False,
|
||||
"employeeInvited": False,
|
||||
"done": False,
|
||||
"platformConfigured": False,
|
||||
"firstConversation": False,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -393,5 +458,56 @@ class LaunchChecklistTests(TestCase):
|
||||
position_title="Operator",
|
||||
)
|
||||
|
||||
final = self.client.get("/api/v1/company/launch-checklist/").json()
|
||||
self.assertTrue(final["done"])
|
||||
final = self.client.get("/api/v1/company/onboarding/").json()
|
||||
self.assertTrue(final["steps"]["connectionBound"])
|
||||
self.assertTrue(final["steps"]["employeeInvited"])
|
||||
|
||||
def test_existing_member_has_not_dismissed_onboarding(self) -> None:
|
||||
"""Признак пустой у всех, кто заведён до появления онбординга."""
|
||||
|
||||
payload = self.client.get("/api/v1/company/onboarding/").json()
|
||||
self.assertIsNone(payload["dismissedAt"])
|
||||
self.assertIsNone(payload["completedAt"])
|
||||
|
||||
def test_dismiss_and_complete_and_restart(self) -> None:
|
||||
dismissed = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
|
||||
).json()
|
||||
self.assertIsNotNone(dismissed["dismissedAt"])
|
||||
self.assertIsNone(dismissed["completedAt"])
|
||||
|
||||
completed = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "complete"}, format="json"
|
||||
).json()
|
||||
self.assertIsNotNone(completed["completedAt"])
|
||||
|
||||
restarted = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "restart"}, format="json"
|
||||
).json()
|
||||
self.assertIsNone(restarted["dismissedAt"])
|
||||
self.assertIsNone(restarted["completedAt"])
|
||||
|
||||
def test_dismissal_is_personal(self) -> None:
|
||||
"""Закрытие одним администратором не прячет визард у второго."""
|
||||
|
||||
self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
|
||||
)
|
||||
colleague = HumanUser.objects.create_user(
|
||||
email="admin@launch.test", password="Password-123"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
user=colleague,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.ADMIN,
|
||||
position_title="Admin",
|
||||
)
|
||||
other = APIClient()
|
||||
other.force_authenticate(colleague)
|
||||
self.assertIsNone(other.get("/api/v1/company/onboarding/").json()["dismissedAt"])
|
||||
|
||||
def test_unknown_action_is_rejected(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "nope"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Фото контакта из мессенджера: скачивается один раз и отдаётся со своего адреса."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.contact_avatars import (
|
||||
contact_avatar_url_in,
|
||||
refresh_contact_avatar,
|
||||
)
|
||||
from chatballs.conversations.models import Contact
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
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
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 64
|
||||
|
||||
|
||||
class ContactAvatarTests(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="Линия"
|
||||
)
|
||||
self.contact = Contact.objects.create(
|
||||
organization=self.organization, name="Иван"
|
||||
)
|
||||
self.inbound = InboundMessage(
|
||||
external_id="1",
|
||||
user_id="777",
|
||||
chat_id="777",
|
||||
text="привет",
|
||||
display_name="Иван",
|
||||
)
|
||||
|
||||
def _integration(self, provider: str) -> Integration:
|
||||
return Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=provider,
|
||||
name=provider,
|
||||
secret="token",
|
||||
channel=self.channel,
|
||||
)
|
||||
|
||||
def test_telegram_photo_is_asked_once_and_stored(self) -> None:
|
||||
integration = self._integration(IntegrationProvider.TELEGRAM)
|
||||
with mock.patch(
|
||||
"chatballs.conversations.transports.telegram.download_profile_photo",
|
||||
return_value=PNG,
|
||||
) as download:
|
||||
with tenant_atomic(self.organization.id):
|
||||
refresh_contact_avatar(integration, self.inbound, self.contact)
|
||||
with tenant_atomic(self.organization.id):
|
||||
refresh_contact_avatar(integration, self.inbound, self.contact)
|
||||
self.assertEqual(download.call_count, 1)
|
||||
self.contact.refresh_from_db()
|
||||
self.assertTrue(self.contact.avatar)
|
||||
self.assertEqual(self.contact.avatar_content_type, "image/png")
|
||||
self.assertEqual(self.contact.avatar_source, "tg:777")
|
||||
|
||||
def test_telegram_without_photo_is_not_asked_again(self) -> None:
|
||||
integration = self._integration(IntegrationProvider.TELEGRAM)
|
||||
with mock.patch(
|
||||
"chatballs.conversations.transports.telegram.download_profile_photo",
|
||||
return_value=None,
|
||||
) as download:
|
||||
with tenant_atomic(self.organization.id):
|
||||
refresh_contact_avatar(integration, self.inbound, self.contact)
|
||||
with tenant_atomic(self.organization.id):
|
||||
refresh_contact_avatar(integration, self.inbound, self.contact)
|
||||
self.assertEqual(download.call_count, 1)
|
||||
self.contact.refresh_from_db()
|
||||
self.assertFalse(self.contact.avatar)
|
||||
|
||||
def test_max_photo_follows_the_url_from_the_update(self) -> None:
|
||||
integration = self._integration(IntegrationProvider.MAX)
|
||||
inbound = InboundMessage(
|
||||
external_id="1",
|
||||
user_id="777",
|
||||
chat_id="",
|
||||
text="привет",
|
||||
display_name="Иван",
|
||||
avatar_url="https://cdn.example.test/ivan.png",
|
||||
)
|
||||
with mock.patch(
|
||||
"chatballs.conversations.transports.max.download_file",
|
||||
return_value=(PNG, "image/png"),
|
||||
) as download:
|
||||
with tenant_atomic(self.organization.id):
|
||||
refresh_contact_avatar(integration, inbound, self.contact)
|
||||
with tenant_atomic(self.organization.id):
|
||||
refresh_contact_avatar(integration, inbound, self.contact)
|
||||
self.assertEqual(download.call_count, 1)
|
||||
self.contact.refresh_from_db()
|
||||
self.assertEqual(self.contact.avatar_source, "https://cdn.example.test/ivan.png")
|
||||
|
||||
def test_download_failure_leaves_the_contact_alone(self) -> None:
|
||||
integration = self._integration(IntegrationProvider.TELEGRAM)
|
||||
with mock.patch(
|
||||
"chatballs.conversations.transports.telegram.download_profile_photo",
|
||||
side_effect=OSError("network is down"),
|
||||
), tenant_atomic(self.organization.id):
|
||||
refresh_contact_avatar(integration, self.inbound, self.contact)
|
||||
self.contact.refresh_from_db()
|
||||
self.assertFalse(self.contact.avatar)
|
||||
self.assertEqual(self.contact.avatar_source, "")
|
||||
|
||||
def test_stored_photo_is_served_from_our_own_address(self) -> None:
|
||||
integration = self._integration(IntegrationProvider.TELEGRAM)
|
||||
with mock.patch(
|
||||
"chatballs.conversations.transports.telegram.download_profile_photo",
|
||||
return_value=PNG,
|
||||
), tenant_atomic(self.organization.id):
|
||||
refresh_contact_avatar(integration, self.inbound, self.contact)
|
||||
self.contact.refresh_from_db()
|
||||
url = contact_avatar_url_in(self.contact, self.organization.id)
|
||||
self.assertIn(f"/conversations/clients/{self.contact.id}/avatar/", url)
|
||||
|
||||
client = APIClient()
|
||||
client.login(username="owner@example.com", password="temporary-password")
|
||||
response = client.get(f"/api/v1/conversations/clients/{self.contact.id}/avatar/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], "image/png")
|
||||
self.assertEqual(b"".join(response.streaming_content), PNG)
|
||||
|
||||
def test_contact_without_photo_has_no_url(self) -> None:
|
||||
self.assertIsNone(contact_avatar_url_in(self.contact, self.organization.id))
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Удаление диалога: право, полнота и последствия для виджета.
|
||||
|
||||
Решение владельца 2026-09-14: «удалить» означает удалить, а не отправить в
|
||||
архив. Удалённый диалог не принимает сообщений, не окликает уведомлениями и
|
||||
обнуляет переписку в виджете клиента.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import (
|
||||
Contact,
|
||||
Conversation,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
)
|
||||
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.notifications.models import Notification, NotificationType
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class ConversationDeleteTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.organization = Organization.objects.create(name="Chat", slug="chat-delete")
|
||||
self.owner = self._member("owner@delete.test", EmployeeRole.OWNER)
|
||||
self.employee = self._member("employee@delete.test", EmployeeRole.EMPLOYEE)
|
||||
group = EmployeeGroup.objects.create(
|
||||
organization=self.organization, name="Операторы"
|
||||
)
|
||||
EmployeeGroupMember.objects.create(
|
||||
organization=self.organization, group=group, employee=self.employee
|
||||
)
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization, code="line", name="Линия"
|
||||
)
|
||||
contact = Contact.objects.create(organization=self.organization, name="Иван")
|
||||
self.conversation = Conversation.objects.create(
|
||||
organization=self.organization, channel=self.channel, contact=contact
|
||||
)
|
||||
self.message = Message.objects.create(
|
||||
conversation=self.conversation,
|
||||
author_type=MessageAuthor.CONTACT,
|
||||
text="Здравствуйте",
|
||||
)
|
||||
self.owner_client = APIClient()
|
||||
self.owner_client.force_authenticate(self.owner.user)
|
||||
self.employee_client = APIClient()
|
||||
self.employee_client.force_authenticate(self.employee.user)
|
||||
|
||||
def _member(self, email: str, role: str) -> OrganizationMembership:
|
||||
user = HumanUser.objects.create_user(email=email, password="Password-123")
|
||||
return OrganizationMembership.objects.create(
|
||||
user=user,
|
||||
organization=self.organization,
|
||||
role=role,
|
||||
position_title="Specialist",
|
||||
)
|
||||
|
||||
def _url(self) -> str:
|
||||
return f"/api/v1/conversations/{self.conversation.id}/"
|
||||
|
||||
def test_operator_cannot_delete(self) -> None:
|
||||
response = self.employee_client.delete(self._url())
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertTrue(Conversation.objects.filter(id=self.conversation.id).exists())
|
||||
|
||||
def test_owner_deletes_conversation_with_history(self) -> None:
|
||||
response = self.owner_client.delete(self._url())
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertFalse(Conversation.objects.filter(id=self.conversation.id).exists())
|
||||
self.assertFalse(Message.objects.filter(id=self.message.id).exists())
|
||||
|
||||
def test_notifications_about_conversation_are_gone(self) -> None:
|
||||
Notification.objects.create(
|
||||
organization=self.organization,
|
||||
type=NotificationType.NEW_DIALOG,
|
||||
title="Новый диалог",
|
||||
target_route="chat",
|
||||
target_id=str(self.conversation.id),
|
||||
source_type="Conversation",
|
||||
source_id=str(self.conversation.id),
|
||||
)
|
||||
self.owner_client.delete(self._url())
|
||||
self.assertEqual(Notification.objects.count(), 0)
|
||||
|
||||
def test_deleted_conversation_cannot_be_written_to(self) -> None:
|
||||
self.owner_client.delete(self._url())
|
||||
response = self.owner_client.post(
|
||||
f"{self._url()}messages/",
|
||||
data=json.dumps({"text": "ещё раз"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_missing_conversation_is_not_found(self) -> None:
|
||||
self.owner_client.delete(self._url())
|
||||
self.assertEqual(self.owner_client.delete(self._url()).status_code, 404)
|
||||
@@ -1,8 +1,15 @@
|
||||
"""Справочник выбора ответственного: ограниченная выдача и поиск на сервере."""
|
||||
"""Справочник выбора ответственного: ограниченная выдача, поиск и присутствие."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.chat_extras_views import DIRECTORY_LIMIT
|
||||
from chatballs.conversations.models import Contact, Conversation, LifecycleState
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
@@ -10,6 +17,7 @@ from chatballs.identity.models import (
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.presence import touch
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
@@ -54,3 +62,65 @@ class ConversationDirectoryTests(TestCase):
|
||||
|
||||
def test_groups_are_returned_as_before(self) -> None:
|
||||
self.assertIn("groups", self._directory())
|
||||
|
||||
|
||||
class DirectoryPresenceTests(TestCase):
|
||||
"""Присутствие и загрузка в выборе ответственного (макет Q5).
|
||||
|
||||
Назначить отсутствующего можно — признак ничего не запрещает; он лишь
|
||||
отвечает на вопрос «кто сейчас за рабочим местом».
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
cache.clear()
|
||||
self.organization = Organization.objects.create(name="Example", slug="directory-presence")
|
||||
self.owner = self._employee("owner@dir.test", EmployeeRole.OWNER)
|
||||
self.away = self._employee("away@dir.test", EmployeeRole.EMPLOYEE)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.owner.user)
|
||||
|
||||
def _employee(self, email: str, role: str) -> OrganizationMembership:
|
||||
user = HumanUser.objects.create_user(email=email, password="Password-123")
|
||||
return OrganizationMembership.objects.create(
|
||||
user=user, organization=self.organization, role=role, position_title="Specialist"
|
||||
)
|
||||
|
||||
def _rows(self) -> dict[int, dict]:
|
||||
response = self.client.get("/api/v1/conversations/directory/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return {row["id"]: row for row in response.json()["employees"]}
|
||||
|
||||
def test_presence_is_reported_for_whoever_is_in_the_app(self) -> None:
|
||||
touch(self.organization.id, self.owner.user_id)
|
||||
rows = self._rows()
|
||||
self.assertTrue(rows[self.owner.user_id]["online"])
|
||||
self.assertIsNotNone(rows[self.owner.user_id]["lastSeenAt"])
|
||||
self.assertFalse(rows[self.away.user_id]["online"])
|
||||
self.assertIsNone(rows[self.away.user_id]["lastSeenAt"])
|
||||
|
||||
def test_long_gone_employee_is_not_online_but_remembered(self) -> None:
|
||||
with mock.patch(
|
||||
"chatballs.presence.timezone.now",
|
||||
return_value=timezone.now() - timedelta(minutes=25),
|
||||
):
|
||||
touch(self.organization.id, self.away.user_id)
|
||||
row = self._rows()[self.away.user_id]
|
||||
self.assertFalse(row["online"])
|
||||
self.assertIsNotNone(row["lastSeenAt"])
|
||||
|
||||
def test_load_counts_only_open_dialogs_of_that_person(self) -> None:
|
||||
channel = Channel.objects.create(
|
||||
organization=self.organization, code="dir-presence", name="Канал"
|
||||
)
|
||||
contact = Contact.objects.create(organization=self.organization, name="Клиент")
|
||||
for lifecycle in (LifecycleState.OPEN, LifecycleState.OPEN, LifecycleState.CLOSED):
|
||||
Conversation.objects.create(
|
||||
organization=self.organization,
|
||||
channel=channel,
|
||||
contact=contact,
|
||||
lifecycle=lifecycle,
|
||||
assigned_operator=self.owner.user,
|
||||
)
|
||||
rows = self._rows()
|
||||
self.assertEqual(rows[self.owner.user_id]["openDialogs"], 2)
|
||||
self.assertEqual(rows[self.away.user_id]["openDialogs"], 0)
|
||||
@@ -15,9 +15,11 @@ from chatballs.conversations.models import Contact, Conversation, MessageAuthor
|
||||
from chatballs.conversations.selectors import conversation_messages
|
||||
from chatballs.conversations.serializers import conversation_payload, message_payload
|
||||
from chatballs.conversations.transports import email as email_transport
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
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 = {
|
||||
|
||||
@@ -359,11 +361,11 @@ class EmailPollTests(TestCase):
|
||||
|
||||
with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
|
||||
|
||||
messages, marker = email_transport.poll_updates(integration)
|
||||
with self.assertRaises(PollFailed):
|
||||
email_transport.poll_updates(integration)
|
||||
|
||||
self.assertEqual(messages, [])
|
||||
|
||||
self.assertEqual(marker, "7:99")
|
||||
# Курсор не сдвинулся: следующий удачный опрос продолжит с того же места.
|
||||
self.assertEqual(integration.poll_marker, "7:99")
|
||||
|
||||
|
||||
|
||||
@@ -504,14 +506,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:
|
||||
|
||||
@@ -23,6 +23,7 @@ from chatballs.integrations.models import (
|
||||
IntegrationProvider,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import run_pending_ai_turns
|
||||
|
||||
|
||||
def _connection(channel: Channel) -> Integration:
|
||||
@@ -71,33 +72,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 +124,7 @@ class OperatorOnlyIngestTests(TestCase):
|
||||
|
||||
)
|
||||
|
||||
ai_turn.assert_not_called()
|
||||
self.assertEqual(turns, 0)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
@@ -179,7 +168,7 @@ class OperatorOnlyIngestTests(TestCase):
|
||||
|
||||
|
||||
|
||||
ai_turn, send = self._ingest_without_ai(
|
||||
turns, send = self._ingest_without_ai(
|
||||
|
||||
InboundMessage(
|
||||
|
||||
@@ -211,7 +200,7 @@ class OperatorOnlyIngestTests(TestCase):
|
||||
|
||||
self.assertEqual(conversation.messages.count(), 1)
|
||||
|
||||
ai_turn.assert_not_called()
|
||||
self.assertEqual(turns, 0)
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Сбой опроса подключения: пауза с удвоением и тишина в журнале.
|
||||
|
||||
Раньше подключение с ненастоящим токеном писало предупреждение каждые три
|
||||
секунды. Теперь после сбоя оно пропускается с растущей паузой, журнал видит
|
||||
первый сбой, выход на максимальную паузу и восстановление, а курсор при
|
||||
сбое не двигается. Демо-подключения не опрашиваются вовсе.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations import poller
|
||||
from chatballs.conversations.transports import backoff, poll
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
|
||||
|
||||
class PollBackoffTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
backoff.reset()
|
||||
self.addCleanup(backoff.reset)
|
||||
self.organization = Organization.objects.create(name="Poll", slug="poll-org")
|
||||
with tenant_atomic(self.organization.id):
|
||||
self.channel = Channel.objects.create(organization=self.organization, name="Main", code="main")
|
||||
self.integration = Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
name="Bot",
|
||||
secret="0000:not-a-token",
|
||||
channel=self.channel,
|
||||
poll_marker="41",
|
||||
)
|
||||
|
||||
def _fail(self, *_args, **_kwargs):
|
||||
raise PollFailed("HTTP Error 401: Unauthorized")
|
||||
|
||||
def test_failure_is_logged_once_and_then_skipped(self) -> None:
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
|
||||
with self.assertLogs("chatballs.conversations.transports.backoff", level="WARNING") as logs:
|
||||
self.assertEqual(poll(self.integration), ([], "41"))
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
self.assertIn("401", logs.output[0])
|
||||
# Пока пауза не вышла, транспорт не вызывается и журнал молчит.
|
||||
with mock.patch.object(backoff, "_now", return_value=backoff._now()):
|
||||
with self.assertNoLogs("chatballs.conversations.transports.backoff", level="WARNING"):
|
||||
self.assertEqual(poll(self.integration), ([], "41"))
|
||||
self.assertTrue(backoff.should_skip(self.integration.id))
|
||||
|
||||
def test_delay_doubles_up_to_the_cap_and_recovery_is_logged(self) -> None:
|
||||
clock = [1000.0]
|
||||
with mock.patch.object(backoff, "_now", side_effect=lambda: clock[0]):
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
|
||||
delays = []
|
||||
for _ in range(10):
|
||||
poll(self.integration)
|
||||
state = backoff._failures[self.integration.id]
|
||||
delays.append(state.delay)
|
||||
clock[0] = state.next_attempt_at # ждём ровно до следующей попытки
|
||||
self.assertEqual(delays[:3], [6.0, 12.0, 24.0])
|
||||
self.assertEqual(delays[-1], backoff.MAX_DELAY_SECONDS)
|
||||
self.assertTrue(all(delay <= backoff.MAX_DELAY_SECONDS for delay in delays))
|
||||
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: lambda _i: ([], "42")}):
|
||||
with self.assertLogs("chatballs.conversations.transports.backoff", level="INFO") as logs:
|
||||
self.assertEqual(poll(self.integration), ([], "42"))
|
||||
self.assertIn("recovered", logs.output[0])
|
||||
self.assertNotIn(self.integration.id, backoff._failures)
|
||||
|
||||
def test_demo_seed_connections_are_not_polled(self) -> None:
|
||||
with tenant_atomic(self.organization.id):
|
||||
Integration.objects.filter(pk=self.integration.pk).update(config={"demoSeed": True})
|
||||
context = TenantContext.for_resource(self.organization)
|
||||
with mock.patch("chatballs.conversations.poller.transports.poll") as polled:
|
||||
poller.poll_all_messengers(context)
|
||||
polled.assert_not_called()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Эскалация ждущих диалогов и личная очередь назначенного.
|
||||
|
||||
Регрессия, ради которой всё это заведено: про ждущий диалог операторов окликали
|
||||
ровно один раз, и дальше суточный dedup гарантировал тишину. Назначение при этом
|
||||
молча писало внешний ключ — назначенный не узнавал, а из общей очереди диалог
|
||||
уже ушёл.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.escalation import sweep_waiting_conversations
|
||||
from chatballs.conversations.models import (
|
||||
Contact,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ExpectedResponder,
|
||||
LifecycleState,
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.conversations.queue_models import policy_for
|
||||
from chatballs.conversations.services import assign_operator
|
||||
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.notifications.models import Notification, NotificationAudience
|
||||
from chatballs.presence import touch
|
||||
from chatballs.testing import tenant_context_for
|
||||
|
||||
|
||||
class QueueTestBase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.organization = Organization.objects.create(name="Example", slug="queue-escalation")
|
||||
self.support = EmployeeGroup.objects.create(
|
||||
organization=self.organization, name="Поддержка"
|
||||
)
|
||||
self.owner = self._employee("owner@queue.test", EmployeeRole.OWNER)
|
||||
self.operator = self._employee("operator@queue.test", EmployeeRole.EMPLOYEE)
|
||||
EmployeeGroupMember.objects.create(
|
||||
organization=self.organization, group=self.support, employee=self.operator
|
||||
)
|
||||
self.context = tenant_context_for(self.owner.user, self.organization)
|
||||
self.policy = policy_for(self.organization)
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization, group=self.support, code="esc", name="Очередь"
|
||||
)
|
||||
self.conversation = self._waiting_conversation()
|
||||
# Обычный случай: смена на месте. Пустую смену проверяет
|
||||
# PresenceEscalationTests, и она её задаёт явно.
|
||||
cache.clear()
|
||||
touch(self.organization.id, self.operator.user_id)
|
||||
|
||||
def _employee(self, email: str, role: str) -> OrganizationMembership:
|
||||
user = HumanUser.objects.create_user(email=email, password="Password-123")
|
||||
return OrganizationMembership.objects.create(
|
||||
user=user, organization=self.organization, role=role, position_title="Specialist"
|
||||
)
|
||||
|
||||
def _waiting_conversation(self) -> Conversation:
|
||||
contact = Contact.objects.create(organization=self.organization, name="Клиент")
|
||||
return Conversation.objects.create(
|
||||
organization=self.organization,
|
||||
channel=self.channel,
|
||||
group=self.support,
|
||||
contact=contact,
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
control_mode=ControlMode.PAUSED,
|
||||
expected_responder=ExpectedResponder.OPERATOR,
|
||||
waiting_since=timezone.now(),
|
||||
)
|
||||
|
||||
def _wait_for(self, minutes: int) -> None:
|
||||
Conversation.objects.filter(pk=self.conversation.pk).update(
|
||||
waiting_since=timezone.now() - timedelta(minutes=minutes)
|
||||
)
|
||||
|
||||
def _dedup_keys(self) -> set[str]:
|
||||
return set(
|
||||
Notification.objects.filter(organization=self.organization).values_list(
|
||||
"dedup_key", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
class QueueEscalationTests(QueueTestBase):
|
||||
def test_fresh_dialog_is_not_escalated(self) -> None:
|
||||
self.assertEqual(sweep_waiting_conversations(self.context), 0)
|
||||
self.assertEqual(self._dedup_keys(), set())
|
||||
|
||||
def test_reminder_goes_to_the_group_of_the_dialog(self) -> None:
|
||||
self._wait_for(self.policy.remind_after_minutes + 1)
|
||||
self.assertEqual(sweep_waiting_conversations(self.context), 1)
|
||||
reminder = Notification.objects.get(dedup_key=f"waiting:{self.conversation.id}:remind")
|
||||
self.assertEqual(reminder.audience, NotificationAudience.OPERATORS)
|
||||
self.assertEqual(reminder.audience_group_id, self.support.id)
|
||||
|
||||
def test_circle_widens_beyond_the_group(self) -> None:
|
||||
self._wait_for(self.policy.widen_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
widened = Notification.objects.get(dedup_key=f"waiting:{self.conversation.id}:widen")
|
||||
self.assertIsNone(widened.audience_group_id)
|
||||
|
||||
def test_management_is_called_last(self) -> None:
|
||||
self._wait_for(self.policy.escalate_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
management = Notification.objects.filter(
|
||||
dedup_key__startswith=f"waiting:{self.conversation.id}:management"
|
||||
)
|
||||
self.assertEqual(
|
||||
{item.recipient_user_id for item in management}, {self.owner.user_id}
|
||||
)
|
||||
|
||||
def test_second_sweep_does_not_repeat_itself(self) -> None:
|
||||
self._wait_for(self.policy.escalate_after_minutes + 1)
|
||||
first = sweep_waiting_conversations(self.context)
|
||||
second = sweep_waiting_conversations(self.context)
|
||||
self.assertGreater(first, 0)
|
||||
self.assertEqual(second, 0)
|
||||
|
||||
def test_assigned_dialog_is_not_escalated_to_everyone(self) -> None:
|
||||
assign_operator(
|
||||
context=self.context,
|
||||
conversation_id=self.conversation.id,
|
||||
assignee=self.operator.user,
|
||||
)
|
||||
self._wait_for(self.policy.escalate_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
self.assertEqual(
|
||||
{key for key in self._dedup_keys() if key.startswith("waiting:")}, set()
|
||||
)
|
||||
|
||||
|
||||
class AssignmentTests(QueueTestBase):
|
||||
def test_assignment_notifies_the_assignee_and_writes_history(self) -> None:
|
||||
assign_operator(
|
||||
context=self.context,
|
||||
conversation_id=self.conversation.id,
|
||||
assignee=self.operator.user,
|
||||
)
|
||||
notification = Notification.objects.get(
|
||||
dedup_key=f"assign:{self.conversation.id}:{self.operator.user_id}"
|
||||
)
|
||||
self.assertEqual(notification.audience, NotificationAudience.USER)
|
||||
self.assertEqual(notification.recipient_user_id, self.operator.user_id)
|
||||
self.assertEqual(
|
||||
list(self.conversation.messages.values_list("system_event", flat=True)),
|
||||
[SystemEvent.ASSIGNED_TO],
|
||||
)
|
||||
|
||||
def test_assigning_to_yourself_does_not_ping_you(self) -> None:
|
||||
assign_operator(
|
||||
context=self.context,
|
||||
conversation_id=self.conversation.id,
|
||||
assignee=self.owner.user,
|
||||
)
|
||||
self.assertFalse(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"assign:{self.conversation.id}:{self.owner.user_id}"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_unclaimed_assignment_returns_the_dialog_to_the_queue(self) -> None:
|
||||
assign_operator(
|
||||
context=self.context,
|
||||
conversation_id=self.conversation.id,
|
||||
assignee=self.operator.user,
|
||||
)
|
||||
Conversation.objects.filter(pk=self.conversation.pk).update(
|
||||
assigned_at=timezone.now()
|
||||
- timedelta(minutes=self.policy.assignment_timeout_minutes + 1)
|
||||
)
|
||||
sweep_waiting_conversations(self.context)
|
||||
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertIsNone(self.conversation.assigned_operator_id)
|
||||
self.assertIsNone(self.conversation.assigned_at)
|
||||
# Ожидание не обнулилось: клиент ждёт с того же момента, что и ждал.
|
||||
self.assertIsNotNone(self.conversation.waiting_since)
|
||||
self.assertIn(
|
||||
SystemEvent.ASSIGNMENT_EXPIRED,
|
||||
set(self.conversation.messages.values_list("system_event", flat=True)),
|
||||
)
|
||||
|
||||
|
||||
class PresenceEscalationTests(QueueTestBase):
|
||||
"""Присутствие сокращает ожидание, но ничего не запрещает."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
# Начинаем с пустой смены: за рабочим местом нет никого.
|
||||
cache.clear()
|
||||
|
||||
def test_circle_widens_at_the_first_threshold_when_nobody_is_online(self) -> None:
|
||||
self._wait_for(self.policy.remind_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
self.assertTrue(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"waiting:{self.conversation.id}:widen"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_present_operator_keeps_the_second_threshold(self) -> None:
|
||||
touch(self.organization.id, self.operator.user_id)
|
||||
self._wait_for(self.policy.remind_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
# Напомнили группе, но круг не расширили: в группе есть кому ответить.
|
||||
self.assertTrue(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"waiting:{self.conversation.id}:remind"
|
||||
).exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"waiting:{self.conversation.id}:widen"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_presence_never_delays_the_wider_circle(self) -> None:
|
||||
touch(self.organization.id, self.operator.user_id)
|
||||
self._wait_for(self.policy.widen_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
self.assertTrue(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"waiting:{self.conversation.id}:widen"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_operator_of_another_group_does_not_count_as_present(self) -> None:
|
||||
outsider = self._employee("outsider@queue.test", EmployeeRole.EMPLOYEE)
|
||||
touch(self.organization.id, outsider.user_id)
|
||||
self._wait_for(self.policy.remind_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
self.assertTrue(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"waiting:{self.conversation.id}:widen"
|
||||
).exists()
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Очередь к оператору: с какого момента идёт ожидание и в каком порядке разбирают.
|
||||
|
||||
Регрессия, ради которой заведён waiting_since: «дольше всех ждущий» считался по
|
||||
времени последнего сообщения, поэтому клиент, напомнивший о себе, уезжал в конец
|
||||
очереди. Чем настойчивее человек, тем позже до него доходили руки.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.models import AIAgent
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
from chatballs.conversations.models import ControlMode, Conversation
|
||||
from chatballs.conversations.selectors import order_conversations
|
||||
from chatballs.conversations.services import (
|
||||
claim_conversation,
|
||||
close_conversation,
|
||||
return_to_queue,
|
||||
)
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
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 ai_answer, tenant_context_for
|
||||
|
||||
|
||||
class QueueTestBase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.owner = HumanUser.objects.get(email="owner@example.com")
|
||||
self.context = tenant_context_for(self.owner, self.organization)
|
||||
# Канал без активного агента: каждый входящий сразу создаёт очередь.
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization, code="queue-order", name="Очередь"
|
||||
)
|
||||
self.integration = Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
name="queue-bot",
|
||||
channel=self.channel,
|
||||
)
|
||||
|
||||
def _ingest(self, external_id: str, chat_id: str, text: str) -> None:
|
||||
inbound = InboundMessage(
|
||||
external_id=external_id,
|
||||
user_id=chat_id,
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
display_name=chat_id,
|
||||
)
|
||||
with mock.patch("chatballs.conversations.transports.send_reply"):
|
||||
ingest_inbound(self.integration, inbound)
|
||||
|
||||
def _waiting_order(self) -> list[int]:
|
||||
ordered, _ = order_conversations(
|
||||
Conversation.objects.filter(organization=self.organization), "waiting"
|
||||
)
|
||||
return list(ordered.values_list("id", flat=True))
|
||||
|
||||
|
||||
|
||||
class QueueOrderTests(QueueTestBase):
|
||||
def test_new_dialog_without_agent_starts_waiting(self) -> None:
|
||||
self._ingest("m-1", "chat-1", "Здравствуйте")
|
||||
conversation = self.channel.conversations.get()
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
|
||||
self.assertIsNotNone(conversation.waiting_since)
|
||||
|
||||
def test_reminder_from_customer_does_not_restart_the_wait(self) -> None:
|
||||
self._ingest("m-1", "chat-1", "Здравствуйте")
|
||||
conversation = self.channel.conversations.get()
|
||||
started = conversation.waiting_since
|
||||
|
||||
self._ingest("m-2", "chat-1", "Ну что там?")
|
||||
|
||||
conversation.refresh_from_db()
|
||||
self.assertEqual(conversation.waiting_since, started)
|
||||
# Свежесть диалога при этом обновилась — ожидание и активность разные вещи.
|
||||
self.assertGreater(conversation.last_message_at, started)
|
||||
|
||||
def test_longest_waiting_goes_first_even_after_a_reminder(self) -> None:
|
||||
self._ingest("a-1", "chat-a", "Первый вопрос")
|
||||
first = Conversation.objects.get(external_chat_id="chat-a")
|
||||
# Первый диалог ждёт заметно дольше второго.
|
||||
Conversation.objects.filter(pk=first.pk).update(
|
||||
waiting_since=timezone.now() - timedelta(hours=2)
|
||||
)
|
||||
self._ingest("b-1", "chat-b", "Второй вопрос")
|
||||
second = Conversation.objects.get(external_chat_id="chat-b")
|
||||
|
||||
# …и именно он напоминает о себе, двигая своё последнее сообщение вперёд.
|
||||
self._ingest("a-2", "chat-a", "Всё ещё жду")
|
||||
|
||||
self.assertEqual(self._waiting_order(), [first.id, second.id])
|
||||
|
||||
def test_claim_ends_the_wait_and_return_to_queue_starts_a_new_one(self) -> None:
|
||||
self._ingest("m-1", "chat-1", "Здравствуйте")
|
||||
conversation = self.channel.conversations.get()
|
||||
first_wait = conversation.waiting_since
|
||||
|
||||
claimed = claim_conversation(context=self.context, conversation_id=conversation.id)
|
||||
self.assertEqual(claimed.control_mode, ControlMode.HUMAN)
|
||||
self.assertIsNone(claimed.waiting_since)
|
||||
|
||||
returned = return_to_queue(context=self.context, conversation_id=conversation.id)
|
||||
self.assertEqual(returned.control_mode, ControlMode.PAUSED)
|
||||
self.assertIsNotNone(returned.waiting_since)
|
||||
# Ожидание началось заново: диалог успел побывать у оператора.
|
||||
self.assertGreater(returned.waiting_since, first_wait)
|
||||
|
||||
def test_closed_dialog_leaves_the_queue(self) -> None:
|
||||
self._ingest("z-1", "chat-z", "Вопрос")
|
||||
conversation = self.channel.conversations.get()
|
||||
self.assertIsNotNone(conversation.waiting_since)
|
||||
|
||||
closed = close_conversation(context=self.context, conversation_id=conversation.id)
|
||||
self.assertIsNone(closed.waiting_since)
|
||||
|
||||
|
||||
class NewDialogNotificationTests(QueueTestBase):
|
||||
"""«Новый диалог» и «клиент запросил оператора» — разные события.
|
||||
|
||||
Диалог начинается и на канале с работающим агентом, где человека никто не
|
||||
звал. Назвать такой оклик просьбой о человеке — соврать тому, кто на него
|
||||
подписан.
|
||||
"""
|
||||
|
||||
def _last_type(self) -> str:
|
||||
return (
|
||||
Notification.objects.filter(organization=self.organization)
|
||||
.order_by("-id")
|
||||
.values_list("type", flat=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
def test_dialog_without_an_agent_asks_for_a_person(self) -> None:
|
||||
self._ingest("n-1", "chat-n", "Здравствуйте")
|
||||
self.assertEqual(self._last_type(), NotificationType.OPERATOR_REQUESTED)
|
||||
|
||||
def test_dialog_handled_by_the_agent_is_just_a_new_dialog(self) -> None:
|
||||
agent = AIAgent.objects.create(
|
||||
organization=self.organization,
|
||||
channel=self.channel,
|
||||
name="Консультант",
|
||||
is_active=True,
|
||||
)
|
||||
self.assertTrue(agent.is_active)
|
||||
with ai_answer("Здравствуйте!"), mock.patch(
|
||||
"chatballs.conversations.transports.send_reply"
|
||||
):
|
||||
ingest_inbound(
|
||||
self.integration,
|
||||
InboundMessage(
|
||||
external_id="a-1",
|
||||
user_id="chat-a",
|
||||
chat_id="chat-a",
|
||||
text="Здравствуйте",
|
||||
display_name="chat-a",
|
||||
),
|
||||
)
|
||||
self.assertEqual(self._last_type(), NotificationType.NEW_DIALOG)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Сроки очереди через API (макет Q2).
|
||||
|
||||
До этого пороги правились только в служебной админке — то есть де-факто никем.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.conversations.queue_models import QueueEscalationPolicy
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
URL = "/api/v1/conversations/queue-policy/"
|
||||
|
||||
|
||||
class QueuePolicyApiTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.organization = Organization.objects.create(name="Example", slug="queue-policy")
|
||||
self.owner = self._employee("owner@policy.test", EmployeeRole.OWNER)
|
||||
self.operator = self._employee("operator@policy.test", EmployeeRole.EMPLOYEE)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.owner.user)
|
||||
|
||||
def _employee(self, email: str, role: str) -> OrganizationMembership:
|
||||
user = HumanUser.objects.create_user(email=email, password="Password-123")
|
||||
return OrganizationMembership.objects.create(
|
||||
user=user, organization=self.organization, role=role, position_title="Specialist"
|
||||
)
|
||||
|
||||
def test_first_read_returns_usual_delays(self) -> None:
|
||||
response = self.client.get(URL)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["remind_after_minutes"], 5)
|
||||
self.assertEqual(payload["assignment_timeout_minutes"], 10)
|
||||
self.assertEqual(payload["defaults"], payload["defaults"] | {"widen_after_minutes": 15})
|
||||
self.assertIsNone(payload["updatedAt"])
|
||||
|
||||
def test_saving_remembers_who_and_when(self) -> None:
|
||||
response = self.client.patch(URL, {"remind_after_minutes": 3}, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["remind_after_minutes"], 3)
|
||||
self.assertEqual(payload["updatedBy"], self.owner.user.full_name or self.owner.user.email)
|
||||
self.assertIsNotNone(payload["updatedAt"])
|
||||
self.assertEqual(QueueEscalationPolicy.objects.get().remind_after_minutes, 3)
|
||||
|
||||
def test_nonsense_values_are_refused(self) -> None:
|
||||
for value in (0, -5, 10000, "быстро", True):
|
||||
with self.subTest(value=value):
|
||||
response = self.client.patch(URL, {"widen_after_minutes": value}, format="json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(QueueEscalationPolicy.objects.get().widen_after_minutes, 15)
|
||||
|
||||
def test_operator_reads_but_does_not_change(self) -> None:
|
||||
self.client.force_authenticate(self.operator.user)
|
||||
self.assertEqual(self.client.get(URL).status_code, 403)
|
||||
self.assertEqual(
|
||||
self.client.patch(URL, {"remind_after_minutes": 1}, format="json").status_code, 403
|
||||
)
|
||||
@@ -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"], "Схема")
|
||||
@@ -9,6 +9,7 @@ from django.test import TestCase
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations import ai_turn
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
from chatballs.conversations.models import ControlMode, MessageAuthor, MessageKind, TranscriptStatus
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
@@ -17,6 +18,7 @@ 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.testing import ai_answer, run_pending_ai_turns
|
||||
|
||||
|
||||
class VoiceAiReplyTests(TestCase):
|
||||
@@ -30,48 +32,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:
|
||||
|
||||
@@ -3,9 +3,7 @@ from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.ai.limits import LimitExceeded
|
||||
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,
|
||||
@@ -30,6 +28,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 TenantAPIClient as APIClient
|
||||
from chatballs.testing import ai_answer, ai_failure, run_pending_ai_turns
|
||||
|
||||
|
||||
def _messenger_connection(channel):
|
||||
@@ -42,10 +41,9 @@ def _messenger_connection(channel):
|
||||
)
|
||||
|
||||
|
||||
class IngestLimitHandlingTests(TestCase):
|
||||
"""При срабатывании дневного лимита стоимости (LimitExceeded) диалог не должен
|
||||
«зависать»: его передают оператору с fallback-ответом клиенту (как при сбое
|
||||
провайдера). См. ingest.ingest_inbound.
|
||||
class IngestProviderFailureTests(TestCase):
|
||||
"""При сбое провайдера диалог не должен «зависать»: его передают оператору с
|
||||
fallback-ответом клиенту. См. ingest.ingest_inbound.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
@@ -63,17 +61,15 @@ class IngestLimitHandlingTests(TestCase):
|
||||
external_id="ext-1", user_id="user-1", chat_id="chat-1", text="Здравствуйте", display_name="Гость"
|
||||
)
|
||||
|
||||
def test_limit_exceeded_hands_off_to_operator(self) -> None:
|
||||
def test_provider_failure_hands_off_to_operator(self) -> None:
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"chatballs.conversations.ingest.run_channel_turn",
|
||||
side_effect=LimitExceeded("Channel daily AI cost limit reached"),
|
||||
),
|
||||
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()
|
||||
# Диалог передан оператору, ответчик — оператор.
|
||||
@@ -170,6 +166,123 @@ class TransportNormalizeTests(TestCase):
|
||||
self.assertEqual(inbound.phone, "")
|
||||
self.assertEqual(inbound.text, "Здравствуйте")
|
||||
|
||||
def test_max_voice_attachment_becomes_a_voice_message(self) -> None:
|
||||
update = {
|
||||
"update_type": "message_created",
|
||||
"message": {
|
||||
"sender": {"user_id": 42, "name": "Мария"},
|
||||
"recipient": {"chat_id": 100},
|
||||
"body": {
|
||||
"mid": "m-3",
|
||||
"text": "",
|
||||
"attachments": [
|
||||
{
|
||||
"type": "audio",
|
||||
"duration": 7,
|
||||
"payload": {"url": "https://cdn.example.test/voice.ogg"},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
inbound = max_transport._normalize(update)
|
||||
self.assertIsNotNone(inbound)
|
||||
self.assertEqual(inbound.voice_url, "https://cdn.example.test/voice.ogg")
|
||||
self.assertEqual(inbound.voice_duration, 7)
|
||||
self.assertFalse(inbound.voice_unavailable)
|
||||
|
||||
def test_max_voice_without_url_still_reaches_the_operator(self) -> None:
|
||||
# Форма вложения у MAX описана неполно. Незнакомая — не повод молча
|
||||
# терять реплику клиента: оператор увидит её заглушкой.
|
||||
update = {
|
||||
"update_type": "message_created",
|
||||
"message": {
|
||||
"sender": {"user_id": 42, "name": "Мария"},
|
||||
"recipient": {"chat_id": 100},
|
||||
"body": {
|
||||
"mid": "m-4",
|
||||
"text": "",
|
||||
"attachments": [{"type": "audio", "payload": {"token": "abc"}}],
|
||||
},
|
||||
},
|
||||
}
|
||||
inbound = max_transport._normalize(update)
|
||||
self.assertIsNotNone(inbound)
|
||||
self.assertTrue(inbound.voice_unavailable)
|
||||
self.assertEqual(inbound.voice_url, "")
|
||||
|
||||
def test_max_event_without_body_pulls_the_message_from_the_chat(self) -> None:
|
||||
# Боевой случай: на голосовое MAX присылает message_created вообще без
|
||||
# message. Содержимое достаём отдельным запросом, иначе реплика клиента
|
||||
# не доедет до оператора.
|
||||
integration = mock.Mock(secret="token", id=7, config={})
|
||||
updates = {
|
||||
"updates": [
|
||||
{"timestamp": 1789442982299, "user_locale": "ru", "update_type": "message_created"}
|
||||
],
|
||||
"marker": 42,
|
||||
}
|
||||
chats = {"chats": [{"chat_id": 100, "last_event_time": 1789442982299}]}
|
||||
messages = {
|
||||
"messages": [
|
||||
{
|
||||
"sender": {"user_id": 42, "name": "Мария"},
|
||||
"recipient": {"chat_id": 100},
|
||||
"body": {
|
||||
"mid": "m-9",
|
||||
"text": "",
|
||||
"attachments": [
|
||||
{
|
||||
"type": "audio",
|
||||
"duration": 5,
|
||||
"payload": {"url": "https://cdn.example.test/v.ogg"},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def fake_request(url, **kwargs):
|
||||
if "/updates" in url:
|
||||
return updates
|
||||
if "/chats" in url:
|
||||
return chats
|
||||
return messages
|
||||
|
||||
with mock.patch(
|
||||
"chatballs.conversations.transports.max.request_json", side_effect=fake_request
|
||||
):
|
||||
found, marker = max_transport.poll_updates(integration)
|
||||
|
||||
self.assertEqual(marker, "42")
|
||||
self.assertEqual(len(found), 1)
|
||||
self.assertEqual(found[0].external_id, "m-9")
|
||||
self.assertEqual(found[0].voice_url, "https://cdn.example.test/v.ogg")
|
||||
|
||||
def test_max_does_not_touch_chats_when_the_event_has_a_body(self) -> None:
|
||||
integration = mock.Mock(secret="token", id=7, config={})
|
||||
updates = {
|
||||
"updates": [
|
||||
{
|
||||
"update_type": "message_created",
|
||||
"timestamp": 1,
|
||||
"message": {
|
||||
"sender": {"user_id": 42, "name": "Мария"},
|
||||
"recipient": {"chat_id": 100},
|
||||
"body": {"mid": "m-1", "text": "Здравствуйте"},
|
||||
},
|
||||
}
|
||||
],
|
||||
"marker": 7,
|
||||
}
|
||||
with mock.patch(
|
||||
"chatballs.conversations.transports.max.request_json", return_value=updates
|
||||
) as request:
|
||||
found, _marker = max_transport.poll_updates(integration)
|
||||
self.assertEqual(len(found), 1)
|
||||
self.assertEqual(request.call_count, 1)
|
||||
|
||||
|
||||
class ContactShareIngestTests(TestCase):
|
||||
"""Шаринг контакта: телефон сохраняется в Contact, AI-ход не запускается,
|
||||
@@ -194,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")
|
||||
@@ -208,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")
|
||||
@@ -410,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)
|
||||
@@ -423,7 +538,7 @@ class WebchatContactTests(TestCase):
|
||||
)
|
||||
self.assertTrue(
|
||||
Notification.objects.filter(
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
target_id=str(conversation.id),
|
||||
).exists()
|
||||
@@ -431,7 +546,7 @@ class WebchatContactTests(TestCase):
|
||||
self.assertEqual(
|
||||
set(
|
||||
Notification.objects.filter(
|
||||
type=NotificationType.INTEGRATION_ERROR,
|
||||
type=NotificationType.AI_STOPPED,
|
||||
audience=NotificationAudience.USER,
|
||||
target_id=str(conversation.id),
|
||||
).values_list("recipient_user__email", flat=True)
|
||||
@@ -440,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"])
|
||||
@@ -1,12 +1,17 @@
|
||||
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
|
||||
|
||||
_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:
|
||||
@@ -17,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,
|
||||
}
|
||||
@@ -26,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,
|
||||
@@ -35,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,
|
||||
}
|
||||
@@ -44,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,
|
||||
}
|
||||
|
||||
# Провайдеры-мессенджеры, у которых есть транспорт приёма/отправки.
|
||||
@@ -51,7 +62,22 @@ SUPPORTED_PROVIDERS = tuple(_POLL.keys())
|
||||
|
||||
|
||||
def poll(integration):
|
||||
return _POLL[integration.provider](integration)
|
||||
"""Опрос подключения с паузой после сбоя (transports.backoff).
|
||||
|
||||
Сбой транспорта не роняет цикл и не пишется в журнал на каждой попытке:
|
||||
подключение пропускается с растущей паузой, а журнал видит только смену
|
||||
состояния. Курсор при сбое не двигается.
|
||||
"""
|
||||
|
||||
if backoff.should_skip(integration.id):
|
||||
return [], integration.poll_marker
|
||||
try:
|
||||
result = _POLL[integration.provider](integration)
|
||||
except PollFailed as error:
|
||||
backoff.record_failure(integration, error)
|
||||
return [], integration.poll_marker
|
||||
backoff.record_success(integration)
|
||||
return result
|
||||
|
||||
|
||||
def send_reply(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
@@ -102,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,
|
||||
}
|
||||
@@ -115,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"))
|
||||
|
||||
|
||||
@@ -137,6 +166,39 @@ def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filena
|
||||
)
|
||||
|
||||
|
||||
# Фото контакта (chatballs.conversations.contact_avatars): MAX отдаёт адрес в
|
||||
# профиле отправителя, Telegram — только по отдельному запросу к API.
|
||||
|
||||
|
||||
def avatar_source(integration, inbound) -> str:
|
||||
"""Чем определяется «то же самое фото» у этого провайдера.
|
||||
|
||||
Пока значение не изменилось, фото не перекачивается. У MAX это адрес из
|
||||
апдейта, у Telegram — сам отправитель: апдейт про фото ничего не говорит,
|
||||
и спрашивать API на каждое сообщение было бы расточительством.
|
||||
"""
|
||||
if integration.provider == IntegrationProvider.TELEGRAM:
|
||||
return f"tg:{inbound.user_id}" if inbound.user_id else ""
|
||||
return inbound.avatar_url
|
||||
|
||||
|
||||
def download_avatar(integration, inbound) -> tuple[bytes, str] | None:
|
||||
"""Фото отправителя: (байты, ключ источника). None — фото у провайдера нет."""
|
||||
source = avatar_source(integration, inbound)
|
||||
if not source:
|
||||
return None
|
||||
if integration.provider == IntegrationProvider.TELEGRAM:
|
||||
content = _telegram.download_profile_photo(integration, inbound.user_id)
|
||||
return (content, source) if content else 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
|
||||
|
||||
|
||||
def download_voice(integration, inbound) -> tuple[bytes, str]:
|
||||
if inbound.voice_content:
|
||||
return inbound.voice_content, inbound.voice_mime or "audio/webm"
|
||||
@@ -144,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"))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Пауза между попытками опроса подключения после сбоя.
|
||||
|
||||
Воркер опрашивает мессенджеры каждые три секунды. Подключение с отозванным
|
||||
токеном или недоступным сервером отвечало ошибкой на каждый цикл и писало
|
||||
её в журнал двадцать раз в минуту — журнал переставал быть читаемым, а
|
||||
чужой сервер получал бессмысленный поток запросов. Теперь после сбоя
|
||||
подключение пропускается с растущей паузой, а в журнал попадают только
|
||||
изменения состояния: первый сбой, выход на максимальную паузу и
|
||||
восстановление.
|
||||
|
||||
Состояние живёт в памяти процесса: воркер один, а после перезапуска первая
|
||||
попытка всё равно нужна.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Первая пауза — два цикла опроса, дальше удвоение до минуты. Потолок был
|
||||
# четвертью часа, пока опрос и ответы AI жили в одном процессе: длинная пауза
|
||||
# берегла общий цикл. Теперь опрос ничего не ждёт, а четверть часа тишины после
|
||||
# одного сетевого сбоя клиент видит как «бот молчит».
|
||||
FIRST_DELAY_SECONDS = 6.0
|
||||
MAX_DELAY_SECONDS = 60.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Failure:
|
||||
failures: int
|
||||
next_attempt_at: float
|
||||
delay: float
|
||||
|
||||
|
||||
_failures: dict[int, _Failure] = {}
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def should_skip(integration_id: int) -> bool:
|
||||
state = _failures.get(integration_id)
|
||||
return state is not None and _now() < state.next_attempt_at
|
||||
|
||||
|
||||
def record_failure(integration, error: object) -> None:
|
||||
previous = _failures.get(integration.id)
|
||||
failures = (previous.failures if previous else 0) + 1
|
||||
delay = min(FIRST_DELAY_SECONDS * 2 ** (failures - 1), MAX_DELAY_SECONDS)
|
||||
_failures[integration.id] = _Failure(failures=failures, next_attempt_at=_now() + delay, delay=delay)
|
||||
if failures == 1:
|
||||
logger.warning(
|
||||
"%s poll failed for integration %s: %s (next attempt in %.0fs)",
|
||||
integration.provider, integration.id, error, delay,
|
||||
)
|
||||
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 %.0fs)",
|
||||
integration.provider, integration.id, error, MAX_DELAY_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def record_success(integration) -> None:
|
||||
state = _failures.pop(integration.id, None)
|
||||
if state is not None:
|
||||
logger.info(
|
||||
"%s poll recovered for integration %s after %s failure(s)",
|
||||
integration.provider, integration.id, state.failures,
|
||||
)
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Для тестов: забыть все сбои."""
|
||||
|
||||
_failures.clear()
|
||||
@@ -60,6 +60,10 @@ class InboundMessage:
|
||||
voice_content: bytes = b""
|
||||
voice_duration: int = 0
|
||||
voice_mime: str = ""
|
||||
# Голосовое в сообщении есть, но источника для скачивания провайдер не дал
|
||||
# (незнакомая форма вложения). Сообщение всё равно доезжает до оператора
|
||||
# заглушкой: молча терять реплику клиента нельзя.
|
||||
voice_unavailable: bool = False
|
||||
# Файлы и фото: каждый становится отдельным сообщением kind=file.
|
||||
files: tuple[InboundFile, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from chatballs.conversations.transports.base import (
|
||||
guess_content_type,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -135,8 +136,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
client = _imap_connect(integration)
|
||||
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
|
||||
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
try:
|
||||
client.select("INBOX", readonly=True)
|
||||
validity = _status_value(client, "UIDVALIDITY")
|
||||
@@ -162,8 +162,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
new_marker = f"{validity}:{uids[-1]}" if uids else integration.poll_marker
|
||||
return messages, new_marker
|
||||
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
|
||||
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class PollFailed(Exception):
|
||||
"""Опрос подключения не удался: сеть, авторизация, ответ не разобран.
|
||||
|
||||
Транспорт не пишет об этом в журнал сам — решение, когда повторить и что
|
||||
записать, принимает слой пауз между попытками (``transports.backoff``).
|
||||
"""
|
||||
@@ -27,6 +27,7 @@ from chatballs.conversations.transports.base import (
|
||||
request_json_multipart,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.integrations.checks import DEFAULT_MAX_BASE_URL
|
||||
from chatballs.integrations.outbound import host_of
|
||||
@@ -93,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)
|
||||
@@ -108,9 +112,10 @@ def _normalize(update: dict) -> InboundMessage | None:
|
||||
chat_id = first(recipient, "chat_id", "chatId")
|
||||
external_id = first(inner, "mid", "msgId", "seq") or first(update, "update_id", "updateId", "timestamp")
|
||||
phone = _contact_phone(inner, msg)
|
||||
voice_url, voice_duration = _voice_attachment(inner)
|
||||
voice_url, voice_duration, voice_unavailable = _voice_attachment(inner)
|
||||
files = _file_attachments(inner)
|
||||
if (not text and not phone and not voice_url and not files) or user_id is None or external_id is None:
|
||||
has_voice = bool(voice_url) or voice_unavailable
|
||||
if (not text and not phone and not has_voice and not files) or user_id is None or external_id is None:
|
||||
return None
|
||||
return InboundMessage(
|
||||
external_id=str(external_id),
|
||||
@@ -124,6 +129,7 @@ def _normalize(update: dict) -> InboundMessage | None:
|
||||
voice_url=voice_url,
|
||||
voice_duration=voice_duration,
|
||||
voice_mime="audio/ogg" if voice_url else "",
|
||||
voice_unavailable=voice_unavailable,
|
||||
files=files,
|
||||
)
|
||||
|
||||
@@ -220,15 +226,91 @@ def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filena
|
||||
return False
|
||||
|
||||
|
||||
def _voice_attachment(inner: dict) -> tuple[str, int]:
|
||||
"""Голосовое/аудио-вложение MAX: payload.url для скачивания."""
|
||||
def _voice_attachment(inner: dict) -> tuple[str, int, bool]:
|
||||
"""Голосовое/аудио-вложение MAX: адрес для скачивания и длительность.
|
||||
|
||||
Третье значение — «вложение было, а адреса в нём нет». Форма вложения у MAX
|
||||
описана неполно, и раньше такое сообщение уходило в никуда: текста в нём
|
||||
нет, адреса нет — normalize возвращал None, и реплика клиента просто
|
||||
пропадала. Теперь оператор видит её заглушкой, а в журнале остаётся сам
|
||||
payload, по которому разбирают форму.
|
||||
"""
|
||||
for attachment in inner.get("attachments") or []:
|
||||
if attachment.get("type") in ("audio", "voice"):
|
||||
payload = attachment.get("payload") or {}
|
||||
url = str(first(payload, "url", "download_url", default=""))
|
||||
if url:
|
||||
return url, int(first(attachment, "duration", default=0) or payload.get("duration") or 0)
|
||||
return "", 0
|
||||
if attachment.get("type") not in ("audio", "voice"):
|
||||
continue
|
||||
payload = attachment.get("payload") or {}
|
||||
duration = int(first(attachment, "duration", default=0) or payload.get("duration") or 0)
|
||||
url = str(first(payload, "url", "download_url", default=""))
|
||||
if url:
|
||||
return url, duration, False
|
||||
logger.warning(
|
||||
"MAX voice attachment without a download url: %s",
|
||||
json.dumps(attachment, ensure_ascii=False),
|
||||
)
|
||||
return "", duration, True
|
||||
return "", 0, False
|
||||
|
||||
|
||||
# Насколько раньше события смотреть чаты, когда MAX прислал событие без тела.
|
||||
BODYLESS_LOOKBACK_MS = 15_000
|
||||
# Сколько чатов и сообщений забирать в этом случае: событие одно, чат почти
|
||||
# всегда один, а перебирать весь список чатов на каждое такое событие нельзя.
|
||||
BODYLESS_CHATS_LIMIT = 5
|
||||
BODYLESS_MESSAGES_PER_CHAT = 3
|
||||
|
||||
|
||||
def _is_bodyless(update: dict) -> bool:
|
||||
"""Событие «пришло сообщение», в котором самого сообщения нет.
|
||||
|
||||
MAX присылает такое, например, на голосовые: update_type есть, timestamp
|
||||
есть, а `message` отсутствует целиком. Разбирать в нём нечего, и раньше
|
||||
реплика клиента на этом заканчивалась.
|
||||
"""
|
||||
if (update.get("update_type") or update.get("updateType")) != "message_created":
|
||||
return False
|
||||
return not (update.get("message") or update.get("payload"))
|
||||
|
||||
|
||||
def _api(integration, path: str) -> dict:
|
||||
return request_json(
|
||||
f"{_base(integration)}{path}",
|
||||
headers={"Authorization": integration.secret, "Content-Type": "application/json"},
|
||||
proxy_url=_proxy(integration),
|
||||
)
|
||||
|
||||
|
||||
def _messages_since(integration, timestamp: int) -> list[InboundMessage]:
|
||||
"""Свежие сообщения из чатов бота — ответ на событие без тела.
|
||||
|
||||
Забираются последние реплики тех чатов, где что-то происходило рядом с
|
||||
событием, и прогоняются через тот же разбор. Повторы безвредны: входящие
|
||||
отсеиваются по идентификатору сообщения (conversations.ingest).
|
||||
"""
|
||||
chats = (_api(integration, f"/chats?count={BODYLESS_CHATS_LIMIT * 4}") or {}).get("chats") or []
|
||||
recent = [
|
||||
chat
|
||||
for chat in chats
|
||||
if int(first(chat, "last_event_time", "lastEventTime", default=0) or 0)
|
||||
>= timestamp - BODYLESS_LOOKBACK_MS
|
||||
]
|
||||
recent.sort(
|
||||
key=lambda chat: int(first(chat, "last_event_time", "lastEventTime", default=0) or 0),
|
||||
reverse=True,
|
||||
)
|
||||
found: list[InboundMessage] = []
|
||||
for chat in recent[:BODYLESS_CHATS_LIMIT]:
|
||||
chat_id = first(chat, "chat_id", "chatId")
|
||||
if chat_id is None:
|
||||
continue
|
||||
payload = _api(
|
||||
integration,
|
||||
f"/messages?chat_id={chat_id}&count={BODYLESS_MESSAGES_PER_CHAT}",
|
||||
)
|
||||
for message in payload.get("messages") or []:
|
||||
inbound = _normalize({"update_type": "message_created", "message": message})
|
||||
if inbound is not None:
|
||||
found.append(inbound)
|
||||
return found
|
||||
|
||||
|
||||
def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
@@ -242,10 +324,18 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
data = request_json(url, headers={"Authorization": token, "Content-Type": "application/json"}, proxy_url=_proxy(integration))
|
||||
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
|
||||
logger.warning("MAX poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
updates = data.get("updates") or []
|
||||
messages = [m for m in (_normalize(u) for u in updates) if m is not None]
|
||||
# Событие без тела: содержимое забираем отдельным запросом, иначе сообщение
|
||||
# клиента (в частности, голосовое) до оператора не доедет вовсе.
|
||||
bodyless = [u for u in updates if _is_bodyless(u)]
|
||||
if bodyless:
|
||||
timestamp = min(int(u.get("timestamp") or 0) for u in bodyless)
|
||||
try:
|
||||
messages.extend(_messages_since(integration, timestamp))
|
||||
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
|
||||
logger.warning("MAX chat history fetch failed for integration %s: %s", integration.id, error)
|
||||
new_marker = data.get("marker")
|
||||
return messages, ("" if new_marker is None else str(new_marker))
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from chatballs.conversations.transports.base import (
|
||||
request_json_multipart,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.integrations.checks import DEFAULT_TELEGRAM_BASE_URL
|
||||
from chatballs.integrations.outbound import host_of
|
||||
@@ -124,8 +125,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
data = request_json(url, proxy_url=_proxy(integration))
|
||||
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
|
||||
logger.warning("Telegram poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
if not data.get("ok"):
|
||||
return [], integration.poll_marker
|
||||
updates = data.get("result") or []
|
||||
@@ -180,6 +180,33 @@ def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url:
|
||||
return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "reply_markup": keyboard})
|
||||
|
||||
|
||||
def download_profile_photo(integration, user_id: str) -> bytes | None:
|
||||
"""Фото профиля отправителя: getUserProfilePhotos -> file_id -> getFile.
|
||||
|
||||
Telegram не кладёт фото в апдейт, поэтому его спрашивают отдельно — один
|
||||
раз на контакт (chatballs.conversations.contact_avatars). None — фото у
|
||||
человека нет или оно закрыто настройками приватности.
|
||||
"""
|
||||
token = integration.secret
|
||||
if not token or not user_id:
|
||||
return None
|
||||
data = request_json(
|
||||
f"{_base(integration)}/bot{token}/getUserProfilePhotos?user_id={user_id}&limit=1",
|
||||
proxy_url=_proxy(integration),
|
||||
)
|
||||
photos = ((data.get("result") or {}).get("photos") or []) if data.get("ok") else []
|
||||
if not photos or not photos[0]:
|
||||
return None
|
||||
# Размеры идут от меньшего к большему; аватар рисуется 40-64px, поэтому
|
||||
# средний размер лучше самого большого — он в разы легче.
|
||||
sizes = [size for size in photos[0] if size.get("file_id")]
|
||||
if not sizes:
|
||||
return None
|
||||
chosen = sizes[min(1, len(sizes) - 1)]
|
||||
content, _content_type = download_file(integration, str(chosen["file_id"]))
|
||||
return content
|
||||
|
||||
|
||||
def download_file(integration, file_id: str) -> tuple[bytes, str]:
|
||||
"""Скачивание файла по file_id: getFile -> file_path -> /file/bot<token>/<path>."""
|
||||
token = integration.secret
|
||||
|
||||
@@ -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)
|
||||
@@ -3,6 +3,7 @@ from django.urls import path
|
||||
from chatballs.conversations import (
|
||||
attachment_views,
|
||||
chat_extras_views,
|
||||
queue_policy_views,
|
||||
reporting_views,
|
||||
views,
|
||||
voice_views,
|
||||
@@ -13,6 +14,7 @@ urlpatterns = [
|
||||
path("stats/", reporting_views.ConversationStatsView.as_view(), name="conversation-stats"),
|
||||
path("clients/", reporting_views.ClientsView.as_view(), name="conversation-clients"),
|
||||
path("clients/<int:contact_id>/", reporting_views.ClientDetailView.as_view(), name="conversation-client-detail"),
|
||||
path("clients/<int:contact_id>/avatar/", reporting_views.ContactAvatarView.as_view(), name="conversation-client-avatar"),
|
||||
path("clients/<int:contact_id>/merge/", reporting_views.ClientMergeView.as_view(), name="conversation-client-merge"),
|
||||
path("<int:conversation_id>/", views.ConversationDetailView.as_view(), name="conversation-detail"),
|
||||
path("<int:conversation_id>/claim/", views.ConversationClaimView.as_view(), name="conversation-claim"),
|
||||
@@ -30,6 +32,7 @@ urlpatterns = [
|
||||
path("<int:conversation_id>/labels/", chat_extras_views.ConversationLabelsView.as_view(), name="conversation-labels"),
|
||||
path("<int:conversation_id>/archive/", chat_extras_views.ConversationArchiveView.as_view(), name="conversation-archive"),
|
||||
path("counters/", chat_extras_views.ConversationCountersView.as_view(), name="conversation-counters"),
|
||||
path("queue-policy/", queue_policy_views.QueuePolicyView.as_view(), name="conversation-queue-policy"),
|
||||
path("directory/", chat_extras_views.ConversationDirectoryView.as_view(), name="conversation-directory"),
|
||||
path("labels/", chat_extras_views.LabelListView.as_view(), name="conversation-label-list"),
|
||||
path("labels/<int:label_id>/", chat_extras_views.LabelDetailView.as_view(), name="conversation-label-detail"),
|
||||
|
||||
@@ -30,8 +30,10 @@ from chatballs.conversations.serializers import (
|
||||
)
|
||||
from chatballs.conversations.services import (
|
||||
ClaimError,
|
||||
assign_operator,
|
||||
claim_conversation,
|
||||
close_conversation,
|
||||
delete_conversation,
|
||||
mark_conversation_as_spam,
|
||||
post_operator_message,
|
||||
release_to_ai,
|
||||
@@ -87,6 +89,21 @@ class ConversationListView(ConversationViewBase):
|
||||
items = items.filter(
|
||||
lifecycle=LifecycleState.OPEN, control_mode=ControlMode.PAUSED
|
||||
)
|
||||
# Две вкладки ожидания (макет «Очередь и уведомления», кадр Q3): общая
|
||||
# очередь — диалоги без ответственного, их берёт кто угодно; «на мне» —
|
||||
# назначенные лично и ждущие, пока их возьмут.
|
||||
if params.get("queue") == "1":
|
||||
items = items.filter(
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
control_mode=ControlMode.PAUSED,
|
||||
assigned_operator__isnull=True,
|
||||
)
|
||||
if params.get("waitingOnMe") == "1":
|
||||
items = items.filter(
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
control_mode=ControlMode.PAUSED,
|
||||
assigned_operator_id=request.user.id,
|
||||
)
|
||||
query = params.get("q", "").strip()
|
||||
if query:
|
||||
from django.contrib.postgres.search import SearchQuery, SearchVector
|
||||
@@ -171,6 +188,28 @@ class ConversationDetailView(ConversationViewBase):
|
||||
}
|
||||
)
|
||||
|
||||
def delete(self, request: Request, conversation_id: int) -> Response:
|
||||
"""Удалить диалог вместе с перепиской — насовсем.
|
||||
|
||||
Право есть только у владельца и администратора: оператор закрывает
|
||||
диалог и помечает спам, но не стирает историю организации.
|
||||
"""
|
||||
try:
|
||||
conversation = self._conversation(
|
||||
request, conversation_id, "conversations.operate"
|
||||
)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": t("conversations.not_found")}, status=404)
|
||||
if not can_administer_access(request.tenant_context.membership):
|
||||
return Response(
|
||||
{"detail": t("conversations.delete_admin_only")}, status=403
|
||||
)
|
||||
# Запись журнала — до удаления: после него у диалога уже нет ни строки,
|
||||
# ни идентификатора, который можно было бы записать.
|
||||
self._audit(request, "deleted", conversation)
|
||||
delete_conversation(context=request.tenant_context, conversation=conversation)
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class ConversationClaimView(ConversationViewBase):
|
||||
required_capability = "conversations.operate"
|
||||
@@ -469,8 +508,11 @@ class ConversationAssigneeView(ConversationViewBase):
|
||||
if membership is None:
|
||||
return Response({"detail": t("admin.employee_not_found")}, status=400)
|
||||
assignee = membership.user
|
||||
conversation.assigned_operator = assignee
|
||||
conversation.save(update_fields=["assigned_operator"])
|
||||
conversation = assign_operator(
|
||||
context=request.tenant_context,
|
||||
conversation_id=conversation.id,
|
||||
assignee=assignee,
|
||||
)
|
||||
self._audit(request, "assignee_changed", conversation)
|
||||
return Response(
|
||||
{
|
||||
|
||||
Loaded 100 of 525 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user